You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
useQueryStates subscriber permanently stuck on a stale value after overlapping shallow: false updates when server responses are slow (still on 2.9.1) #1498
(cacheComponents: true is enabled, Turbopack in dev)
Description
When several useQueryStates hooks issue shallow: false updates in a short burst (an interactive "write storm"), and each resulting RSC round-trip is slow (roughly ≥500ms — a dev server, a cold serverless function, or a slow network), one multi-key useQueryStates subscriber can re-base onto a stale committed state and then return the stale value forever — while useSearchParams(), window.location.search, and sibling nuqs subscribers (including single-key ones reading the same param) all report the correct value. Only a hard reload (or a later unrelated navigation) recovers it.
v2.9.1 clearly improved this area (thank you!): a related manifestation I had pinned on 2.9.0 — a remove-then-re-add of an array item being swallowed because the writing component's own useQueryStates snapshot was stale at event time — is fixed, A/B-verified 2.9.0 vs 2.9.1 with the same scripted reproduction steps. And when server responses are fast, the divergence I still see always heals within milliseconds. But under slow responses, the sticky case remains on 2.9.1.
The table below is a render-by-render probe from the affected subscriber (a component reading an 18-parser useQueryStates map; city is parseAsArrayOf(parseAsString).withDefault([]) with shallow: false). Columns: what the nuqs hook returned vs useSearchParams().get("city") vs new URLSearchParams(location.search).get("city") at the same render:
t (ms)
nuqs
useSearchParams()
location.search
phase
16874–16895
0505320
—
—
optimistic, after setQuery
17736
0505320 → "" same burst
—
0505320
history flush landed; nuqs flips back
18089–18093
"" → 0505320
0505320
0505320
RSC commit; briefly heals
18110 → ∞
""stuck
0505320
0505320
after a later debounced write from another hook
The write burst that triggers it (all shallow: false, from three different components):
setQuery({ city: [...], mapQ: null, page: 0 }) on an 18-parser map — default 50ms throttle
~1–2s later (after a map camera animation): a 4-key bounds write on limitUrlUpdates: debounce(500) from a third hook
With each RSC round-trip taking 1–3s, updates overlap in flight. The stuck state consistently sets in around update #3. Reducing the number of flushes helps the odds but does not remove the race.
Environment differential (same code, same interaction):
environment
RSC latency
result
next dev
1–3s
stuck, 2/2 runs
next build + next start, same machine
fast
diverges, heals in ~12ms, 1/1
Vercel deployment (cached RSC responses)
tens of ms
never observed
So this hides completely in fast environments — which I suspect is why it survives: any repro needs artificially slow server responses.
Expected: after the transitions settle, every subscriber converges on the committed URL's values.
Actual: one subscriber can keep returning the pre-update value indefinitely while the URL and useSearchParams() are correct.
Workaround I shipped: affected read-only consumers derive their values from window.location.search (with useSearchParams subscribed as a commit-time re-render trigger), and parse via createLoader, keeping useQueryStates for writes only.
Reproduction
Our reproduction is a private app, so I can't link it — but the setup is small, and the load-bearing ingredients are clear; Working on a minimal shareable reproduction based on the recipe above — will update this issue when it's ready.:
App-router page reading searchParams, with an artificial delay in the RSC render to simulate slow responses: await new Promise((r) => setTimeout(r, 800)) — this is the key ingredient; without it the bug heals invisibly.
Three client components:
A: useQueryStates with many keys (I have 18; includes city: parseAsArrayOf(parseAsString).withDefault([])), { shallow: false }, rendering the value of city — the victim subscriber.
B: a button whose handler calls A's-shaped setQuery({ city: ["x"], page: 0 }) from its own hook instance, and ~1.5s later (in my app, this comes from a map camera animation's moveend event; a setTimeout reproduces it). triggers C.
C: useQueryStates over 4 numeric keys with { shallow: false, limitUrlUpdates: debounce(500) }, writing new values.
Click B. Watch A: the value appears optimistically, flips back when the history flush/commit lands, may briefly heal, then sticks at the old value while the address bar and a useSearchParams() probe show the new one.
Timing-dependent, so not 100% per-click in a minimal setup — in my app, it's ~every run with ≥ 500ms RSC latency; repeating the interaction a few times should catch it.
Possibly related: #1469, #1470 (the v2.9.1 fixes — they resolved the event-time-swallow manifestation for me, but not this one).
Context
What's your version of
nuqs?What framework are you using?
Which version of your framework are you using?
(
cacheComponents: trueis enabled, Turbopack in dev)Description
When several
useQueryStateshooks issueshallow: falseupdates in a short burst (an interactive "write storm"), and each resulting RSC round-trip is slow (roughly ≥500ms — a dev server, a cold serverless function, or a slow network), one multi-keyuseQueryStatessubscriber can re-base onto a stale committed state and then return the stale value forever — whileuseSearchParams(),window.location.search, and sibling nuqs subscribers (including single-key ones reading the same param) all report the correct value. Only a hard reload (or a later unrelated navigation) recovers it.v2.9.1 clearly improved this area (thank you!): a related manifestation I had pinned on 2.9.0 — a remove-then-re-add of an array item being swallowed because the writing component's own
useQueryStatessnapshot was stale at event time — is fixed, A/B-verified 2.9.0 vs 2.9.1 with the same scripted reproduction steps. And when server responses are fast, the divergence I still see always heals within milliseconds. But under slow responses, the sticky case remains on 2.9.1.The table below is a render-by-render probe from the affected subscriber (a component reading an 18-parser
useQueryStatesmap;cityisparseAsArrayOf(parseAsString).withDefault([])withshallow: false). Columns: what the nuqs hook returned vsuseSearchParams().get("city")vsnew URLSearchParams(location.search).get("city")at the same render:useSearchParams()location.search0505320setQuery0505320→""same burst0505320""→050532005053200505320""stuck05053200505320The write burst that triggers it (all
shallow: false, from three different components):setQuery({ city: [...], mapQ: null, page: 0 })on an 18-parser map — default 50ms throttlemapQclear onlimitUrlUpdates: debounce(400)— called synchronously with chore(deps-dev): Bump next from 9.3.6 to 9.4.0 #1 but flushing as its own update)limitUrlUpdates: debounce(500)from a third hookWith each RSC round-trip taking 1–3s, updates overlap in flight. The stuck state consistently sets in around update #3. Reducing the number of flushes helps the odds but does not remove the race.
Environment differential (same code, same interaction):
next devnext build+next start, same machineSo this hides completely in fast environments — which I suspect is why it survives: any repro needs artificially slow server responses.
Expected: after the transitions settle, every subscriber converges on the committed URL's values.
Actual: one subscriber can keep returning the pre-update value indefinitely while the URL and
useSearchParams()are correct.Workaround I shipped: affected read-only consumers derive their values from
window.location.search(withuseSearchParamssubscribed as a commit-time re-render trigger), and parse viacreateLoader, keepinguseQueryStatesfor writes only.Reproduction
Our reproduction is a private app, so I can't link it — but the setup is small, and the load-bearing ingredients are clear; Working on a minimal shareable reproduction based on the recipe above — will update this issue when it's ready.:
searchParams, with an artificial delay in the RSC render to simulate slow responses:await new Promise((r) => setTimeout(r, 800))— this is the key ingredient; without it the bug heals invisibly.useQueryStateswith many keys (I have 18; includescity: parseAsArrayOf(parseAsString).withDefault([])),{ shallow: false }, rendering the value ofcity— the victim subscriber.setQuery({ city: ["x"], page: 0 })from its own hook instance, and ~1.5s later (in my app, this comes from a map camera animation's moveend event; a setTimeout reproduces it). triggers C.useQueryStatesover 4 numeric keys with{ shallow: false, limitUrlUpdates: debounce(500) }, writing new values.useSearchParams()probe show the new one.Timing-dependent, so not 100% per-click in a minimal setup — in my app, it's ~every run with ≥ 500ms RSC latency; repeating the interaction a few times should catch it.
Possibly related: #1469, #1470 (the v2.9.1 fixes — they resolved the event-time-swallow manifestation for me, but not this one).