#1498·nuqs

`useQueryStates` subscriber permanently stuck on a stale value after overlapping `shallow: false` updates when server responses are slow (still on 2.9.1)

Author: LaserGodsCreated Jul 20, 2026Updated Jul 21, 2026
Labelsbug

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):

  1. setQuery({ city: [...], mapQ: null, page: 0 }) on an 18-parser map — default 50ms throttle
  2. (previously: a separate mapQ clear on limitUrlUpdates: debounce(400) — called synchronously with #1 but flushing as its own update)
  3. ~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.:

  1. 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.
  2. 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.
  3. 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).