Server-side gcTime schedules a GC timer that pins the whole SSR async context
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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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