Problem Solved

JavaScript · debounce vs throttle

Home › Courses › JavaScript › Problems › Debounce vs throttle
🟨 Concept · 3 min read

Debounce vs throttle.
Group or space. Not both.

Both limit how often a function runs. They do it differently. Debounce waits until the events stop — one call, after the last event plus a delay. Throttle runs at most once per interval — fires on a schedule regardless of how many events occur. This page covers the difference, the working implementations, and the use cases each pattern is designed for.

Short answer: Debounce collapses bursts — use for search input, autosave, form validation. Throttle spaces calls — use for scroll, resize, mousemove, drag. Delay: 300ms for debounce is standard, 100ms for throttle. Use lodash.debounce and lodash.throttle for production — they handle edge cases (leading/trailing, cancel, flush). Write your own only for learning or when the dependency isn't worth it.

✅ The difference

Same input events, different output timing.

timing.js
// User types 5 events: keystrokes at 0ms, 50ms, 100ms, 150ms, 200ms
// Then stops.

// Debounce with 300ms delay:
// ────────┬─────────────────────────┬─────
//         0ms   50  100  150  200   500ms
//         │     │    │    │    │     │
//         X     X    X    X    X     ✓
//         (keystroke events, all swallowed)
//
// One call at 500ms = last event (200) + delay (300).
// The timer resets on each keystroke.

// Throttle with 300ms interval:
// ────────┬─────────────────────────┬─────
//         0ms   50  100  150  200   300ms
//         │     │    │    │    │     │
//         ✓     X    X    ✓    X     ✓
//         (calls at 0, 100, 300...)
//
// Multiple calls, spaced 300ms apart.
// Ignores intermediate events.
The mental model:

Debounce = "wait until quiet, then act"
  → Like an elevator: door waits until no one
    has entered for 3 seconds, then closes.
  → Every event resets the timer.

Throttle = "act at most once per period"
  → Like a metronome: ticks every 100ms regardless
    of what you do in between.
  → Events don't reset the timer.

Which to use:

Debounce — the function should run ONCE,
           after the events stop:
  ✅ Search as you type
  ✅ Autosave after edits
  ✅ Form validation after input
  ✅ Resize layout calculation after resize
  ✅ API calls triggered by user input

Throttle — the function should run periodically
           DURING the events:
  ✅ Scroll handlers
  ✅ Mousemove tracking
  ✅ Infinite scroll checks
  ✅ Drag position updates
  ✅ Progress bar updates

The key question:
  "Do I want the FINAL value, or REGULAR updates?"
  Final value → debounce
  Regular updates → throttle

The wrong choice:
  Debounce on scroll — the handler only runs
    after the user stops scrolling. Janky.
  Throttle on search — the API is called every
    300ms during typing. Wasteful.

Match the pattern to the use case.

✅ Debounce — the implementation

Simple, readable, works.

debounce.js
function debounce(fn, delay) {
  let timerId;

  return function (...args) {
    clearTimeout(timerId);

    timerId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

// Usage: search input
const searchInput = document.getElementById('search');

const handleSearch = debounce((event) => {
  console.log('Searching for:', event.target.value);
  // fetch('/api/search?q=' + event.target.value)
}, 300);

searchInput.addEventListener('input', handleSearch);

// What happens:
// User types 'h'    → timer set (fires in 300ms)
// User types 'e'    → timer cleared, new timer set
// User types 'l'    → timer cleared, new timer set
// User types 'l'    → timer cleared, new timer set
// User types 'o'    → timer cleared, new timer set
// User stops typing → 300ms later, handler runs with 'hello'
How the implementation works:

1. timerId is captured in the closure.
   It survives between calls to the returned function.

2. Each call clears the previous timer.
   The pending call is cancelled.

3. Each call sets a new timer.
   If the user doesn't call again within `delay`,
   the timer fires and calls fn.

4. ...args and this preserve the arguments
   and context of the most recent call.
   Without this, the handler would receive
   the wrong event or lose `this`.

What it doesn't handle:

❌ Leading edge — calling on the first event
   instead of the last.
❌ Cancel — a way to cancel a pending call.
❌ Flush — a way to run immediately.
❌ Immediate call — an option to call fn on
   the first event, not the last.

For these, use lodash.debounce:

  import debounce from 'lodash/debounce';

  const search = debounce(fn, 300, {
    leading: false,
    trailing: true,
    maxWait: 1000
  });

  search.cancel();   // cancel pending
  search.flush();    // run immediately

The simple implementation is enough for
90% of cases. The library handles the rest.

✅ Throttle — the implementation

Simple, readable, works.

throttle.js
function throttle(fn, interval) {
  let lastCall = 0;

  return function (...args) {
    const now = Date.now();

    if (now - lastCall >= interval) {
      lastCall = now;
      fn.apply(this, args);
    }
  };
}

// Usage: scroll handler
const handleScroll = throttle(() => {
  console.log('Scroll position:', window.scrollY);
  // Update sticky header, progress bar, etc.
}, 100);

window.addEventListener('scroll', handleScroll);

// What happens:
// User scrolls rapidly for 1 second (60 events).
// Handler runs at t=0, t=100, t=200, ..., t=1000.
// That's 11 calls instead of 60.

// At 16ms throttle (60fps):
// Handler runs at t=0, 16, 32, 48, ...
// ~60 calls per second, but each on a fixed schedule.
// Smooth for animations, DOM updates.
How the implementation works:

1. lastCall is captured in the closure.
   It tracks when fn was last called.

2. Each call checks the current time.

3. If enough time has passed since lastCall,
   the function runs and lastCall is updated.

4. If not enough time has passed, the call is
   ignored entirely.

What it does NOT do:

❌ Trailing call — if the last event in a burst
   happened after the last call, the handler
   doesn't run for that final event.
❌ Configurable leading/trailing — the simple
   version is leading-only (fires on the first
   event of each interval, then ignores the rest).
❌ Cancel / flush.

For these, use lodash.throttle:

  import throttle from 'lodash/throttle';

  const onScroll = throttle(fn, 100, {
    leading: true,
    trailing: true
  });

  onScroll.cancel();
  onScroll.flush();

The simple version is enough for most cases.
The library handles the edge cases.

⚠️ requestAnimationFrame is often better than throttle
   for visual updates:

   let ticking = false;
   window.addEventListener('scroll', () => {
     if (!ticking) {
       requestAnimationFrame(() => {
         updateUI();
         ticking = false;
       });
       ticking = true;
     }
   });

   This ties updates to the browser's frame rate,
   not a timer. Smoother for scroll animations.

   Use throttle for logic that isn't frame-synced
   (API calls, analytics, localStorage writes).
   Use rAF for visual updates.

Don't use throttle on search input. Throttle fires every N milliseconds regardless of whether the user is still typing. On a search input, this means firing a request mid-word — "hel", "hello", "helloooo" — most of which are wasted. Debounce is the right pattern: wait until the user stops, then fire once with the complete query. The reverse mistake is also common: using debounce on scroll means the handler doesn't fire until scrolling stops, which is janky for visual updates. Match the pattern to the use case — final value vs regular updates.

⚠️ Common mistakes

The debounce/throttle mistakes that cost real time.

1. Debounce on scroll

Handler only runs after scrolling stops — janky. Fix: throttle.

2. Throttle on search input

Fires mid-word — wasted requests. Fix: debounce.

3. Wrong delay

100ms for search feels laggy; 1000ms for scroll feels disconnected. Fix: 300ms debounce, 100ms throttle.

4. Recreating the debounced function on every render

Breaks the debouncing. Fix: create once, outside render, or use useMemo/useRef in React.

5. Forgetting cleanup

Timers keep running after unmount. Fix: clearTimeout on cleanup, or use the library's cancel.

6. Not preserving this or args

Custom implementation loses context. Fix: fn.apply(this, args).

7. Debounce for analytics events

Analytics should capture all events — debouncing loses data. Fix: send all, throttle the network layer.

8. Using setInterval instead of setTimeout

Misses the trailing call. Fix: use setTimeout with clearTimeout (debounce pattern).

❓ Frequently asked

What is the difference between debounce and throttle?

Both limit how often a function runs. Debounce waits until the events stop — it runs the function once, after the last event plus a delay. Throttle runs the function at most once per interval — it fires on a schedule regardless of how many events occur. Debounce collapses bursts into one call; throttle spaces calls out.

Why use debounce for search input?

Because the user types many characters quickly. Without debounce, every keystroke triggers an API call — 10 characters = 10 requests, most of which are wasted. With debounce (300ms), the API call only fires when the user stops typing for 300ms. Result: 1 request instead of 10.

Why use throttle for scroll?

Because scroll fires at 60+ events per second — throttling to 100ms reduces that to 10 events per second, which is still smooth but 6x less work. Without throttle, a scroll handler that does DOM queries or layout calculations can drop frames, cause jank, and drain battery.

Can I use a library instead of writing my own?

Yes — lodash has debounce and throttle, and they're battle-tested. For most applications, using the library is the right choice: it handles edge cases (immediate execution, cancel, flush, leading/trailing options) that take time to implement correctly. Write your own only for learning, or when the library dependency isn't worth it.

Does debounce delay every call or just the last one?

By default, debounce delays only the last call. If the user types 'hello' quickly, the function runs once, after 300ms of no input — with 'hello', not 'h', 'he', 'hel', 'hell', 'hello' separately. This is what you want for search. But debounce can also be configured to run on the leading edge (the first call), or both edges.

What is the delay value I should use?

Depends on the use case. Search input: 300ms is standard. Autosave: 1-2 seconds. Scroll throttle: 16ms (60fps) is ideal but 100ms is common. Resize: 100-200ms. Mousemove: 16-50ms. Start with 300ms for debounce, 100ms for throttle, and adjust based on feel.

🔗 Continue learning

🗺️ JavaScript learning path
The full roadmap from variables to async — every concept in order

Next in this series:

🎯 Event delegation
One listener for many elements — the pattern and its performance implications
← All JavaScript problems
Problem solved · JavaScript