Skip to content

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

Description

@AdzerKI

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions