Back to Performance
Performance
easy
mid

How would you implement debounced inputs to improve perceived performance?

Debounce delays an action until N ms after the LAST event (waits for the user to stop). Throttle caps the rate (at most one per N ms). Debounce is right for search-as-you-type, autosave, resize handlers. Throttle is right for scroll, drag, mousemove updates. Implement with lodash.debounce/throttle or hand-rolled with setTimeout + clearTimeout. In React, useDeferredValue or use-debounce hook so the debounced value plays nicely with state.

7 min read·~10 min to think through

Input events fire frequently — every keystroke, every scroll pixel, every pointer move. Reacting to each one wastes work and often causes jank. Debounce and throttle are the two main rate-limiting patterns.

Debounce — wait for quiet

Fires once after the input has stopped for N ms.

js
function debounce(fn, ms) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), ms);
  };
}

const onSearch = debounce(q => fetch(`/search?q=${q}`), 300);

If the user types fast: many events come in, each cancels the previous timer; only the last (after they pause) fires.

Use for:

  • Search-as-you-type input.
  • Autosave drafts.
  • Resize-triggered relayout.
  • "User stopped typing" detection.

Throttle — cap the rate

Fires at most once per N ms, no matter how many events come in.

js
function throttle(fn, ms) {
  let last = 0;
  let timer;
  return function (...args) {
    const now = Date.now();
    const wait = ms - (now - last);
    if (wait <= 0) {
      last = now;
      fn.apply(this, args);
    } else {
      clearTimeout(timer);
      timer = setTimeout(() => {
        last = Date.now();
        fn.apply(this, args);
      }, wait);
    }
  };
}

const onScroll = throttle(() => updateHeader(), 100);

Use for:

  • Scroll position tracking.
  • Mousemove updates.
  • Drag handlers.
  • API rate limiting from the client.

In React

jsx
import { useDeferredValue, useTransition, useState } from 'react';

function Search() {
  const [query, setQuery] = useState('');
  const deferred = useDeferredValue(query);
  const results = useSearchResults(deferred);   // hook that fetches/computes

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <Results items={results} />
    </>
  );
}

useDeferredValue defers updating the dependent computation until the input stops changing — similar effect to debounce but baked into the render scheduler.

For "real" debounce on the value itself:

jsx
import { useDebounce } from 'use-debounce';

function Search() {
  const [query, setQuery] = useState('');
  const [debounced] = useDebounce(query, 300);

  useEffect(() => {
    if (debounced) fetch(`/search?q=${debounced}`);
  }, [debounced]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

Combining with cancellation

Debounce reduces the number of requests; AbortController cancels the ones still in flight when the input changes:

jsx
useEffect(() => {
  if (!debounced) return;
  const ctrl = new AbortController();
  fetch(`/search?q=${debounced}`, { signal: ctrl.signal })
    .then(r => r.json()).then(setResults)
    .catch(err => err.name !== 'AbortError' && setError(err));
  return () => ctrl.abort();
}, [debounced]);

Throttling animations

For scroll/drag/mousemove tied to visual updates, prefer requestAnimationFrame coalescing over time-based throttle:

js
let scheduled = false;
window.addEventListener('scroll', () => {
  if (scheduled) return;
  scheduled = true;
  requestAnimationFrame(() => {
    updateUI();
    scheduled = false;
  });
});

This caps work to one per paint frame (16ms at 60fps) regardless of event rate, and aligns with the browser's render schedule.

leading vs trailing edges

  • Trailing (default in most implementations): fire at the end of the wait. Common for debounce.
  • Leading: fire immediately on the first event, then suppress until the timer expires. Useful for "fire on click, ignore double-clicks for 500ms."
  • Both: rare but useful for "respond instantly + send a final update."

Lodash and most libraries take { leading, trailing, maxWait } options.

Pitfalls

  • Debounce inside renderconst debounced = debounce(fn, 300) recreates a new debounced function every render → never fires. Always memoize with useMemo or use useCallback.
  • Stale closures — if the debounced function captures state, it's pinned to the value at creation. Use refs or useDebouncedCallback that updates the ref.
  • Forgetting cleanup — pending timers on unmount keep the component alive; cancel on unmount.
  • Wrong delay — 100ms for search feels twitchy; 800ms feels sluggish. 250–400ms is a sweet spot for search-as-you-type.
  • Throttling animations with setTimeout instead of rAF — janky vs aligned with frames.
  • Debouncing analytics — you might lose events on close. Use beacons for end-of-life events.

Mental model

Debounce = "tell me when they stop." Throttle = "tell me at most every N ms." Pick based on whether you care about the final value (debounce) or sampled progress (throttle). For 60fps UI updates, use requestAnimationFrame coalescing instead.

Follow-up questions

  • When would you use leading-edge debounce?
  • What's the difference between throttle and requestAnimationFrame?
  • How does useDeferredValue differ from useDebounce?
  • Why might a debounced callback in React never fire?

Common mistakes

  • Creating the debounced function inside render — never fires consistently.
  • Stale closures — debounced function references old state.
  • Forgetting to cancel on unmount — timers fire on unmounted components.
  • Throttling with setTimeout for animations — use rAF instead.
  • Debouncing analytics with no flush — events lost on page close.
  • Picking wrong delay — too fast feels twitchy, too slow feels broken.

Performance considerations

  • Debounce cuts request rate dramatically (5 keystrokes/sec → 1 request after pause). Throttle bounds CPU (60 scroll events/sec → 10 updates/sec). rAF coalescing caps at the display rate. Combined with abort/cancel, search-as-you-type can go from 'crushing the API' to 'one request after the user pauses.'

Edge cases

  • maxWait in lodash debounce — fires at least every maxWait even if events keep coming.
  • Throttle with leading: false suppresses the first event — usually wrong for UX.
  • Pointermove + rAF combo gives smoothest drag UX (see drag question).
  • Server-sent events (SSE): no need to throttle — server controls rate.
  • iOS Safari may throttle background tab timers — debounce delays effectively get longer.

Real-world examples

  • GitHub omnisearch debounces input.
  • Google Search uses leading debounce for instant feedback + trailing for full results.
  • Scroll-driven nav bar shrink: rAF throttle.
  • Form autosave: debounce 1-2s after last edit.

Senior engineer discussion

Seniors pick the right primitive: debounce for 'final value', throttle for 'progress', rAF for 'frame-aligned animation'. They memoize debounced functions in React, cancel pending timers on unmount, and combine with AbortController for full race protection. They also know useDeferredValue is the React-native alternative for many search-as-you-type cases.

Related questions