DevLift
Back to Blog

Build Your Own Debounce and Throttle

The naive 10-line debounce works for demos. Build the real thing: leading edges, trailing calls, cancel, flush, and a throttle that never silently drops the last event.

Admin
July 31, 20267 min read0 views

Build Your Own Debounce and Throttle

You add a search box. The user types "react hooks tutorial". For every keystroke, your autocomplete fires a network request — 20 characters, 20 requests, most of them obsolete before the previous one returns. The response for "r" arrives while they're already on "react ho". Your server is sweating, your network tab looks like a waterfall, and stale results might flash in between.

Two patterns fix this. Debounce waits until the user stops, then fires once. Throttle fires at a fixed rate no matter how many times you call it. Both are short enough to understand completely. The naive 10-line version works for demos — the one you actually need has leading edges, trailing calls, cancel, and flush. We'll build all of it.

What We're Building

  • debounce(fn, delay) — fires after delay ms of silence
  • Leading-edge option: fires immediately on first call, then suppresses
  • .cancel() — drops a pending call entirely
  • .flush() — forces immediate execution if a call is waiting
  • throttle(fn, delay) — fires at most once per delay ms
  • Trailing-call option: guarantees the last call in a window always fires
Rendering diagram...

Step 1: Trailing Debounce

1

Core trailing debounce

// debounce.js
 
function debounce(fn, delay) {
  let timerId = null;
 
  return function debounced(...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => {
      timerId = null;
      fn.apply(this, args);
    }, delay);
  };
}

Every call clears the previous timer and starts a fresh one. The only way fn executes is if delay milliseconds pass without another call — silence on the wire. The closure over timerId is what ties all invocations together. Without it, each call would have its own independent timer and nothing would be debounced.

const search = debounce(async (query) => {
  const results = await fetch(`/api/search?q=${encodeURIComponent(query)}`).then(r => r.json());
  renderResults(results);
}, 300);
 
searchInput.addEventListener('input', e => search(e.target.value));
// User types "react hooks" over 500ms → one request, 300ms after the last keystroke

fn.apply(this, args) isn't just ceremony. If you debounce a method on an object and call it as obj.debouncedMethod(), this inside fn must still point to obj. Writing it as () => fn(...args) inside setTimeout loses the calling context — the arrow function captures this from the outer scope, which is the returned debounced function called as a standalone, not as a method.

Step 2: Leading Edge

The trailing debounce is right for search. It's wrong for a submit button. The user clicks, nothing happens for 300ms, then the form submits. That delay feels like a bug. What you actually want: fire immediately on first click, then suppress additional clicks for 300ms. That's the leading edge.

It also matters for UI feedback — a spinner should appear the moment the user clicks, not after the delay expires.

2

Add leading and trailing options

function debounce(fn, delay, { leading = false, trailing = true } = {}) {
  let timerId = null;
  let lastArgs = null;
  let lastThis = null;
 
  return function debounced(...args) {
    lastArgs = args;
    lastThis = this;
 
    // True only when: leading is enabled AND no timer is currently running
    const isFirstCall = leading && !timerId;
 
    clearTimeout(timerId);
 
    timerId = setTimeout(() => {
      timerId = null;
      // isFirstCall is captured per-timer: each timer knows whether it started a leading call
      if (trailing && !isFirstCall) {
        fn.apply(lastThis, lastArgs);
        lastArgs = lastThis = null;
      }
    }, delay);
 
    if (isFirstCall) {
      fn.apply(this, args);
    }
  };
}

isFirstCall is evaluated at call time and captured in the closure of each setTimeout callback. Here's why this matters: when call 1 comes in with no active timer, isFirstCall is true — the timer it starts will not fire a trailing call. When calls 2 and 3 arrive mid-window, they clear the old timer and create new ones with isFirstCall = false — those timers will fire the trailing call using the most recent args.

This lets all four combinations work correctly:

// Default — wait for silence, then fire with the latest args
const autocomplete = debounce(fetchSuggestions, 250);
 
// Leading only — instant response, ignore rapid repeats
const onSubmit = debounce(submitForm, 500, { leading: true, trailing: false });
// First click fires immediately. More clicks within 500ms are silently dropped.
 
// Trailing only (same as default, but explicit)
const onSave = debounce(saveToAPI, 1000, { leading: false, trailing: true });
 
// Both — fire at the start of a burst AND at the end
const onResize = debounce(recalcLayout, 150, { leading: true, trailing: true });
// Layout runs when resize begins, then again when the user stops dragging
⚠️

With leading: true, trailing: true and a single call, the function fires exactly once (on the leading edge). The trailing call is suppressed because no additional calls updated lastArgs during the delay window. This prevents double-fires for isolated single activations.

Step 3: Cancel and Flush

Real code has lifecycles. A React component unmounts while a search is pending. A route transition happens while an auto-save is queued. You need two escape hatches:

cancel — throw away the pending call. Don't run it, ever.
flush — execute it right now without waiting for the timer.

3

Add cancel and flush

function debounce(fn, delay, { leading = false, trailing = true } = {}) {
  let timerId = null;
  let lastArgs = null;
  let lastThis = null;
 
  function invoke() {
    const args = lastArgs;
    const context = lastThis;
    lastArgs = lastThis = null;
    fn.apply(context, args);
  }
 
  function debounced(...args) {
    lastArgs = args;
    lastThis = this;
 
    const isFirstCall = leading && !timerId;
    clearTimeout(timerId);
 
    timerId = setTimeout(() => {
      timerId = null;
      if (trailing && !isFirstCall) invoke();
    }, delay);
 
    if (isFirstCall) invoke();
  }
 
  debounced.cancel = function () {
    clearTimeout(timerId);
    timerId = null;
    lastArgs = lastThis = null;
  };
 
  debounced.flush = function () {
    if (timerId && lastArgs) {
      clearTimeout(timerId);
      timerId = null;
      invoke();
    }
  };
 
  return debounced;
}
 
module.exports = { debounce };

cancel in practice — component cleanup:

const debouncedSearch = debounce(fetchResults, 300);
searchInput.addEventListener('input', e => debouncedSearch(e.target.value));
 
// React useEffect cleanup — don't update state after the component unmounts
useEffect(() => {
  return () => debouncedSearch.cancel();
}, []);

flush in practice — user-triggered saves:

const autoSave = debounce(saveDocument, 2000);
editor.addEventListener('input', autoSave);
 
// Cmd+S: user wants to save *now*, not in 2 seconds
document.addEventListener('keydown', (e) => {
  if ((e.metaKey || e.ctrlKey) && e.key === 's') {
    e.preventDefault();
    autoSave.flush();
  }
});

flush is a safe no-op when nothing is pending — the guard timerId && lastArgs handles it. No need to check at the call site whether a call is actually queued.

Step 4: Basic Throttle

Debounce waits for silence. Throttle doesn't wait — it enforces a maximum call rate regardless of how many invocations come in. For a scroll handler that updates a progress indicator, you don't want to wait until the user stops scrolling to see any update. You want updates every 100ms while they scroll.

4

Timestamp-based throttle

// throttle.js
 
function throttle(fn, delay) {
  let lastCallTime = 0;
 
  return function throttled(...args) {
    const now = Date.now();
 
    if (now - lastCallTime >= delay) {
      lastCallTime = now;
      fn.apply(this, args);
    }
  };
}

No timers in the basic version — just a timestamp comparison. If enough wall-clock time has passed since the last execution, run and record the time. Otherwise, drop the call silently.

const updateProgressBar = throttle((scrollY) => {
  const total = document.body.scrollHeight - window.innerHeight;
  const pct = Math.round((scrollY / total) * 100);
  progressBar.style.width = `${pct}%`;
}, 100);
 
window.addEventListener('scroll', () => updateProgressBar(window.scrollY));
// Fires at most 10 times per second regardless of scroll speed

The timestamp approach is deliberate. The obvious alternative — set a flag, clear it with setTimeout(…, delay) — measures the window from when the timer got around to running, and timers only ever fire late. Each window inherits the previous one's lateness, so the effective rate drifts below the rate you asked for. Comparing timestamps re-anchors to the clock on every call.

Date.now() is the pragmatic choice, not the correct one: it's wall-clock time, so an NTP correction or a manual clock change can move it backwards and hand you a window that never expires or one that expires instantly. performance.now() is monotonic and is what you want if throttling anything that matters. Everyone ships Date.now() anyway, including Lodash.

// Throttle API calls to stay within rate limits
const apiCall = throttle(async (endpoint, data) => {
  return fetch(endpoint, { method: 'POST', body: JSON.stringify(data) });
}, 1000); // at most 1 req/sec

Step 5: Throttle with Trailing Call

There's a silent data loss issue in the basic throttle. If the last scroll event arrives 90ms into a 100ms window, it gets dropped. The user scrolled to 87% depth, but the last thing you recorded was wherever they were at 80ms into the window. For scroll analytics or any continuous-value tracking, that missing final sample matters.

The fix: when a call arrives mid-window, schedule it to execute when the current window expires.

5

Throttle with guaranteed trailing call

function throttle(fn, delay, { trailing = false } = {}) {
  let lastCallTime = 0;
  let timerId = null;
  let lastArgs = null;
  let lastThis = null;
 
  return function throttled(...args) {
    const now = Date.now();
    const remaining = delay - (now - lastCallTime);
 
    lastArgs = args;
    lastThis = this;
 
    if (remaining <= 0) {
      // Window has expired — execute immediately
      if (timerId) {
        clearTimeout(timerId);
        timerId = null;
      }
      lastCallTime = now;
      fn.apply(this, args);
      lastArgs = lastThis = null;
    } else if (trailing && !timerId) {
      // Mid-window call — schedule it for when the window ends
      timerId = setTimeout(() => {
        lastCallTime = Date.now();
        timerId = null;
        fn.apply(lastThis, lastArgs);
        lastArgs = lastThis = null;
      }, remaining);
    }
  };
}

remaining tells you exactly where you are in the current window. If it's <= 0, the window is over — execute immediately. If you're mid-window and trailing is enabled, schedule a call for precisely when the window ends, but only if one isn't already scheduled.

// Without trailing: if the last scroll fires 10ms before window end, it's dropped
const trackBasic = throttle(sendScrollDepth, 200);
 
// With trailing: final position is always captured
const trackFull = throttle(sendScrollDepth, 200, { trailing: true });
⚠️

Don't enable trailing: true for button click handlers. A throttled click with trailing: true fires once immediately and potentially again at the end of the window if clicked mid-window. Two fires for one click is never what you want. Keep trailing for continuous-value tracking — scroll depth, mouse position, sensor readings.

Putting It All Together

// utils.js — complete implementations
 
function debounce(fn, delay, { leading = false, trailing = true } = {}) {
  let timerId = null;
  let lastArgs = null;
  let lastThis = null;
 
  function invoke() {
    const args = lastArgs;
    const context = lastThis;
    lastArgs = lastThis = null;
    fn.apply(context, args);
  }
 
  function debounced(...args) {
    lastArgs = args;
    lastThis = this;
    const isFirstCall = leading && !timerId;
    clearTimeout(timerId);
    timerId = setTimeout(() => {
      timerId = null;
      if (trailing && !isFirstCall) invoke();
    }, delay);
    if (isFirstCall) invoke();
  }
 
  debounced.cancel = function () {
    clearTimeout(timerId);
    timerId = null;
    lastArgs = lastThis = null;
  };
 
  debounced.flush = function () {
    if (timerId && lastArgs) {
      clearTimeout(timerId);
      timerId = null;
      invoke();
    }
  };
 
  return debounced;
}
 
function throttle(fn, delay, { trailing = false } = {}) {
  let lastCallTime = 0;
  let timerId = null;
  let lastArgs = null;
  let lastThis = null;
 
  return function throttled(...args) {
    const now = Date.now();
    const remaining = delay - (now - lastCallTime);
    lastArgs = args;
    lastThis = this;
 
    if (remaining <= 0) {
      if (timerId) { clearTimeout(timerId); timerId = null; }
      lastCallTime = now;
      fn.apply(this, args);
      lastArgs = lastThis = null;
    } else if (trailing && !timerId) {
      timerId = setTimeout(() => {
        lastCallTime = Date.now();
        timerId = null;
        fn.apply(lastThis, lastArgs);
        lastArgs = lastThis = null;
      }, remaining);
    }
  };
}
 
module.exports = { debounce, throttle };

Testing

// test.js — run with: node test.js
 
const { debounce, throttle } = require('./utils');
 
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
 
// Throw, don't console.assert. console.assert in Node logs and continues, so a
// file full of broken assertions still signs off with "All tests passed."
function check(cond, msg) {
  if (!cond) throw new Error(msg);
  console.log('ok  ' + msg.split(':')[0]);
}
 
async function run() {
  // Trailing debounce: fires once after silence
  let count = 0;
  const dTrailing = debounce(() => count++, 50);
  dTrailing(); dTrailing(); dTrailing();
  await wait(30);
  check(count === 0, `Trailing: expected 0 mid-delay, got ${count}`);
  await wait(60);
  check(count === 1, `Trailing: expected 1 after silence, got ${count}`);
 
  // Leading debounce: fires immediately, suppresses repeats
  count = 0;
  const dLeading = debounce(() => count++, 50, { leading: true, trailing: false });
  dLeading();
  check(count === 1, `Leading: expected 1 immediately, got ${count}`);
  dLeading(); dLeading();
  check(count === 1, `Leading suppress: expected still 1, got ${count}`);
  await wait(60); // let window expire for cleanup
 
  // Cancel: drops pending call
  count = 0;
  const dCancel = debounce(() => count++, 50);
  dCancel();
  dCancel.cancel();
  await wait(70);
  check(count === 0, `Cancel: expected 0, got ${count}`);
 
  // Flush: executes pending call immediately
  count = 0;
  const dFlush = debounce(() => count++, 200);
  dFlush();
  dFlush.flush();
  check(count === 1, `Flush: expected 1 immediately, got ${count}`);
  await wait(220);
  check(count === 1, `Flush no double: expected still 1, got ${count}`);
 
  // Basic throttle: at most once per window
  count = 0;
  const tBasic = throttle(() => count++, 50);
  tBasic(); tBasic(); tBasic(); tBasic();
  check(count === 1, `Throttle: expected 1, got ${count}`);
  await wait(60);
  tBasic();
  check(count === 2, `Throttle after window: expected 2, got ${count}`);
 
  // Throttle with trailing: last mid-window call fires at window end
  count = 0;
  const tTrailing = throttle(() => count++, 50, { trailing: true });
  tTrailing(); // fires immediately — count = 1
  await wait(25);
  tTrailing(); // mid-window, schedules trailing
  await wait(35); // window expires, trailing fires — count = 2
  check(count === 2, `Throttle trailing: expected 2, got ${count}`);
 
  console.log('All tests passed.');
}
 
run().catch(err => { console.error(err); process.exit(1); });

No test framework required. For a full suite with many timing-sensitive tests, swap in fake timers from Jest or Vitest — jest.useFakeTimers() lets you advance the clock manually instead of waiting for real delays.

What We Skipped

maxWait for debounce: If the user never stops typing, a 300ms debounce with no ceiling means the handler might not fire for 10 seconds. Lodash adds maxWait — a hard ceiling on how long to wait regardless of activity. Implementation: track lastCallTime and in the timer callback check if now - lastCallTime >= maxWait. If so, invoke unconditionally. Useful for auto-save where you want to commit every 30 seconds no matter what.

Async return values: Our implementations return undefined. If fn is async and you need to await the result, you'd thread the Promise back through invoke and store the last result. Most production code either ignores the return value or manages the Promise externally — that's why Lodash doesn't do this either.

Throttle cancel/flush: The same pattern from debounce applies directly — extract the timer state, add the two methods. The logic is identical; it's left out here rather than duplicating it.

requestAnimationFrame throttle: For DOM animation work, Date.now() is the wrong clock. You want calls coalesced to the browser's paint cycle (~16ms at 60fps). Replace timestamp tracking with a boolean flag:

function rafThrottle(fn) {
  let scheduled = false;
  let lastArgs;
  let lastThis;
  return function (...args) {
    lastArgs = args;
    lastThis = this;
    if (!scheduled) {
      scheduled = true;
      requestAnimationFrame(() => {
        fn.apply(lastThis, lastArgs);
        scheduled = false;
      });
    }
  };
}

Multiple calls within the same frame collapse into one, synchronized with the browser paint. No drift, no timer overhead.

React hooks: A bare debounce(fn, delay) inside a component recreates the debounced function on every render, resetting the timer each time — which breaks debouncing entirely. So you stabilize it with useRef. But stabilizing the wrong thing gives you a worse bug than the one you started with:

// BROKEN — the closure is frozen at first render
function useDebounce(fn, delay) {
  const ref = useRef(null);
  if (!ref.current) ref.current = debounce(fn, delay);
  useEffect(() => () => ref.current.cancel(), []);
  return ref.current;
}

debounce(fn, delay) captures the fn from the render it was created in — and fn closes over that render's props and state. Every later call runs against a snapshot from first mount. An auto-save wired up this way keeps saving the document as it looked when the component mounted, and it does it silently: no error, no warning, just stale writes.

Keep the debounced wrapper stable and let it read the latest callback out of a ref:

function useDebounce(fn, delay) {
  const fnRef = useRef(fn);
  fnRef.current = fn;                      // refreshed every render
 
  const debouncedRef = useRef(null);
  if (!debouncedRef.current) {
    debouncedRef.current = debounce((...args) => fnRef.current(...args), delay);
  }
 
  useEffect(() => () => debouncedRef.current.cancel(), []);
  return debouncedRef.current;
}

The timer identity never changes, so debouncing works. The function it calls is always current.

The naive debounce at the top of this post is 10 lines. The version with leading, trailing, cancel and flush is 35, and throttle with a trailing call is 26. That ratio is the whole point: the mechanism is trivial and the edge cases are the work. Before you paste either one in, answer two questions — does the first call need to be instant, and does the last call have to arrive? Leading answers the first, trailing answers the second, and every bug in this area is someone who picked the wrong pair.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Build a pub/sub system from 60 lines of JavaScript: subscribe, publish, unsubscribe, once(), namespace wildcards, error isolation, and async publish.
AdminJuly 31, 20266 min read
An Observable is just a function. Build one from scratch in about 120 lines of JavaScript and finally understand why unsubscribing stops work, how operators chain, and what 'lazy' actually means.
AdminJuly 31, 20265 min read
Picture this: a live dashboard that polls for new notifications every 3 seconds. The count increments in state, the UI updates, everything looks right — except the interval callback is silently reading count = 0 on every tick, because it…
AdminJuly 31, 20266 min read