Skip to content

fix: don't re-suspend consumers awaiting a remote query when its value arrives - #16855

Closed
half2me wants to merge 2 commits into
sveltejs:version-3from
half2me:claude/sveltekit-3-recomputation-freeze-ep9kiv
Closed

fix: don't re-suspend consumers awaiting a remote query when its value arrives#16855
half2me wants to merge 2 commits into
sveltejs:version-3from
half2me:claude/sveltekit-3-recomputation-freeze-ep9kiv

Conversation

@half2me

@half2me half2me commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

closes #16854

Deriveds downstream of a remote query that the page also awaits stop being memoized, so every read re-walks the dependency graph. The cost is exponential in the depth of that graph — enough to lock the main thread on a page that renders a chart, with no error, no failed boundary and no recovery short of a reload.

Cause

Query.set() unconditionally replaced #promise with a fresh resolved promise. That identity change is how a consumer awaiting the query learns a new value is available, and it is needed for a server-initiated single-flight update, an explicit .set(...) and SSR hydration — but not when a request was already in flight.

In that case #clear_pending() has just resolved the promise those consumers are suspended on, and #then reads #current after the promise settles rather than closing over the value, so they already see the new value. Replacing the promise as well invalidated #then a second time and re-suspended an already-awaiting consumer, in a second batch, while the first was still pending.

Two live batches put Svelte into 'time travelling' mode, where update_derived and is_dirty deliberately never mark a derived CLEAN. Everything downstream of the query then recomputes on every read instead of once.

This is a 3.x regression rather than a Svelte one because the response path moved. In 2.64.0 the fetch callback returned the value and #run()'s own .then wrote it, so set() was never involved in a response. In 3.x the callback returns nothing and remote_request injects the value through set(), putting it on the hot path for every query response.

Fix

Replace the promise only when consumers are not already suspended on one, or when the existing promise is a rejection that would otherwise keep being thrown.

Test

test/apps/async/src/routes/remote/query-derived-memoization renders the same seven-level derived graph twice: once fed by a plain awaited promise (the control) and once by an awaited remote query. The graph alternates nodes returning a fresh object with nodes returning a constant, which is the shape that makes lost memoization expensive.

The test counts recomputations rather than timing the interaction, so a regression fails in milliseconds with a number instead of hanging until the test times out, and it asserts the graph actually re-rendered so the control cannot pass while doing nothing.

Measured over one interaction, with svelte@5.56.8 and vite@8.2.1 held constant so the Kit version is the only variable:

recomputations while time travelling total
@sveltejs/kit 2.64.0 0 14
3.0.0-next.23, before this PR 27,307 27,309
3.0.0-next.23, with this PR 0 14

The two commits are separated so the test can be checked out on its own and observed to fail.

Verified separately against the original layerchart reproduction from #16854: FROZEN on the first press before, 25ms/11bars / 25ms/12bars / 18ms/13bars / 27ms/14bars after.


Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests

  • Run the tests with pnpm test and lint the project with pnpm lint and pnpm check

pnpm lint, pnpm check and pnpm -F @sveltejs/kit test:unit (737 passed) are clean. The full test/apps/async suite passes in both dev and build modes apart from four query.live tests that fail identically on an unmodified checkout of main.

Changesets

  • If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running pnpm changeset and following the prompts. Changesets that add features should be minor and those that fix bugs should be patch. Please prefix changeset messages with feat:, fix:, or chore:.

Edits

  • Please ensure that 'Allow edits from maintainers' is checked. PRs without this option may be closed.

🤖 Generated with Claude Code

@pkg-svelte-dev

pkg-svelte-dev Bot commented Aug 20, 2026

Copy link
Copy Markdown

Install the latest version of @sveltejs/kit from cfd8e42:

pnpm add https://pkg.svelte.dev/@sveltejs/kit/c/cfd8e42a0ea59b00030cb608b69d7ffb2233f14f

Open in pkg.svelte.dev: https://pkg.svelte.dev/repos/kit/pr/16855

Note

This PR is from a fork. A maintainer must approve approve each commit before it can be built and installed.

@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cfd8e42

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@sveltejs/kit Patch

Not sure what this means? Click here to learn what changesets are.

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

claude added 2 commits August 25, 2026 19:12
…awaited query

Deriveds that are downstream of a remote query the page also `await`s stop
being memoized: every read re-walks the whole dependency graph, so the cost is
exponential in the depth of the graph. In a real app with a charting library it
locks the main thread — millions of recomputations, no convergence, no recovery.

The fixture reduces that to a dependency-free derived graph seven levels deep.
Each level pairs a derived returning a fresh object (whose write version always
increases) with one returning a constant (whose write version never does), which
is the shape that makes the missing memoization visible. The page renders that
graph twice over: once fed by a plain awaited promise, and once by an awaited
remote query.

The test counts recomputations rather than timing the interaction, so a
regression fails in milliseconds with a number instead of hanging until the test
times out, and it asserts the graph actually re-rendered so the control cannot
pass while doing nothing.

Currently the promise case costs 14 recomputations and the query case 27305.

See sveltejs#16854

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTP5uWeBF5QDLADgw5UysS
`Query.set` always replaced `#promise` with a fresh resolved promise. That is
what tells consumers awaiting the query that a new value is available, and it is
needed for a server-initiated single-flight update, an explicit `.set(...)` and
SSR hydration — but not when a request was already in flight.

In that case `#clear_pending()` has just resolved the promise those consumers are
suspended on, and `#then` reads `#current` after the promise settles rather than
closing over the value, so they already see the new value. Replacing the promise
as well invalidated `#then` a second time and re-suspended a consumer that was
already awaiting the query, in a second batch, while the first was still pending.

Two live batches put Svelte into 'time travelling' mode, where deriveds are
deliberately never marked clean. Everything downstream of the query then
recomputes on every read instead of once, at a cost exponential in the depth of
the derived graph. A page rendering a chart from an awaited query locked up the
main thread: millions of recomputations with no convergence and no recovery.

Only replace the promise when consumers are not already suspended on one, or when
the existing promise is a rejection that would otherwise keep being thrown.

The existing `query-derived-memoization` test now passes: recomputations for one
interaction drop from 27305 to 14, matching both the plain-promise control and
@sveltejs/kit 2.64.0.

Fixes sveltejs#16854

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTP5uWeBF5QDLADgw5UysS
@half2me
half2me force-pushed the claude/sveltekit-3-recomputation-freeze-ep9kiv branch from fbd5f20 to cfd8e42 Compare August 25, 2026 17:12
@Nic-Polumeyv

Nic-Polumeyv commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Thanks! This is unfortunately incorrect. Superseded by #16958. #run() leaves its own resolver in #latest, so had_pending is true for every query that has resolved through #run() and set() then never invalidates awaiters; a client-loaded batch query followed by .set() keeps the stale value.

@half2me

half2me commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @Nic-Polumeyv :)

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.

Chart fed from an awaited remote query freezes the tab (regression in 3.0.0-next; fine on 2.64.0)

3 participants