Repository navigation
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.
Activity
YakuBrangJa commentedon Sep 9, 2026
Could this be the same issue I'm experiencing?
The issue is that, the queryClient data loaded on the server never gets garbage collected.
For example, data loaded during a fresh visit or page reload seems to never get garbage collected, while data loaded through normal in-app navigation is garbage collected as expected.
AdzerKI commentedon Sep 10, 2026
Depends on where you are looking.
This issue is about the server process: with a finite gcTime the SSR render schedules a Node timer, and that timer pins the render context, so RSS keeps growing across requests. In the browser cache, hydrated queries are collected like any other once nothing observes them.
So — do you set a finite gcTime anywhere, and is the growth in Node RSS or in the browser heap? If it is the browser heap, it is something else.
YakuBrangJa commentedon Sep 13, 2026
I think I found the bug.
The gcTime falls back to default 30_000 ms on page refresh and fresh app visit.
Edit: It happens specifically when the configured query option gcTime is less than the default gcTime. If it's higher than default, it behave normally.
My query option setup
Hard refresh / Fresh application visit. (30_000 ms)
Normal in-app page navigation via
<Link />or navigate (5_000 ms, match with my query option config)AdzerKI commentedon Sep 13, 2026
That is a different one, worth its own issue.
hydrate()builds the query fromdefaultOptions.hydrate.queriesplusclient.defaultQueryOptions()— it never sees the options you pass toinfiniteQueryOptions, so the hydrated query starts with the client defaultgcTime. When your component then observes it,setOptionsgoes throughupdateGcTime, which isthis.gcTime = Math.max(this.gcTime || 0, newGcTime ?? 3e5): a smaller per-querygcTimecan never win over what hydration already put there. On in-app navigation there is no hydrated query, the observer builds it, and your 5s applies — which is exactly the split you are seeing.This issue is about the server scheduling the GC timer at all, and the timer pinning the render context.
fix(query): stop a 24h gcTime pinning every SSR render (#121)
rohitpotato commentedon Nov 5, 2026
For anyone landing here later: as of early November, scheduleGc() on main still starts a real timer for any finite gcTime, including during SSR. Only the default falls back to Infinity on the server, so this still bites if your hooks or defaultOptions set their own gcTime.
We hit the same thing on v3 (cacheTime): pods at ~5 GB, every finished SSR request retained through Timeout → AsyncContextFrame → … → Query. Our workaround was a server-only QueryCache that builds every query with an infinite cache time, so nothing is scheduled during SSR. Pods went from ~4.8 GiB to ~1.2 GiB.
I wrote up how we found it (heap snapshots, a BFS from the GC roots, the timer lists) and the workaround, in case it's useful: https://rohitpotato.xyz/thoughts/ssr-requests-still-alive
slorber commentedon Nov 11, 2026
Quite interesting findings @rohitpotato, I was not aware of this AsyncLocalSorage + setTimeout behavior even though it makes sense.
Note that it's already kind-of documented here, although it doesn't mention the possible ALS leak it still recommends either Infinity, a relatively short timeout, or an explicit
queryClient.clear()call.https://tanstack.com/query/latest/docs/framework/react/guides/ssr#high-memory-consumption-on-server
As far as I understand the main problem is that you could inadvertently restore the GC timer on the server, while you most likely only wanted it on the client:
Maybe a less risky API would be to have a separate config for the server gc timer?
rohitpotato commentedon Nov 12, 2026
Yes, @slorber, a server gcTime flag makes sense. For now, I have opened a PR to fix the docs to reflect the real reasoning behind the issue, how it can be easily missed, and how to avoid it.
#12589
The server gcTime change requires a change in the public API, i might open another PR to propose that as well.