If you are a front end dev you NEED to know about that (Throttle vs. Debounce)

If you are a front end dev you NEED to know about that (Throttle vs. Debounce)

Performance and UX live and die on the client. Two small utilities—throttle and debounce—solve a surprising number of real-world problems: from taming noisy scroll/resize events to making type-ahead search feel instantaneous without DDoS-ing your backend. This post goes beyond one-liners and covers when to use each, solid implementations, React patterns, and common pitfalls.


Mental Models

  • Debounce collects rapid-fire calls and emits the last one after a quiet period.
  • Throttle guarantees a maximum execution rate (e.g., at most once every 200ms), optionally firing leading and/or trailing calls.

Plain JavaScript Implementations

Debounce (simple + cancel/flush)

function debounce(fn, wait, immediate = false) {
  let timerId = null;
  let lastArgs;
  let lastThis;

  function later() {
    const context = lastThis;
    const args = lastArgs;
    timerId = null;
    if (!immediate) fn.apply(context, args);
  }

  const debounced = function (...args) {
    lastArgs = args;
    lastThis = this;
    const callNow = immediate && !timerId;
    clearTimeout(timerId);
    timerId = setTimeout(later, wait);
    if (callNow) fn.apply(lastThis, lastArgs);
  };

  debounced.cancel = () => {
    if (timerId) {
      clearTimeout(timerId);
      timerId = null;
    }
  };

  debounced.flush = () => {
    if (timerId) {
      clearTimeout(timerId);
      const context = lastThis;
      const args = lastArgs;
      timerId = null;
      fn.apply(context, args);
    }
  };

  return debounced;
}

Throttle (leading/trailing + cancel)

function throttle(fn, wait, { leading = true, trailing = true } = {}) {
  let timerId = null;
  let lastCallTime = 0;
  let lastArgs;
  let lastThis;

  function invoke() {
    lastCallTime = Date.now();
    fn.apply(lastThis, lastArgs);
    lastArgs = lastThis = null;
  }

  const throttled = function (...args) {
    const now = Date.now();
    if (!lastCallTime && leading === false) {
      lastCallTime = now;
    }

    const remaining = wait - (now - lastCallTime);
    lastArgs = args;
    lastThis = this;

    if (remaining <= 0 || remaining > wait) {
      if (timerId) {
        clearTimeout(timerId);
        timerId = null;
      }
      invoke();
    } else if (!timerId && trailing !== false) {
      timerId = setTimeout(() => {
        timerId = null;
        if (trailing !== false) invoke();
      }, remaining);
    }
  };

  throttled.cancel = () => {
    if (timerId) clearTimeout(timerId);
    timerId = null;
    lastCallTime = 0;
    lastArgs = lastThis = null;
  };

  return throttled;
}

Practical Uses

Debounce Search (prevent request storms)

const searchInput = document.querySelector('#search');

async function fetchResults(query) {
  const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
  return res.json();
}

const onInput = debounce(async (e) => {
  const q = e.target.value.trim();
  if (!q) return;
  const data = await fetchResults(q);
  console.log('Results:', data);
}, 300);

searchInput.addEventListener('input', onInput);

Throttle Scroll (smooth performance)

const onScroll = throttle(() => {
  // e.g., update scroll-based UI, analytics, lazy-load check
  console.log('Scroll position:', window.scrollY);
}, 200, { leading: true, trailing: true });

window.addEventListener('scroll', onScroll, { passive: true });

requestAnimationFrame Throttle (buttery animations)

function rafThrottle(fn) {
  let running = false;
  return (...args) => {
    if (running) return;
    running = true;
    requestAnimationFrame(() => {
      fn(...args);
      running = false;
    });
  };
}

const onMouseMove = rafThrottle((e) => {
  // UI paint work here
});
window.addEventListener('mousemove', onMouseMove);

React Patterns

useDebouncedValue (derive debounced state)

import { useEffect, useState } from 'react';

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

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);

  return debounced;
}

// Usage: const debouncedQuery = useDebouncedValue(query, 300);

useThrottleCallback (stable throttled handler)

import { useMemo, useRef, useEffect } from 'react';

function throttle(fn, wait, options) {
  // ...same throttle as above...
}

export function useThrottleCallback(fn, wait, options) {
  const fnRef = useRef(fn);
  useEffect(() => { fnRef.current = fn; }, [fn]);

  const throttled = useMemo(() =>
    throttle((...args) => fnRef.current(...args), wait, options),
    [wait, options]
  );

  useEffect(() => throttled.cancel, [throttled]);
  return throttled;
}

// Usage: const onScroll = useThrottleCallback(handleScroll, 200, { trailing: true });

Debounced fetch with AbortController (cancel stale requests)

const search = (() => {
  let controller;
  return async (q) => {
    if (controller) controller.abort();
    controller = new AbortController();
    const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
      signal: controller.signal,
    });
    return res.json();
  };
})();

const debouncedSearch = debounce(search, 300);

Lodash Equivalents

  • _.debounce(fn, wait, { leading, trailing, maxWait })
  • _.throttle(fn, wait, { leading, trailing })

Both return functions with .cancel() and .flush() methods. If you can depend on Lodash, use it—it’s well-tested and handles edge cases.


Common Pitfalls & Edge Cases

  • Losing this/arguments: make sure your wrappers use fn.apply(this, args).
  • Double-fires: combining { leading: true, trailing: true } can fire both at start and end. Be intentional.
  • Async functions: throttling/debouncing doesn’t queue Promises. Handle concurrency (e.g., ignore stale responses, use AbortController).
  • Cleanup in React: always cancel timers on unmount to avoid state updates on unmounted components.
  • Passive listeners: use { passive: true } for scroll/touch for better performance.
  • SSR: debounce/throttle are no-ops server-side; guard browser-only APIs.
  • Time drift: for visual work, prefer requestAnimationFrame over time-based throttling.

When to Use Which

  • Typing, resize end, auto-save after pause → Debounce
  • Continuous input (scroll, drag, mousemove), analytics beacons → Throttle
  • Animations/paints → requestAnimationFrame throttle
  • Network: prefer canceling stale requests vs. sending all and reconciling results

Bonus: Testing Tips

  • Use fake timers (e.g., Jest) to step time deterministically.
  • Assert both leading and trailing behaviors.
  • Verify .cancel() prevents calls and .flush() forces immediate execution.

Backend Note: Throttling vs. Rate Limiting

Frontend throttling is a client-side pacing strategy. Server-side rate limiting enforces quotas per client or token. They complement each other: throttle on the client for UX, rate-limit on the server for protection.


Key Takeaways

  • Debounce reduces noise; throttle caps frequency.
  • Preserve context/args, offer cancel/flush, and be explicit about leading/trailing.
  • Pair with AbortController, passive listeners, and rAF for best UX.
  • Prefer a well-tested util (e.g., Lodash) unless you have strong reasons to roll your own.

Read more