Overview
When you update multiple search params in quick succession, nuqs automatically batches them into a single URL update. This prevents redundant history entries and avoids browser rate limiting.
const [lat, setLat] = useQueryState('lat', parseAsFloat)
const [lng, setLng] = useQueryState('lng', parseAsFloat)
function updateLocation() {
setLat(48.8566) // Queued
setLng(2.3522) // Queued
// Both updates applied together to URL: ?lat=48.8566&lng=2.3522
}
How batching works
Event loop batching
All state updates in the same JavaScript event loop tick are automatically batched:
const [search, setSearch] = useQueryState('q')
const [page, setPage] = useQueryState('page', parseAsInteger)
const [sort, setSort] = useQueryState('sort')
function applyFilters() {
setSearch('react')
setPage(1)
setSort('newest')
// Single URL update: ?q=react&page=1&sort=newest
}
From the README:
“You can call as many state update functions as needed in a single event loop tick, and they will be applied to the URL asynchronously.”
The update queue
Internally, nuqs uses a ThrottledQueue to manage updates:
// From packages/nuqs/src/lib/queues/throttle.ts:31-44
export class ThrottledQueue {
updateMap: UpdateMap = new Map() // Stores pending updates
options: Required<AdapterOptions> = {
history: 'replace',
scroll: false,
shallow: true
}
timeMs: number = defaultRateLimit.timeMs
transitions: TransitionSet = new Set()
// ...
}
When you call setState:
- Update is added to the queue
- If another update for the same key exists, it’s replaced
- Options are merged (preserving the most permissive settings)
- A flush is scheduled for the next tick
From packages/nuqs/src/lib/queues/throttle.ts:45-72:
push({ key, query, options }: UpdateQueuePushArgs, timeMs: number): void {
// Enqueue update
this.updateMap.set(key, query)
// Merge options (preserve most permissive)
if (options.history === 'push') {
this.options.history = 'push'
}
if (options.scroll) {
this.options.scroll = true
}
if (options.shallow === false) {
this.options.shallow = false
}
// Keep the maximum throttle value
if (!Number.isFinite(this.timeMs) || timeMs > this.timeMs) {
this.timeMs = timeMs
}
}
Flushing the queue
The queue flushes on the next event loop tick, respecting the throttle limit:
// From packages/nuqs/src/lib/queues/throttle.ts:120-143
const runOnNextTick = () => {
const now = performance.now()
const timeSinceLastFlush = now - this.lastFlushedAt
const timeMs = this.timeMs
const flushInMs = rateLimitFactor * Math.max(0, timeMs - timeSinceLastFlush)
if (flushInMs === 0) {
flushNow() // Flush immediately if enough time has passed
} else {
timeout(flushNow, flushInMs, this.controller!.signal) // Wait
}
}
Throttling
Default throttle
To prevent browser rate limiting, nuqs throttles URL updates:
- Chrome/Firefox: 50ms between updates (default)
- Safari 17+: 120ms between updates
- Safari <17: 320ms between updates
The default is automatically detected:
// From packages/nuqs/src/lib/queues/rate-limiting.ts:6-20
function getDefaultThrottle() {
if (typeof window === 'undefined') return 50
const isSafari = Boolean(window.GestureEvent)
if (!isSafari) return 50
try {
const match = navigator.userAgent?.match(/version\/([\d\.]+) safari/i)
return parseFloat(match![1]!) >= 17 ? 120 : 320
} catch {
return 320
}
}
Why throttle?
Browsers rate-limit history API calls to prevent abuse:
- Safari allows only 100 updates per 10 seconds (Safari 17+) or 30 seconds (older)
- Exceeding this causes errors and breaks navigation
Throttling only affects URL updates and server requests. State updates are always immediate to keep UI responsive.
Custom throttle values
You can override the default throttle:
import { throttle } from 'nuqs'
const [query, setQuery] = useQueryState('q', {
shallow: false,
limitUrlUpdates: throttle(1000) // Max 1 server request per second
})
Minimum throttle value
Values below 50ms are ignored:
throttle(10) // Treated as 50ms
throttle(0) // Treated as 50ms
This prevents rate-limiting errors even if you try to disable throttling.
Debouncing
For high-frequency updates (like text input), debouncing waits for a pause before updating the URL:
import { debounce } from 'nuqs'
const [query, setQuery] = useQueryState('q', {
shallow: false,
limitUrlUpdates: debounce(300) // Wait 300ms after typing stops
})
function SearchInput() {
return (
<input
value={query ?? ''}
onChange={e => setQuery(e.target.value)}
/>
)
}
Behavior:
- User types “h” → state updates, debounce starts
- User types “e” → state updates, debounce resets
- User types “llo” → state updates, debounce resets
- 300ms passes with no typing → URL updates to
?q=hello
This is especially useful with shallow: false to avoid server requests on every keystroke.
Await URL updates
All setState functions return a Promise that resolves when the URL has been updated:
const [lat, setLat] = useQueryState('lat', parseAsFloat)
const [lng, setLng] = useQueryState('lng', parseAsFloat)
async function updateLocation() {
setLat(48.8566)
const search = await setLng(2.3522)
// URL has been updated
console.log(search.get('lat')) // '48.8566'
console.log(search.get('lng')) // '2.3522'
}
From the README:
“If you wish to know when the URL has been updated, and what it contains, you can await the Promise returned by the state updater function, which gives you the updated URLSearchParameters object.”
Promise caching
The returned Promise is cached until the next flush:
const promise1 = setLat(48.8566)
const promise2 = setLng(2.3522)
promise1 === promise2 // true (same event loop tick)
From the README:
“The returned Promise is cached until the next flush to the URL occurs, so all calls to a setState (of any hook) in the same event loop tick will return the same Promise reference.”
Option merging
When batching updates with different options, nuqs merges them intelligently:
History mode
If any update uses push, all updates use push:
setLat(48.8566, { history: 'replace' })
setLng(2.3522, { history: 'push' })
// Result: Both updates use 'push'
From packages/nuqs/src/lib/queues/throttle.ts:56-58:
if (options.history === 'push') {
this.options.history = 'push'
}
If any update requests scroll, the page scrolls:
setLat(48.8566, { scroll: false })
setLng(2.3522, { scroll: true })
// Result: Page scrolls to top
Shallow mode
If any update uses shallow: false, the server is notified:
setLat(48.8566, { shallow: true })
setLng(2.3522, { shallow: false })
// Result: Server Components re-render
Throttle time
The highest throttle value wins:
import { throttle } from 'nuqs'
setLat(48.8566, { limitUrlUpdates: throttle(100) })
setLng(2.3522, { limitUrlUpdates: throttle(500) })
// Result: Updates throttled at 500ms
From packages/nuqs/src/lib/queues/throttle.ts:68-71:
// Keep the maximum finite throttle value
if (!Number.isFinite(this.timeMs) || timeMs > this.timeMs) {
this.timeMs = timeMs
}
Using useQueryStates
For related query params that should always update together, use useQueryStates:
import { useQueryStates, parseAsFloat } from 'nuqs'
const [coords, setCoords] = useQueryStates(
{
lat: parseAsFloat.withDefault(45.18),
lng: parseAsFloat.withDefault(5.72)
},
{
history: 'push'
}
)
// Update both at once (guaranteed atomic)
setCoords({ lat: 48.8566, lng: 2.3522 })
// Or update a subset
setCoords({ lat: 48.8566 }) // Only updates lat, preserves lng
From the README.
State updates: O(1)
State updates are synchronous and immediate:
setLat(48.8566)
console.log(lat) // 48.8566 immediately
URL updates: throttled
URL updates are async and throttled:
setLat(48.8566)
console.log(window.location.search) // Still old value
await new Promise(resolve => setTimeout(resolve, 100))
console.log(window.location.search) // Updated
Memory usage
The queue stores only the latest value for each key:
for (let i = 0; i < 1000; i++) {
setLat(i)
}
// Queue size: 1 (only lat=999)
// Memory: O(number of unique keys)
Debugging batching
Enable debug logs to see batching in action:
localStorage.setItem('debug', 'nuqs')
You’ll see logs like:
[nuqs gtq] Enqueueing lat=48.8566
[nuqs gtq] Enqueueing lng=2.3522
[nuqs gtq] Scheduling flush in 50ms. Throttled at 50ms (x1)
[nuqs gtq] Flushing queue {lat: '48.8566', lng: '2.3522'}
From the README debugging section.
Edge cases
Aborting pending updates
Navigating away or unmounting aborts pending updates:
setLat(48.8566)
setLng(2.3522)
router.push('/other-page') // Pending updates are aborted
Infinite throttle
Setting throttleMs: Infinity prevents URL updates entirely:
import { throttle } from 'nuqs'
const [state, setState] = useQueryState('key', {
limitUrlUpdates: throttle(Infinity)
})
setState('value') // State updates, URL never updates
From packages/nuqs/src/lib/queues/throttle.ts:93-96:
if (!Number.isFinite(this.timeMs)) {
debug('[nuqs gtq] Skipping flush due to throttleMs=Infinity')
return Promise.resolve(getSearchParamsSnapshot())
}
Concurrent rendering
Adapters control whether to reset the queue immediately or wait:
type AdapterInterface = {
// ...
autoResetQueueOnUpdate?: boolean
}
Next.js sets this to false to handle React 18’s concurrent rendering:
// From packages/nuqs/src/adapters/next.ts:18
autoResetQueueOnUpdate: false
This prevents race conditions during Suspense boundaries and concurrent updates.