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.
Describe the bug
On the server
updateGcTimefalls back toInfinity, so by default no GC timer is scheduled and the per-requestQueryClientis simply dropped with the response. As soon as a query passes an explicit finitegcTime— a normal client-side option, e.g. "keep the platform limits for a day" — that fallback is bypassed andscheduleGc()schedules a realsetTimeoutduring 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
AsyncLocalStoragestore alive for the wholegcTime— and with it everything the framework stores there: the work store, thereact-dom/serverRequest, 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 livereact-dom/serverRequestobjects and 268 pending timers, retained throughTimeout → [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
The script simulates 2000 SSR renders. Each render runs inside an
AsyncLocalStoragestore holding a 512 KB payload, creates a per-requestQueryClientand builds one query. All references are dropped andglobal.gc()is called twice before measuring.Expected behavior
The server never schedules a GC timer, whatever
gcTimethe query asks for — the same outcome theisServer ? Infinitydefault already produces. The render context is released as soon as the response is done.How often does this bug happen?
Every time
Platform
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:This keeps the
gcTimevalue 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.