7 min readRishi

Debouncing and Throttling in React: Taming Expensive Event Handlers

You build a search-as-you-type box. Every keystroke fires an API call. A user typing "keyboard" sends eight requests in under a second, the responses arrive out of order, and the results flicker between stale and fresh. Or you wire up a scroll handler that recalculates layout — and now it runs sixty-plus times per second, the page janks, and the fan spins up. Both are the same underlying problem: an event fires far more often than the expensive work behind it needs to run.

The two classic fixes are debouncing and throttling. The ideas take thirty seconds to understand. Implementing them correctly in React takes a bit more care, because React's render model has a way of quietly breaking the naive version. Let me cover both.

Debounce vs. throttle: pick the right one

People mix these up constantly, and using the wrong one produces a subtly bad UX. The distinction:

  • Debounce: wait until the activity stops, then run once. Every new event resets a timer; the function runs only after a quiet gap. Think of an elevator that waits for people to stop getting on before closing the doors.
  • Throttle: run at most once per interval, no matter how many events fire. The function runs immediately, then ignores further calls until the interval passes. Think of a turnstile that admits one person every few seconds regardless of how big the crowd is.

The deciding question is whether you care about the final state or about regular updates during the activity:

Use caseChoiceWhy
Search-as-you-typeDebounceYou only want to search once the user pauses typing
Autosave a draftDebounceSave when editing stops, not mid-word
Validate a field on inputDebounceDon't show "invalid email" while they're still typing
Scroll position / sticky headerThrottleYou want smooth, continuous updates, just fewer of them
Window resize handlerThrottleKeep the layout responsive during the drag, not only at the end
Infinite-scroll triggerThrottleCheck "near bottom?" steadily as they scroll

The rule of thumb: debounce when you only care about the last event; throttle when you want a steady, capped stream of events. Get this choice right first — no amount of clean implementation fixes a debounce that should have been a throttle.

The naive React version is broken

Here's the implementation almost everyone writes first, and why it fails:

function SearchBox() {
  const [query, setQuery] = useState("");

  // ❌ BROKEN: a new debounced function on every render
  const handleChange = debounce((value) => {
    fetchResults(value);
  }, 300);

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

The bug: debounce() returns a stateful function — it holds the pending timer inside a closure. But this code creates a brand-new debounced function on every render. Each keystroke triggers a re-render, which throws away the old debounced function (and its pending timer) and makes a fresh one with no memory of the previous call. The internal timer is reset to nothing every time, so the debounce never actually accumulates — it effectively fires on every keystroke. The thing you added to prevent per-keystroke calls is silently doing per-keystroke calls.

This is the heart of why debounce/throttle in React is trickier than in vanilla JS: in plain JavaScript you create the debounced function once and it persists. In React, re-renders constantly recreate it unless you explicitly tell React to preserve it across renders. The debounced function must be stable across renders, or it has no memory and does nothing.

Doing it correctly

The fix is to create the debounced function once and keep the same instance across renders. useMemo (or useRef) does this:

import { useMemo, useEffect } from "react";
import debounce from "lodash.debounce";

function SearchBox() {
  const [results, setResults] = useState([]);

  const debouncedSearch = useMemo(
    () =>
      debounce(async (value) => {
        const data = await fetchResults(value);
        setResults(data);
      }, 300),
    []                       // created once, stable across renders
  );

  // Cancel any pending call when the component unmounts
  useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch]);

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

Two things make this correct, and both matter:

  1. useMemo(..., []) creates the debounced function a single time. Now its internal timer survives across renders, so it can actually accumulate keystrokes and fire once after the pause.
  2. The cleanup in useEffect calls .cancel() on unmount. Without it, a debounced callback can fire after the component is gone and call setResults on an unmounted component — a memory leak and a potential warning or error. Always cancel pending timers on unmount. This is the step people forget, and it causes the flakiest bugs.

The same structure works for throttle — swap debounce for throttle, keep the useMemo and the cleanup.

The cleaner pattern: a custom hook

Rather than repeating the useMemo + cleanup dance everywhere, encapsulate the most common case — debouncing a value — in a reusable hook:

function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);   // reset timer on every change
  }, [value, delay]);

  return debounced;
}

This debounces the value instead of the callback, which is often what you actually want and reads beautifully:

function SearchBox() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebouncedValue(query, 300);

  useEffect(() => {
    if (debouncedQuery) fetchResults(debouncedQuery);
  }, [debouncedQuery]);

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

Note the elegance: the input stays a controlled component updating on every keystroke (so typing feels instant and responsive), while the expensive effect only runs when the debounced value settles. You've decoupled the snappy UI from the throttled side effect — exactly the separation you want. The clearTimeout in the effect cleanup is doing the debouncing: every keystroke cancels the previous pending timer and starts a new one.

Don't forget the request race

Debouncing cuts the number of requests, but it doesn't fix out-of-order responses. Even debounced, a slow response for "key" can land after a fast response for "keyboard," leaving stale results on screen. Debounce and the race are separate problems, and solving one doesn't solve the other. Guard against it:

useEffect(() => {
  const controller = new AbortController();
  fetchResults(debouncedQuery, { signal: controller.signal })
    .then(setResults)
    .catch((e) => { if (e.name !== "AbortError") throw e; });
  return () => controller.abort();   // cancel the in-flight request
}, [debouncedQuery]);

The effect cleanup aborts the previous request before starting a new one, so only the latest query's response can ever update state. Pair debouncing with cancellation and you've handled both the volume problem and the ordering problem — which together are what actually make a search box feel solid.

The takeaway

Debounce and throttle are simple ideas with one React-specific gotcha that trips up nearly everyone: the helper function must be stable across renders, or its internal timer resets on every render and it silently does nothing. Wrap it in useMemo or a useRef, always clean up pending timers on unmount, and reach for a useDebouncedValue hook for the common "debounce a value" case. Get those right and the rest is just choosing the correct tool — debounce for the final state, throttle for a steady stream — for the job in front of you.

Keep reading

Newsletter

New posts, straight to your inbox

One email per post. No spam, no tracking pixels, unsubscribe anytime.

Comments

  • No comments yet. Be the first.