#11320·query

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

Author: AdzerKICreated Aug 28, 2026Updated Sep 13, 2026

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.