Debouncing & Throttling
Limit how often expensive or network-bound logic runs when the user (or the browser) fires events rapidly.
The problem I keep seeing
Search boxes that hit the API on every keystroke, scroll handlers that run 60 times per second, resize handlers that recompute layout on every pixel—all of that wastes requests, clogs the main thread, and can leave the UI showing stale or out-of-order results. You need to slow down the reaction to high-frequency events without making the UI feel unresponsive.
Debounce: run after the user (or event source) has been quiet for a period (e.g. 300ms after last keystroke). Throttle: run at most once per period (e.g. scroll handler runs at most every 100ms). Same idea—reduce frequency—different timing model.
Naive approach
Run the effect (or handler) on every change. For search, that’s one request per character; for scroll, dozens per second.
First improvement
Debounce the value: derive a “stable” value that only updates after the user has stopped typing for delay ms. Use that for the API call. Same pattern works for any high-frequency input (e.g. filter inputs).
Why this helps: One request per “burst” of typing. Fewer network calls, and you avoid the race where an older response overwrites a newer one if you don’t cancel in-flight requests (React Query does that when queryKey changes).
Remaining issues
- Scroll/resize: For scroll or resize, debounce can feel laggy (nothing happens until you stop). Throttle is better: run at most every N ms so the UI updates periodically while the user scrolls.
- Leading vs trailing: Debounce is usually “trailing” (after pause). You can also do “leading” (run immediately, then ignore for delay). Throttle often uses “leading” so the first event runs right away.
- Abort in-flight requests: When the debounced value changes, cancel any previous fetch so an old response doesn’t overwrite newer results. React Query’s
queryKeychange does this; with rawfetchuseAbortController.
Production pattern
Keep useDebounce for the input value; feed the debounced value into useQuery as queryKey. Use enabled to avoid requesting when the query is too short. For scroll/resize, use a throttle (or a library like lodash.throttle / use-debounce) so you don’t run on every frame.
When I use this
- Debounce: Search, filter inputs, any text that drives an API call or heavy computation. “Wait until the user pauses.”
- Throttle: Scroll position, window resize, mousemove, progress updates. “Run at most every N ms.”
- Skip: When the operation is cheap and you need every value (e.g. local state for controlled input). Don’t debounce the input value itself—only the side effect.
Rule of thumb
Gotchas
- Stale closure in throttle: If you throttle a callback that reads state, the callback may see an old state. Use a ref for the latest value, or ensure the throttled function is recreated when deps change.
- Leading edge: For “submit on first keystroke after idle” (e.g. run search as soon as user types, then debounce), use a debounce with
leading: trueor a custom implementation. - React 18 Strict Mode: Effects run twice in dev. Your debounce/throttle should still behave correctly—cleanup clears the timer. If you store “last run” in a ref, double-mount doesn’t break it.