Skip to main content

Debounce and throttle in JavaScript

Debounce postpones a function until after a pause in calls. Throttle limits it to at most once per time interval.

Theory

TL;DR

  • Debounce waits for silence: fires once after calls stop, not during them
  • Throttle keeps a beat: fires at a fixed rate during continuous activity
  • Analogy: debounce is a restroom that locks for 5 minutes after each use; throttle is a coffee refill available every 5 minutes max
  • User stops then acts? Debounce. Need a steady rate? Throttle.

Quick example

javascript
// Debounce: fires once after calls stop const debounce = (fn, delay) => { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; }; // Throttle: fires at most once per delay const throttle = (fn, delay) => { let lastCall = 0; return (...args) => { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; fn(...args); } }; };

Both are closures. debounce resets a timer on every call. throttle checks a timestamp and drops calls that arrive too soon.

Key difference

Debounce collects a burst of calls and fires exactly once, after the burst ends. That last word is the whole point. Throttle does not wait for an end. It fires at the start, then once more per interval, for as long as calls keep coming.

When to use

  • User typing in a search box → debounce (one API call after they pause, not per keystroke)
  • Window scroll or resize → throttle (update layout at a steady pace)
  • Form submit button → throttle (block duplicate submissions)
  • External API with rate limits → throttle (space requests evenly)
  • Field validation while typing → debounce (check after pause, not mid-word)

Comparison table

AspectDebounceThrottle
When it firesAfter last call + delayImmediately, then once per interval
During rapid callsResets each time, fires once at endFires at fixed intervals
Total executions per burstUsually 1Multiple, rate-limited
Default behaviorTrailing (after silence)Leading (first call goes through)
Best forAutocomplete, auto-save, validationScroll, mousemove, API polling

How it works internally

debounce calls clearTimeout on every invocation. That cancels the pending callback. Only the last call survives, because nothing cancels it. throttle skips the timer entirely in its basic form. It uses Date.now() to compare the current time against the last execution. If the gap is smaller than the delay, the call is dropped.

One thing I got wrong early on: throttle fires immediately on the first call, because lastCall starts at 0, so Date.now() - 0 is always larger than any realistic delay. The first call always goes through.

Both patterns rely on the browser event loop. Timer callbacks run in the macrotask queue, after the current synchronous code finishes.

Common mistakes

Mistake 1: No cleanup on unmount in React

javascript
// Wrong: timer fires after component is gone useEffect(() => { debouncedFetch(query); }, [query]); // Correct: cancel on cleanup useEffect(() => { const timer = setTimeout(() => fetchData(query), 300); return () => clearTimeout(timer); }, [query]);

Without cleanup, the timer calls setState on an unmounted component. That throws warnings in dev mode and can break server-side rendering.

Mistake 2: New debounced function on every render

javascript
// Wrong: new function each render means the timer resets constantly function SearchInput() { const handleChange = debounce((val) => search(val), 500); return <input onChange={handleChange} />; } // Correct: create once function SearchInput() { const handleChange = useMemo( () => debounce((val) => search(val), 500), [] ); return <input onChange={handleChange} />; }

Mistake 3: Expecting throttle to skip the first call

javascript
const throttled = throttle(fn, 1000); throttled(); // Fires immediately (leading edge) throttled(); // Skipped throttled(); // Skipped

Lodash _.throttle fires on the leading edge by default. Pass { leading: false } to change this.

Mistake 4: Losing this context

javascript
// Wrong: `this` is lost inside the wrapper function debounce(fn, delay) { let timer; return function() { clearTimeout(timer); timer = setTimeout(fn, delay); // `this` not forwarded }; } // Correct function debounce(fn, delay) { let timer; return function(...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); // ✅ }; }

Mistake 5: Nesting debounce inside throttle

javascript
// Broken: inner debounce resets every time throttle fires const broken = throttle(() => debounce(save, 500)(), 1000); // save is never called

One wrapper is enough. Nesting them causes the inner timer to reset on every outer call.

Real-world usage

  • React search bars use the use-debounce hook (same pattern as Vercel commerce templates)
  • Lodash _.debounce and _.throttle appear across most large npm projects for scroll and input handling
  • express-rate-limit in Node.js/Express follows the throttle pattern for API endpoints
  • For scroll and resize at 60fps, requestAnimationFrame is often a better fit than a fixed-delay throttle
  • Native ResizeObserver removes the need for manual throttling of resize events in modern browsers

Follow-up questions

Q: Implement debounce from scratch.
A: Use clearTimeout and setTimeout inside a closure. Preserve this and args with .apply(this, args). The Quick example above shows the minimal version.

Q: What happens when delay is 0?
A: Debounce fires on the next event loop tick after each call. Throttle passes through every call, because Date.now() - 0 is always at least 0.

Q: How do you fix a memory leak from debounce in React?
A: Return a cleanup function from useEffect that calls clearTimeout. Store the timer ID in a ref with useRef so it persists across renders.

Q: What is the difference between leading and trailing edge in throttle?
A: Leading fires on the first call immediately. Trailing fires at the end of the interval with the last set of arguments. Lodash _.throttle does both by default, which causes an extra call at the end of a burst that surprises most people.

Q: How do you debounce an async function?
A: The wrapper returns void, not a Promise. To get the resolved value, pass it through a callback, or track the latest Promise in a ref and resolve it separately. Multiple in-flight promises can race if you are not careful.

Examples

Basic: debounce for a search input

javascript
const debounce = (fn, delay) => { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; }; const input = document.querySelector('#search'); const search = debounce((event) => { console.log('API call with:', event.target.value); // fetch(`/api/search?q=${event.target.value}`) }, 400); input.addEventListener('input', search); // Typing 'hello' triggers 5 input events // Result: 1 API call, 400ms after the last keystroke

Without debounce, typing five characters produces five API calls. With it, one call arrives after the user pauses.

Intermediate: throttled scroll handler for infinite scroll

javascript
const throttle = (fn, delay) => { let lastCall = 0; return (...args) => { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; fn(...args); } }; }; const handleScroll = throttle(() => { const nearBottom = window.scrollY + window.innerHeight >= document.body.scrollHeight - 100; if (nearBottom) { loadMoreItems(); } }, 200); window.addEventListener('scroll', handleScroll); // Max 5 checks per second, regardless of scroll speed

A fast scroll without throttle fires hundreds of events per second. At 200ms you get five checks. That is enough for infinite scroll to feel instant without hammering the browser.

Advanced: React custom hook with proper cleanup

javascript
import { useCallback, useRef, useEffect } from 'react'; function useDebounce(callback, delay) { const timerRef = useRef(null); useEffect(() => { return () => clearTimeout(timerRef.current); }, []); return useCallback((...args) => { clearTimeout(timerRef.current); timerRef.current = setTimeout(() => callback(...args), delay); }, [callback, delay]); } function SearchBox() { const handleSearch = useDebounce((value) => { fetch(`/api/search?q=${value}`).then((res) => res.json()); }, 300); return ( <input type='text' onChange={(e) => handleSearch(e.target.value)} placeholder='Search...' /> ); } // useEffect cleanup cancels the timer on unmount // No setState calls after the component is gone

The useEffect cleanup is the piece most hand-rolled implementations skip. Without it, a user who navigates away mid-typing triggers a fetch that tries to update state on an unmounted component.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?