Debounce vs Throttle: The Visual Guide Every Frontend Developer Needs

⚡
Frontend Performance Series — practical guides on making the browser do less work

A scroll handler fires 50 times per second. A search input fires on every keystroke. Running expensive work on every single event is a fast route to a janky, unresponsive UI. Debounce and throttle are the two tools for limiting that — but they work completely differently, and picking the wrong one causes real bugs. This guide shows you the difference visually, builds both from scratch, and tells you exactly when to use each.


⚡ Quick Answer

Debounce: waits for events to stop firing, then runs once. Use for search inputs, form validation — anything where only the final value matters. Throttle: runs at a fixed interval no matter how many events fire. Use for scroll position tracking, progress bars, analytics — anything needing regular updates during continuous activity. requestAnimationFrame: use instead of throttle for anything directly changing visual properties — it syncs with the browser paint cycle for smoother results.


📋 Table of Contents

  1. The Problem: Events Fire Way Too Often
  2. What Is Debounce?
  3. What Is Throttle?
  4. Visual Demo — See the Difference Live
  5. Building Debounce from Scratch
  6. Building Throttle from Scratch
  7. React Hooks — useDebounce and useThrottle
  8. When to Use requestAnimationFrame Instead
  9. The Decision Table
  10. Common Pitfalls
  11. FAQ

The Problem: Events Fire Way Too Often

Browser events don’t care about your performance budget. A user typing in a search box fires the input event on every single keypress. A user scrolling fires the scroll event 50–100 times per second. A user resizing their browser fires resize continuously throughout the drag.

If your event handler does anything expensive — an API call, a layout recalculation, a complex DOM update — running it on every single event will make your page feel laggy and unresponsive.

🧒 Explain it like I’m new to this

Imagine your friend keeps pressing the lift button 20 times before the lift arrives. The lift doesn’t go up 20 floors — it still just goes to one floor. Debounce and throttle are your way of telling the browser: “I heard you the first time — stop firing this function 50 times per second, I’ll handle it on a schedule that actually makes sense.”


What Is Debounce?

Debounce delays execution until after a quiet period. Every time the event fires, it resets a timer. The function only runs once that timer completes without being reset — meaning events have stopped firing for the specified duration.

Think of it as: “wait until they’ve finished, then act.”

🧒 Explain it like I’m new to this

A lift door that keeps re-opening every time a new person walks toward it. It only closes — and moves — after nobody new has approached for 3 seconds. Debounce is that door: it keeps resetting until there’s a quiet moment, then it executes.

Best for: search inputs (only call the API after typing stops), form validation (only validate after the user pauses), auto-save drafts, window resize recalculations where you only need the final size.

⚠️
Debounce is the wrong choice for scroll-linked UI. While the user is actively scrolling, debounce produces zero output — the function never runs while events are still coming in. If you debounce a scroll progress bar, it shows nothing while scrolling and only updates when the user stops. That’s the wrong behaviour. Use throttle instead.

What Is Throttle?

Throttle limits how often a function can run — at most once per defined interval, regardless of how many times the event fires. If the interval is 100ms, the function runs at most 10 times per second even if the event fires 1,000 times per second.

Think of it as: “run on a schedule, no matter what.”

🧒 Explain it like I’m new to this

A security guard who lets one person through the turnstile every 5 seconds, no matter how many people are queuing. The queue (events) can grow as long as it wants — the rate of entry (function calls) stays controlled and predictable.

Best for: scroll-linked animations, tracking cursor position, progress bar updates, infinite scroll load triggers, rate-limiting analytics events

DebounceThrottle
Core behaviourWaits for events to stop, then fires onceFires at a fixed interval during continuous events
Output while activeZero — silent while events are incomingRegular — fires on schedule throughout
Output after events stopOne final call after the quiet periodNothing — last interval may be skipped
Only final value matters?✓ Perfect fit✗ Not ideal
Needs regular updates?✗ Not suitable✓ Perfect fit
Typical delay250–400ms for input, 150ms for resize100–150ms for scroll, 16ms for visual

Visual Demo — See the Difference Live

Click or tap rapidly inside the box below. The timeline shows every raw event as a grey tick, the debounced output as a blue tick, and the throttled output as an orange tick. This is the clearest way to see how differently they behave.

Interactive debounce vs throttle demo
👆 Click / tap here rapidly — or hold and move your mouse
Raw events every event
Count: 0
Debounced 300ms delay
Count: 0
Throttled 150ms interval
Count: 0

Building Debounce from Scratch

The implementation is short enough to fit on a napkin, and understanding it means you’ll never need to guess what a debounce library does under the hood.

function debounce(fn, delay) {
  let timer;

  return function (...args) {
    // Every call clears the previous timer and starts a fresh one.
    // Only if the timer completes without being reset does fn() actually run.
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

// Usage: search input that only calls the API after 300ms of quiet
const handleSearch = debounce((query) => {
  fetchResults(query);
}, 300);

searchInput.addEventListener('input', (e) => handleSearch(e.target.value));
What is fn.apply(this, args) doing here?
fn.apply(this, args) calls the original function with the correct this context and all the arguments that were passed in. Using apply (instead of just fn(...args)) means the debounced wrapper preserves this correctly when the debounced function is used as a method on an object — important if you’re debouncing class methods or event handlers that rely on this.

Debounce with a leading call

Sometimes you want the function to fire immediately on the first call, then ignore subsequent calls until the quiet period — like a button that responds instantly but can’t be double-triggered. This is called a “leading” debounce.

function debounce(fn, delay, { leading = false } = {}) {
  let timer;

  return function (...args) {
    const callNow = leading && !timer;
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      if (!leading) fn.apply(this, args);
    }, delay);
    if (callNow) fn.apply(this, args);
  };
}

// Fires immediately on first click, then ignores clicks for 500ms
const handleSubmit = debounce(submitForm, 500, { leading: true });

Building Throttle from Scratch

function throttle(fn, interval) {
  let lastTime = 0;

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

    // Only run if enough time has passed since the last execution
    if (now - lastTime >= interval) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

// Usage: scroll handler that updates a progress bar at most every 100ms
const updateProgress = throttle(() => {
  const scrolled = window.scrollY;
  const total   = document.body.scrollHeight - window.innerHeight;
  progressBar.style.width = (scrolled / total * 100) + '%';
}, 100);

window.addEventListener('scroll', updateProgress, { passive: true });
💡
Notice passive: true on the scroll listener. Passive listeners tell the browser “this handler will never call preventDefault()” — so the browser can start scrolling immediately without waiting for your JavaScript to complete. It’s one of the simplest performance wins for any scroll handler and should be the default for any listener that only reads scroll position.

React Hooks — useDebounce and useThrottle

In React, debounce and throttle need careful lifecycle handling. The debounced or throttled function must be created once — not recreated on every render — and the timer must be cleaned up when the component unmounts.

import { useState, useEffect } from 'react';

/**
 * Returns a debounced copy of `value` that only updates
 * after the user has stopped changing it for `delay` ms.
 * Ideal for search inputs: pass the raw input value in,
 * use the returned debounced value to trigger API calls.
 */
export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);

    // Cleanup: clear the timer when value changes before delay completes
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// Usage in a search component
function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  // Only fires when typing has paused for 300ms
  useEffect(() => {
    if (debouncedQuery) fetchResults(debouncedQuery);
  }, [debouncedQuery]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
import { useCallback, useRef } from 'react';

/**
 * Returns a throttled version of `fn` that runs at most
 * once per `interval` ms. Stable across renders — the
 * returned function reference never changes.
 */
export function useThrottle<T extends (...args: any[]) => void>(
  fn: T,
  interval: number
): T {
  const lastTimeRef = useRef<number>(0);
  const fnRef       = useRef(fn);
  // Keep fnRef current without changing throttled identity
  fnRef.current = fn;

  return useCallback((...args) => {
    const now = Date.now();
    if (now - lastTimeRef.current >= interval) {
      lastTimeRef.current = now;
      fnRef.current(...args);
    }
  }, [interval]) as T;
}

// Usage: scroll handler that updates position at most every 100ms
function ScrollTracker() {
  const handleScroll = useThrottle(() => {
    setScrollY(window.scrollY);
  }, 100);

  useEffect(() => {
    window.addEventListener('scroll', handleScroll, { passive: true });
    return () => window.removeEventListener('scroll', handleScroll);
  }, [handleScroll]);
}

When to Use requestAnimationFrame Instead

For anything that directly changes visual properties — moving an element, updating a CSS transform, animating opacity based on scroll position — a millisecond-based throttle is the wrong tool. It runs on an arbitrary timer that has no relationship to when the browser is actually going to paint the next frame.

requestAnimationFrame fixes this. It fires your callback exactly once per browser paint cycle — typically 60 times per second on a 60Hz display — and never more often than the screen can actually show.

// ❌ Millisecond throttle: runs on an arbitrary timer,
//    may run between frames or skip frames unpredictably
window.addEventListener('scroll', throttle(() => {
  el.style.transform = `translateY(${window.scrollY * 0.5}px)`;
}, 16));

// ✅ rAF throttle: syncs exactly with browser paint cycles — smooth
let ticking = false;

window.addEventListener('scroll', () => {
  if (ticking) return;    // already queued for the next frame

  requestAnimationFrame(() => {
    el.style.transform = `translateY(${window.scrollY * 0.5}px)`;
    ticking = false;    // ready for the next scroll event
  });

  ticking = true;
}, { passive: true });
🎯
The three-way decision:
Debounce — only the final value matters (search, validation, auto-save)
Throttle — regular non-visual updates at a controlled rate (analytics, API rate-limiting)
requestAnimationFrame — visual updates synced with browser paint (animations, parallax, scroll-linked transforms)

The Decision Table

SituationUseRecommended delay
Search input — call API as user typesdebounce300–400ms
Form validation — validate on pausedebounce250–300ms
Auto-save draft while editingdebounce1000–2000ms
Window resize — recalculate layoutdebounce150–200ms
Scroll progress bar or reading indicatorthrottle100ms
Infinite scroll — trigger data loadthrottle200ms
Analytics — track scroll depththrottle500–1000ms
Rate-limit API calls during dragthrottle100–200ms
Parallax / scroll-linked transformrequestAnimationFrame~16ms (60fps)
Sticky header opacity on scrollrequestAnimationFrame~16ms (60fps)
Canvas animation synced with scrollrequestAnimationFrame~16ms (60fps)

Common Pitfalls

  • Creating the debounced/throttled function inside the event handler or React render. A new function instance is created every render, meaning the timer state is lost on every re-render. Create it once outside the component, or use useCallback / useRef to stabilise the reference.
  • Forgetting to clean up timers on unmount. A debounce timer that fires after a component has unmounted will try to set state on an unmounted component. Return a cleanup function from useEffect that calls clearTimeout.
  • Debouncing a scroll-linked animation. The UI shows nothing while the user is actively scrolling — only updates after they stop. This is throttle territory.
  • Throttling with too long an interval. A 500ms throttle on a progress bar means updates feel delayed and sticky. Keep scroll-related throttle intervals at 100ms or below, or switch to requestAnimationFrame.
  • Not adding passive: true to scroll listeners. Without it, the browser has to wait for your JavaScript to finish before it can scroll — a direct cause of scroll jank. All scroll listeners that don’t call preventDefault() should be passive.
  • Using a millisecond throttle for visual updates. A 16ms setTimeout and requestAnimationFrame are not the same thing — rAF syncs with the actual display refresh rate and produces smoother results on variable refresh rate displays.

Frequently Asked Questions

What is the difference between debounce and throttle in JavaScript?

Debounce delays execution until after events stop firing — the function only runs once the events have been quiet for a set duration. Throttle limits execution to at most once per interval — it runs on a fixed schedule regardless of how many events fire. Use debounce when only the final value matters. Use throttle when you need regular updates during continuous events.

When should I use debounce vs throttle?

Use debounce for search inputs (call API after typing stops), form validation, auto-saving drafts, and window resize recalculations. Use throttle for scroll progress bars, infinite scroll triggers, analytics tracking, and rate-limiting API calls during drag operations. Use requestAnimationFrame instead of either for anything directly changing visual properties.

How do you implement debounce in JavaScript?

A debounce function wraps another function. Every call clears the previous timer and sets a new one. The inner function only executes when the timer completes without being reset: function debounce(fn, delay) { let timer; return function(...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; }

How do you implement throttle in JavaScript?

A throttle function tracks the timestamp of the last execution and skips calls that arrive too soon: function throttle(fn, interval) { let lastTime = 0; return function(...args) { const now = Date.now(); if (now - lastTime >= interval) { lastTime = now; fn.apply(this, args); } }; }

When should I use requestAnimationFrame instead of throttle?

Use requestAnimationFrame when your callback directly changes visual properties — transform, opacity, position, scroll-linked animations. rAF syncs with the browser’s actual repaint cycle (typically 60fps), producing smoother results than a fixed millisecond throttle. Use millisecond throttle for non-visual tasks: analytics, API rate-limiting, non-visual state updates.


The One-Page Cheat Sheet

  • Debounce: “wait until they’ve stopped.” Timer resets on every event. Fires once after quiet period. Use for search, validation, auto-save.
  • Throttle: “run on a schedule.” Fires at a fixed interval throughout continuous events. Use for scroll tracking, progress bars, analytics.
  • requestAnimationFrame: “sync with the screen.” Fires once per browser paint. Use for any visual property change — transforms, opacity, parallax.
  • Delay guidelines: search/input 300–400ms · scroll/resize 100–150ms · visual updates use rAF
  • Always add passive: true to scroll and touch listeners that don’t call preventDefault()
  • In React: create the debounced/throttled function once — not inside render — and clean up timers in the useEffect return
⚙️
Up next in the series
Web Workers — Stop Freezing the UI

Debounce and throttle limit how often your code runs. Web Workers move the work entirely off the main thread. Part 4 covers the cases where even a perfectly throttled handler is too slow — because the computation itself is the problem — and how to solve it. Read Part 4 →


Posted

in

,

by

Advertisement