Skip to content

ADR-0023 Debounce & throttle

Company standard debounce and throttle — TanStack Pacer's React hooks, pinned to exact versions while pre-1.0; server-side limits stay with ADR-0010.

Decision
TanStack Pacer, pinnedBuild the app
Status
Accepted
Date
Scope
Company-wide default for all new apps

Browser events arrive faster than anything should react to them: a keystroke per character, dozens of scroll and resize events per second, an autosave on every edit. Without a gate, each one becomes a render, a URL write, or a request. The surrounding decisions are made:

  • Reads and URL state: filters live in the URL and reach Query through loaderDeps and the query key (ADR-0012)
  • Writes: POST server functions called from useMutation; every write passes the server rate limiter (ADR-0010), whose starting limits are 100 writes per 60 s per IP and 300 per 60 s per user
  • Components: shadcn/ui on Base UI, with className, ref, and event handlers forwarded (ADR-0011)

What is open: which library supplies debounce and throttle, how it connects to Query and the URL, and where client-side timing ends and server-side limits begin.

Constraints:

  • Two different things are called “rate limiting.” The server-side limiter (ADR-0010) stops abuse. A client-side limiter only shapes what a well-behaved browser sends — any client can skip it, so it protects the server from nothing
  • Pacer is pre-1.0 and says so. Its overview calls it beta with an API “still subject to change”; the packages are @tanstack/pacer 0.23.1 and @tanstack/react-pacer 0.24.1 (published 2026-10-05). The pre-1.0 policy is the one ADR-0008 set for Drizzle v1 while it was a release candidate: adopt, pin exact versions, upgrade deliberately

Debouncing, throttling, rate limiting, queuing, and batching behind one API, in sync and async variants.

Pros

  • One API for debounce and throttle, at three levels: a callback (useDebouncedCallback), a value (useDebouncedValue), or the instance (useDebouncer, with flush, cancel, and isPending). The async variants add awaited results, onError / onSuccess / onSettled, retries, and abort through an AbortSignal
  • The hooks keep one instance across renders and cancel pending work on unmount — the two things hand-written versions most often get wrong
  • Pending state you can render (isPending, isExecuting), subscribed only for the fields you select; default options set once with PacerProvider
  • Same project family as Router, Query, and Form — Pacer’s overview says its code was extracted from them. MIT; peer range react >=16.8; @tanstack/react-pacer depends on exactly @tanstack/pacer 0.23.1 and re-exports it

Cons

  • Beta, and it has already broken inside a 0.x minor: @tanstack/pacer 0.23.0 (2026-09-28) went ESM-only, dropped CommonJS, and renamed state fields (executeCount to executionCount, settledCount to settleCount). Even patch 0.23.1 added six framework adapters and changed how queue state snapshots are replaced
  • No maxWait: a continuous stream of calls can postpone a trailing debounce forever. Pacer’s guide points to throttling when work must run at a bounded interval
  • Unmount cancels pending work; it does not flush it. A trailing call is lost unless we flush it ourselves
  • By its own description “mostly a client-side library today” — we use it for browser events only

Hooks useDebounce, useDebouncedCallback, and useThrottledCallback; maxWait, cancel, flush, and isPending. 10.1.1, MIT.

Pros: does debounce and throttle for React and nothing else; stable major; has maxWait. Cons: no async error handling, retry, or abort; nothing for rate limiting, queuing, or batching; a different maintainer and release train. For debounce and throttle alone it is as good as Pacer today — and our fallback if Pacer disappoints.

4.18.1, MIT. leading, trailing, and maxWait options; cancel() and flush() on the returned function.

Pros: ubiquitous and battle-tested; has maxWait. Cons: plain functions, not React-aware. Every use needs the instance kept stable across renders, cancelled on unmount, and its callback kept fresh — the hand-written hook problem again. No pending state, no async handling, no abort.

1.52.0, MIT. debounce takes an AbortSignal and edges (leading / trailing) and returns cancel() and flush(); throttle is alongside.

Pros: modern and typed; abort support. Cons: plain functions again, with the same React lifecycle work as lodash-es. Adopting it for two functions means adopting a general utility library, which is a separate decision we have not made.

About fifteen lines of setTimeout, useRef, and cleanup.

Pros: zero dependencies and zero pre-1.0 risk. Cons: every product rewrites leading/trailing edges, cancel, flush, pending state, and abort, and gets one subtly wrong. No throttle unless someone writes it too. Behavior differs from product to product.

Not time-based: React renders the deferred value in the background, interruptibly, with no fixed delay.

Pros: nothing to tune, adapts to the device, built in. Cons: React’s docs are explicit that it “does not by itself prevent extra network requests.” It is enough when the expensive part is rendering from data already in memory, such as filtering a large local list. It is not enough when the input triggers a request, a URL write, or a mutation — those need a real delay.

TanStack Pacer, through @tanstack/react-pacer pinned to exact versions, is the company standard for debounce and throttle in all new apps. Pacer’s rate limiter, queuer, and batcher are available, but are client-side only.

Concretely:

  • Package: @tanstack/react-pacer 0.24.1, installed with bun add --exact. It re-exports the core and pins @tanstack/pacer 0.23.1 itself, so the core is not installed separately
  • Pin exact versions (no ^) while Pacer is pre-1.0; upgrade deliberately, as in ADR-0008. Renovate (ADR-0016) opens the PR but never auto-merges it; a human reads the release notes first. Revisit at 1.0
  • Pick the narrowest hook. useDebouncedValue or useDebouncedCallback by default; useDebouncer only when the code needs flush, cancel, or isPending; the async hooks only for async side effects, never for fetching data
  • Search inputs: the draft stays in local useState, so typing is instant. useDebouncedValue produces the committed value. Shareable filters write it to the URL search params with navigate({ search, replace: true }) — replace so each pause doesn’t add a history entry — and flow on through loaderDeps into the query key (ADR-0012). Local-only filters pass the committed value straight into the query key. Add placeholderData: keepPreviousData so the list doesn’t blank between keys. Debounce the URL write; never debounce the loader
  • Pacer never holds server data. Don’t use useAsyncDebouncer to fetch into component state, as Pacer’s own quick start does. Query owns server state, with caching and deduplication (ADR-0012); Pacer only decides when the key changes
  • Debounced writes are not guaranteed writes. Autosave is a debounced useMutation call. Pending work is dropped on unmount and navigation, so flush explicitly (flush()) on blur and on route leave. Writes still go through POST server functions and the server limiter (ADR-0010); handle 429 with its Retry-After. Never retry a write automatically unless it is idempotent — Pacer’s own retry guide warns that writes can create duplicates
  • Throttle continuous events (scroll, resize, pointer move) with useThrottledCallback: the first call runs now and the latest later call runs at the trailing edge. Use throttle, not debounce, whenever work must keep running while events arrive, because debounce has no maxWait
  • Rate-limiting boundary. Server limits (ADR-0010) are the only limits that count; flood protection is the reverse proxy’s (ADR-0017). Pacer’s rate limiter is an in-memory, in-browser counter; its guide says it is “not a distributed quota or enforcement system.” Use it for UX only — for example, disabling a send button with “try again in N s” — and never to replace or relax a server limit, or to gate anything security-related
  • Wait values are set once, not scattered. Set PacerProvider defaultOptions at the root (debouncer.wait of 300 ms to start); hooks override with named constants where a use case differs (about 1000 ms for autosave, 200 ms for scroll and resize throttles). These are starting values taken from Pacer’s own examples, not measurements — tune per product
  • SOLID: hooks own timing and state; components stay presentational. A feature hook (for example useSearchFilter) owns the draft, the debounce, and the URL write, and returns { value, onChange, isPending }. The input, a shadcn/ui Input (ADR-0011), forwards value, onChange, ref, and className and knows nothing about delays. Components never import @tanstack/react-pacer; only hooks do, so leaving Pacer is a change inside a few hooks. One thin hook per use case, not a generic wrapper
  • useDeferredValue stays available for render cost alone. Use it on top of a debounced value only when the render after the delay is still expensive

The deciding factors: one tested lifecycle for debounce and throttle that cleans up after itself, async work with abort and pending state when a use case needs it, and the same project family as Router, Query, and Form. We accept the pre-1.0 risk, the missing maxWait, and a smaller ecosystem than lodash.

Easier:

  • One API and one lifecycle for debounce and throttle in every product; pending work is cancelled on unmount
  • Search and filters: instant typing, one request per pause, a linkable URL, and the Query cache doing the data work
  • Pending state is renderable (“Searching…”, “Unsaved changes…”) without hand-rolled flags
  • Leaving is cheap: Pacer sits behind a few hooks, and use-debounce covers the same debounce and throttle surface

Harder:

  • Pre-1.0 risk, stated plainly. Pacer’s docs call it beta, and its minors change APIs (0.23.0 renamed state fields and went ESM-only on 2026-09-28). Exact pins mean no automatic fixes: every bump is a deliberate PR checked against the release notes and the tests
  • Revisit triggers: Pacer reaches 1.0 (plan the upgrade window and review the pin policy, as with Drizzle in ADR-0008); a bump breaks an API we use and fixing it costs more than moving the few hooks to use-debounce; or the project stops releasing
  • Trailing calls are lost on unmount unless flushed, and the sync hooks do not catch errors thrown by the callback. In the async variants, a replaced call’s promise resolves at once with the previous lastResult — only the latest call’s promise carries the new result
  • No maxWait: a long burst of calls can delay a debounced run indefinitely; choose throttle when a bound matters
  • Online examples may predate renames; check the docs for the pinned version, not latest
  • A client-side limiter can give false safety. Review for any change that removes or loosens a server limit “because the client already limits it”
  • Timing code is hard to test; keep wait values in named constants and test the feature hook, not the library

Given up: the stable majors and maxWait of use-debounce and lodash-es, and the zero-risk simplicity of a hand-written hook. Accepted — one lifecycle-aware API with async and abort support, from the project family we already run, is worth the pre-1.0 pin and a planned upgrade at 1.0.