`useQueryStates` subscriber permanently stuck on a stale value after overlapping `shallow: false` updates when server responses are slow (still on 2.9.1)
Context
What's your version of nuqs?
"nuqs": "^2.9.1"What framework are you using?
- ✅ Next.js (app router)
- ❌ Next.js (pages router)
- ❌ React SPA (no router)
- ❌ Remix
- ❌ React Router
Which version of your framework are you using?
"next": "16.2.10",
"react": "19.2.7",
"react-dom": "19.2.7"(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- (previously: a separate
mapQclear onlimitUrlUpdates: debounce(400)— called synchronously with #1 but flushing as its own update) - ~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:
useQueryStateswith many keys (I have 18; includescity: parseAsArrayOf(parseAsString).withDefault([])),{ shallow: false }, rendering the value ofcity— 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:
useQueryStatesover 4 numeric keys with{ shallow: false, limitUrlUpdates: debounce(500) }, writing new values.
- A:
- 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).
Source: 47ng/nuqs