#Utilities#Logic

Debounce

Master the debounce technique in JavaScript to control function execution frequency. Essential for optimizing search inputs, resize handlers, and preventing excessive API calls.

By Pratik RaiEasy

Knowing how to write debounce and knowing when to reach for it are different skills. This is about the second one — choosing between debounce, throttle and the platform APIs that often beat both, and avoiding the handful of mistakes that make a correctly-implemented debounce do nothing at all.

For the implementation itself — timers, leading and trailing edges, cancel and flush — see the debounce polyfill challenge.

Choosing the right tool

Most "this fires too often" problems have a better answer than a timer. Work down this list:

Is it a scroll or resize handler that moves something on screen? Use requestAnimationFrame, not a timer. It syncs with the paint cycle, so you never compute a layout that gets thrown away, and it pauses in background tabs.

Are you detecting whether an element is visible? Use IntersectionObserver. Lazy-loading, infinite scroll and scroll-spy navigation all have native implementations that need no scroll listener whatsoever.

Are you reacting to an element changing size? ResizeObserver. It fires only when that element resizes, rather than on every window resize.

Are you waiting for the user to stop typing? Now you want debounce.

Do you need updates while something continuous is happening? You want throttle.

The distinction between the last two: debounce waits for silence, throttle enforces a rate. A search box wants debounce — only the final query matters. A drag handler wants throttle — you need positions during the drag, not after it.

Picking a delay

Delay is a UX decision, not a technical one:

  • 100–200ms feels instantaneous. Good for local filtering where the work is cheap.
  • 300ms is the usual default for network-backed search. Long enough to skip most intermediate keystrokes, short enough to feel responsive.
  • 500ms+ starts to feel laggy for search, but is right for autosave, where firing less often is the goal.

Above roughly 400ms, users notice the pause and start wondering whether the app registered their input. If you need a long delay, show a pending indicator.

The three mistakes that matter

Recreating the debounced function every render. By far the most common. In React, calling debounce(...) in the component body returns a new function each render, each with its own timer — so nothing is ever debounced. It still works, which is why it survives review.

JSfile.javascript
1// Broken 2const handleSearch = debounce((q) => search(q), 300); 3 4// Correct 5const handleSearch = useMemo(() => debounce((q) => search(q), 300), []); 6useEffect(() => handleSearch.cancel, [handleSearch]);

Not cancelling on unmount. A pending call that fires after teardown either wastes a request or touches a component that no longer exists. Any debounced function inside a component needs its cancel wired to the cleanup.

Assuming debounce fixes race conditions. It does not. Debouncing reduces how many requests you send; it says nothing about the order they come back in. With a slow connection, the response for "ca" can arrive after the one for "cat" and overwrite it with stale results.

That needs separate handling — either an AbortController cancelling the previous request, or a request counter you check before applying a response:

JSfile.javascript
1const requestId = ++latestRequest.current; 2const data = await search(query); 3if (requestId !== latestRequest.current) return; // a newer request superseded this 4setResults(data);

A production search box needs debounce and cancellation. They solve different problems and neither substitutes for the other.

Debounce is not rate limiting

Worth separating, because the terms get mixed up. Debounce and throttle are client-side UX tools — they shape how often your own code runs. They are not a defence against abuse, and they offer the server no protection: anyone can call your API directly.

If the goal is protecting a resource, you need actual rate limiting on the server. Client-side timers are about not wasting work, not about enforcing limits.

Testing debounced code

Debounce makes tests time-dependent, which makes them slow and flaky if you wait for real timers. Use fake timers instead:

JSfile.javascript
1jest.useFakeTimers(); 2const fn = jest.fn(); 3const debounced = debounce(fn, 300); 4 5debounced(); debounced(); debounced(); 6expect(fn).not.toHaveBeenCalled(); 7 8jest.advanceTimersByTime(300); 9expect(fn).toHaveBeenCalledTimes(1);

Three calls, one execution — that assertion is the definition of debounce, and it catches the "new function every render" bug that a manual browser check will not.

Key takeaways

  • Check for a platform API first — rAF, IntersectionObserver, ResizeObserver often beat a timer.
  • Debounce waits for a pause; throttle maintains a rate.
  • 300ms is a sensible default for network-backed search.
  • Memoise the debounced function, or it silently does nothing.
  • Always cancel on unmount.
  • Debounce does not prevent out-of-order responses — you need cancellation too.

Goal: Implement debounce and throttle functions within 30 minutes. Focus on understanding closures and timing mechanisms.

Frequently asked questions

Is debounce a common JavaScript interview question?
It is one of the two or three most-asked utility-function questions in front-end interviews, usually alongside throttle. Interviewers like it because a correct answer needs closures, timers, `this` handling and argument forwarding — several fundamentals in one small function.
What is the difference between debounce and throttle?
Debounce waits for the activity to stop: every new call resets the timer, so the function runs once after things go quiet. Throttle guarantees a steady rate: it runs, then ignores further calls until the interval has passed. Search-as-you-type wants debounce; scroll and resize handlers usually want throttle.
Why does a debounce implementation need apply?
Because the returned wrapper is what gets called, so the original function loses both its arguments and its `this` unless you pass them on. Capturing the arguments and calling `func.apply(this, args)` inside the timer keeps the debounced version behaving like the function it wrapped, which matters as soon as it is used as a method or an event handler.
What follow-ups should I expect after implementing debounce?
The usual next steps are a `cancel` method to drop a pending call, a `flush` method to run it immediately, and a leading-edge option that fires on the first call rather than the last. Being asked to then write throttle is extremely common, so it is worth being able to explain how the two differ before you are asked to code it.

Related Challenges

Continue learning with these related challenges

View All
JavaScript

Throttle Polyfill

Implement throttle from scratch: guarantee a function runs at most once per interval regardless of how often it is called.

JavaScript · Closures · TimersPratik Rai ·

JavaScript

Parallel vs Sequential Promises

Implement two async functions A and B, then return their sum using both sequential and parallel Promise execution, observing the time difference.

JavaScript · Promises · Async/AwaitPratik Rai ·

JavaScript

Array.prototype.map Polyfill

Implement Array.prototype.map from scratch. Master callback signatures, thisArg binding, and sparse-array hole preservation.

JavaScript · Arrays · PolyfillsPratik Rai ·