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://github.com/proxy/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

  1. YakuBrangJa commented on Sep 9, 2026

    @YakuBrangJa

    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.

  2. AdzerKI commented on Sep 10, 2026

    @AdzerKI
    Author

    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.

  3. YakuBrangJa commented on Sep 13, 2026

    @YakuBrangJa

    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

        infiniteQueryOptions({
          queryKey: [...postQueries.all(), 'feed', params],
          queryFn: ({ pageParam }) => {
            return get_posts_feed_fn({ data: { cursor: pageParam, ...params } })
          },
          initialPageParam: undefined as string | undefined,
          getNextPageParam: (lastPage) => lastPage.meta.nextCursor ?? undefined,
          select: selectFeed,
          staleTime: Infinity,
          gcTime: 5_000,
        }),

    Hard refresh / Fresh application visit. (30_000 ms)

    Image

    Normal in-app page navigation via <Link /> or navigate (5_000 ms, match with my query option config)

    Image
  4. AdzerKI commented on Sep 13, 2026

    @AdzerKI
    Author

    That is a different one, worth its own issue.

    hydrate() builds the query from defaultOptions.hydrate.queries plus client.defaultQueryOptions() — it never sees the options you pass to infiniteQueryOptions, so the hydrated query starts with the client default gcTime. When your component then observes it, setOptions goes through updateGcTime, which is this.gcTime = Math.max(this.gcTime || 0, newGcTime ?? 3e5): a smaller per-query gcTime can 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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