Skip to content

fix(query-core): never schedule gc timers on the server - #12135

Open
A-Raphie wants to merge 2 commits into
TanStack:mainfrom
A-Raphie:no-server-gc-timers
Open

A-Raphie wants to merge 2 commits into
TanStack:mainfrom
A-Raphie:no-server-gc-timers

Conversation

@A-Raphie

@A-Raphie A-Raphie commented Oct 19, 2026 •

Copy link
Copy Markdown

On the server, a query or mutation with an explicit finite gcTime schedules a real setTimeout during SSR: the server default of Infinity is applied through updateGcTime's fallback, which an explicit value bypasses. The timer serves no purpose - a request-scoped QueryClient is discarded together with the response - but a pending timer keeps the render's async context (AsyncLocalStorage) alive until it fires, retaining the HTML, the RSC payload, and the Request held by react-dom/server.

Reported in #12025 with a real-world case: a single useQuery({ gcTime: 24h }) in a Next.js root provider left a 24-hour timer and ~1.4 MB of retained memory behind on every render; an idle replica accumulated 272 Request objects and 268 pending timers before being OOM-killed.

scheduleGc now returns early in server environments, so no gc timer is scheduled for any gcTime value - the same behavior as the server default. Client behavior is unchanged (the server check is the existing environmentManager detection, already used for the gcTime fallback and retry defaults).

Regression test: with the server environment forced, a finished query with gcTime: 1000 must leave vi.getTimerCount() at 0 and still be in the cache after advancing past the gcTime. Verified to fail before (expected 1 to be +0) and pass after. Full query + mutation suites pass (81/81).

Fixes #12025

Summary by CodeRabbit

  • Bug Fixes
    • Queries fetched optimistically can now use the current observer’s query function and options, including when the query was recreated without an observer.
    • On the server, queries with finite cache lifetimes remain available in the cache instead of being removed by a scheduled garbage-collection timer.

…s queries still fetch

fetchOptimistic builds the query from the observer's options but then calls query.fetch() without them. QueryCache.build returns an existing query without applying options, so a query that was rebuilt without a queryFn - by broadcastQueryClient from a cross-tab message, or hydrated - throws Missing queryFn when a suspending observer fetches it before any observer has attached: the fallback at the top of fetch() only looks at already-attached observers, and there are none yet in this path.

Reported through query-broadcast-client-experimental: when a query is removed and re-added cross-tab, the rebuilt query stays without a queryFn if the other tab's fetch never succeeds, and a mounted useSuspenseQuery crashes with Missing queryFn on its next optimistic fetch instead of fetching with its own options.

The regression test reproduces that sequence: observer bound -> query removed -> rebuilt from key+hash only -> fetchOptimistic. It fails with Missing queryFn before this change and succeeds after. Full queryObserver suite (76) and broadcast client suite (13) pass.

Fixes TanStack#12129
The server default of gcTime: Infinity prevents gc timers during SSR, but an explicit finite gcTime on a query or mutation bypassed it and scheduled a real setTimeout. On the server the timer serves no purpose - a request-scoped QueryClient is discarded with the response - but a pending timer keeps the render's async context alive until it fires, retaining the HTML, the RSC payload and the Request held by react-dom/server. With useQuery({ gcTime: 24h }) in a root provider, every render of a Next.js app left behind a 24-hour timer and ~1.4MB of retained memory until replicas were OOM-killed.

scheduleGc now returns early in server environments. Client behavior is unchanged.

Fixes TanStack#12025
@changeset-bot

changeset-bot Bot commented Oct 19, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 807e412

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: TanStack/query/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 4ce677cd-6f47-4340-aad4-df83107b24c3
📥 Commits

Reviewing files that changed from the base of the PR and between 8c3f218 and 807e412.

📒 Files selected for processing: 4
  • packages/query-core/src/__tests__/query.test.tsx
  • packages/query-core/src/__tests__/queryObserver.test.tsx
  • packages/query-core/src/queryObserver.ts
  • packages/query-core/src/removable.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

📝 Walkthrough

The changes prevent server-side garbage-collection timers for finite gcTime values. They also make fetchOptimistic pass the observer’s defaulted options to Query.fetch. Tests cover both behaviors.

Changes: Server-side garbage collection

Layer / File(s) Summary
Skip GC timers on the server
packages/query-core/src/removable.ts, packages/query-core/src/__tests__/query.test.tsx
Removable.scheduleGc exits on the server. A test checks that finite gcTime schedules no timer and that the query remains cached after time advances.

Optimistic query fetch

Layer / File(s) Summary
Pass observer options to fetchOptimistic
packages/query-core/src/queryObserver.ts, packages/query-core/src/__tests__/queryObserver.test.tsx
fetchOptimistic passes its defaulted options to Query.fetch. A regression test checks the fetch succeeds when the query was rebuilt without a query function.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

Suggested reviewers: sukvvon, amariahak

Merge Risk: ⚪ Minimal · up to 807e4

This change stops garbage-collection timers from being scheduled on the server and makes optimistic fetches use the observer's options. No actionable merge-blocking risk was identified.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Out of Scope Changes check Warning The change to QueryObserver.fetchOptimistic and its test address fetching a rebuilt query that has no queryFn. This behavior is unrelated to issue #12025, which concerns server-side GC timers and … Remove the queryObserver.ts change and its regression test, or move them to a separate pull request with the relevant linked issue.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly and concisely summarizes the primary change: preventing garbage-collection timers on the server.
Description check Passed The description clearly explains the problem, motivation, implementation, regression test, test results, and linked issue. It does not reproduce the template headings or checklist, but it provides the…
Linked Issues check Passed Issue #12025 requires no GC timer on the server for any gcTime. Removable.scheduleGc now clears an existing timeout and returns when isServerEnvironment() is true. This covers the shared removab…
Docstring Coverage Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
Full details: Description check

Explanation

The description clearly explains the problem, motivation, implementation, regression test, test results, and linked issue. It does not reproduce the template headings or checklist, but it provides the required substantive information and is mostly complete.

Full details: Linked Issues check

Explanation

Issue #12025 requires no GC timer on the server for any gcTime. Removable.scheduleGc now clears an existing timeout and returns when isServerEnvironment() is true. This covers the shared removable path used by queries and mutations. The new regression test uses finite gcTime: 1000, verifies zero timers, advances time, and verifies that the query remains cached.

Full details: Out of Scope Changes check

Explanation

The change to QueryObserver.fetchOptimistic and its test address fetching a rebuilt query that has no queryFn. This behavior is unrelated to issue #12025, which concerns server-side GC timers and retained SSR context. The current PR description also does not identify this second objective.

Resolution

Remove the queryObserver.ts change and its regression test, or move them to a separate pull request with the relevant linked issue.

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explicit gcTime schedules a GC timer during SSR, retaining the whole render in memory

1 participant