TanStack / TanStack/query

Server-side gcTime schedules a GC timer that pins the whole SSR async context

Open
#11,320 4 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
50.3k
Forks
4.2k
Avg merge
18h 25m
Merged PRs (30d)
200

Description

Describe the bug

On the server updateGcTime falls back to Infinity, so by default no GC timer is scheduled and the per-request QueryClient is simply dropped with the response. As soon as a query passes an explicit finite gcTime — a normal client-side option, e.g. "keep the platform limits for a day" — that fallback is bypassed and scheduleGc() schedules a real setTimeout during server rendering.

A Node timer captures the async context it was created in. A timer created inside an SSR render therefore keeps that render's AsyncLocalStorage store alive for the whole gcTime — and with it everything the framework stores there: the work store, the react-dom/server Request, the produced HTML and the RSC payload. The timer can never do anything useful either: the client it would clean up is unreachable long before it fires.

In our Next.js 16 App Router app one useQuery({ gcTime: 24h }) sits in the root provider, so every page render left one pending 24h timer and ~1.4 MB retained. A heap snapshot of an idle replica showed 272 live react-dom/server Request objects and 268 pending timers, retained through Timeout → [kAsyncContextFrame] → AsyncContextFrame → … → Query. Replicas grew to the pod memory limit and were OOM-killed.

Your minimal, reproducible example

https://gist.github.com/AdzerKI/44081bf1d74a64d43ce651018ce2cba9

Steps to reproduce
npm i @tanstack/query-core
node --expose-gc gc-timer-retains-async-context.mjs default
node --expose-gc gc-timer-retains-async-context.mjs finite

The script simulates 2000 SSR renders. Each render runs inside an AsyncLocalStorage store holding a 512 KB payload, creates a per-request QueryClient and builds one query. All references are dropped and global.gc() is called twice before measuring.

gcTime=default renders=2000 pendingTimers=0    external=1MB    rss=118MB
gcTime=60000  renders=2000 pendingTimers=2000 external=1001MB rss=1068MB
Expected behavior

The server never schedules a GC timer, whatever gcTime the query asks for — the same outcome the isServer ? Infinity default already produces. The render context is released as soon as the response is done.

How often does this bug happen?

Every time

Platform
  • OS: Linux (Node.js 24)
  • Browser: n/a, server-side rendering
Tanstack Query adapter

react-query

TanStack Query version

v5.101.0 (@tanstack/query-core)

TypeScript version

v6.0.3

Additional context

Suggested fix — make scheduleGc() a no-op on the server:

protected scheduleGc(): void {
  this.clearGcTimeout()

  if (isServerEnvironment()) {
    return
  }

  if (isValidTimeout(this.gcTime)) {
    this.#gcTimeout = timeoutManager.setTimeout(() => {
      this.optionalRemove()
    }, this.gcTime)
  }
}

This keeps the gcTime value itself (nothing else reads it on the server) and matches what the server default already does. Long-lived Node processes that do want garbage collection already have an escape hatch: environmentManager.setIsServer(() => false).

Happy to open a PR with this and a test.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by running gc-timer-retains-async-context.mjs in the reported default and finite-gcTime modes, then read updateGcTime and scheduleGc in the query core. Trace how the finite value reaches timeoutManager and compare this with the server default. Done means SSR with any finite gcTime leaves no pending GC timer or retained render context, with regression coverage for the server path.

Written by the indexing model from the issue text.

Assessment

Tech stack
next.js, node.js, react, typescript
Domain
backend, performance
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.