JavaScriptReactPerformanceClosuresTimers

Implement Custom Debounce

Learn how to build a debounce utility function from scratch. Debounce is one of the most frequently asked JavaScript interview questions — it tests your understanding of closures, timers, and performance optimization.

20 min read7 sections
01

What is Debounce?

Debounce is a programming technique that limits how often a function can fire. It ensures a function is only called after a specified delay has passed since the last invocation. If the function is called again before the delay expires, the timer resets.

🔍

Search Input

Wait until the user stops typing before firing an API call. Prevents sending a request on every keystroke.

📐

Window Resize

Recalculate layout only after the user finishes resizing, not on every pixel change.

🖱️

Button Clicks

Prevent duplicate form submissions by ignoring rapid clicks and only processing the last one.

Why interviewers love this question

Debounce tests three core JavaScript concepts at once: closures (the timer variable persists between calls), higher-order functions (it takes a function and returns a function), and the event loop (setTimeout behavior). It's a compact question with deep signal.

02

How It Works

Here's the mental model. Imagine you're in an elevator. The door starts closing, but someone walks in — the door reopens and the timer resets. It only actually closes when nobody has entered for a few seconds. Debounce works the same way.

1

User triggers the function

A keystroke, scroll, resize, or click event fires. The debounced wrapper receives the call.

2

Timer starts (or resets)

If there's an existing timer, it gets cleared with clearTimeout(). A new setTimeout is created with the specified delay.

3

Delay expires

If no new calls come in during the delay period, the timer fires and the original function executes with the most recent arguments.

4

If called again before delay

The timer resets back to zero. The previous pending call is cancelled. Only the latest call matters.

Timeline visualizationtext
User types: H  e  l  l  o
             |  |  |  |  |
Time:       0ms 100ms 200ms 300ms 400ms ... 900ms
             ↓  ↓  ↓  ↓  ↓         ↓
Timer:      set reset reset reset reset    FIRES!
search("Hello")

Without debounce: 5 API calls
With debounce (500ms): 1 API call
03

Implementation

The implementation is surprisingly short. The key insight is that the returned function forms a closure over the timer variable, so it persists between calls.

Basic Debounce

debounce.tstypescript
function debounce<T extends (...args: any[]) => void>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout>;

  return function (...args: Parameters<T>) {
    // Clear any existing timer — this is the "reset"
    clearTimeout(timer);

    // Start a new timer
    timer = setTimeout(() => {
      fn(...args);
    }, delay);
  };
}

Let's break this down line by line:

LineWhat it does
let timerDeclared in the outer scope — persists via closure across all calls to the returned function
clearTimeout(timer)Cancels the previous pending call. If there's no timer, this is a no-op (safe to call)
timer = setTimeout(...)Schedules the actual function call after the delay. Stores the timer ID so it can be cleared
fn(...args)Calls the original function with the most recent arguments when the timer finally fires

Interview tip

Interviewers often ask you to write this on a whiteboard. Practice writing it from memory. The entire implementation is 8 lines — there's no excuse for not knowing it cold.

04

Using Debounce in React

Using debounce in React has a gotcha: components re-render, which means a new debounced function gets created on every render — defeating the purpose. You need to stabilize the reference with useCallback or useRef.

Approach 1: useCallback (Simple)

SearchComponent.tsxtypescript
"use client";

import { useState, useCallback } from "react";

function debounce<T extends (...args: any[]) => void>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout>;
  return function (...args: Parameters<T>) {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

export default function SearchComponent() {
  const [query, setQuery] = useState("");

  const search = (value: string) => {
    console.log("API call:", value);
    // fetch(`/api/search?q=${value}`)
  };

  // useCallback ensures the debounced function is created once
  // eslint-disable-next-line react-hooks/exhaustive-deps
  const debouncedSearch = useCallback(debounce(search, 500), []);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setQuery(value);          // Update input immediately (responsive UI)
    debouncedSearch(value);   // Debounce the expensive operation
  };

  return (
    <input
      value={query}
      onChange={handleChange}
      placeholder="Search..."
    />
  );
}

Approach 2: useRef (More Control)

SearchWithRef.tsxtypescript
"use client";

import { useState, useRef, useEffect } from "react";

export default function SearchWithRef() {
  const [query, setQuery] = useState("");
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  const debouncedSearch = (value: string) => {
    // Clear previous timer
    clearTimeout(timerRef.current);

    // Set new timer
    timerRef.current = setTimeout(() => {
      console.log("API call:", value);
    }, 500);
  };

  // Cleanup on unmount
  useEffect(() => {
    return () => clearTimeout(timerRef.current);
  }, []);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setQuery(value);
    debouncedSearch(value);
  };

  return (
    <input
      value={query}
      onChange={handleChange}
      placeholder="Search..."
    />
  );
}

Which approach to use?

The useCallback approach is cleaner when you have a standalone debounce utility. The useRef approach gives you direct access to the timer for cleanup and cancellation. In interviews, show the useCallback approach first — it's more concise.

05

Debounce vs Throttle

This is the most common follow-up question. They sound similar but solve different problems:

DebounceThrottle
When it firesAfter the last call + delayAt most once per interval
Resets on new call?Yes — timer restartsNo — ignores calls until interval passes
Best forSearch input, form validation, resize endScroll events, mouse move, rate limiting
AnalogyElevator door — resets when someone entersBus schedule — leaves every 10 min regardless
Visual comparisontext
Events:    x x x x x x x x . . . . x x x x . . . .
           |                       |
Debounce:  . . . . . . . . . . D . . . . . . . D
           (waits for silence)     (waits again)

Throttle:  x . . T . . T . . T . . x . . T . . T
           (fires every interval regardless)
06

Edge Cases & Enhancements

The basic debounce covers 90% of use cases. But interviewers love asking about enhancements. Here are the most common ones:

Leading Edge (Immediate) Debounce

Sometimes you want the function to fire immediately on the first call, then ignore subsequent calls until the delay passes. This is called "leading edge" debounce.

debounce-with-leading.tstypescript
function debounce<T extends (...args: any[]) => void>(
  fn: T,
  delay: number,
  options: { leading?: boolean } = {}
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout> | null = null;

  return function (...args: Parameters<T>) {
    const isFirstCall = timer === null;

    clearTimeout(timer!);

    // Fire immediately on first call if leading is true
    if (options.leading && isFirstCall) {
      fn(...args);
    }

    timer = setTimeout(() => {
      // Fire on trailing edge (default behavior)
      if (!options.leading || !isFirstCall) {
        fn(...args);
      }
      timer = null; // Reset so next call is treated as "first"
    }, delay);
  };
}

// Usage:
// Fires immediately, then waits 500ms of silence
const debouncedClick = debounce(handleClick, 500, { leading: true });

Cancel Method

Adding a cancel method lets consumers abort a pending debounced call — useful for component unmounting in React.

debounce-with-cancel.tstypescript
interface DebouncedFn<T extends (...args: any[]) => void> {
  (...args: Parameters<T>): void;
  cancel: () => void;
}

function debounce<T extends (...args: any[]) => void>(
  fn: T,
  delay: number
): DebouncedFn<T> {
  let timer: ReturnType<typeof setTimeout>;

  const debounced = function (...args: Parameters<T>) {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  } as DebouncedFn<T>;

  debounced.cancel = () => {
    clearTimeout(timer);
  };

  return debounced;
}

// Usage in React:
// useEffect(() => {
//   return () => debouncedSearch.cancel();
// }, []);

Interview strategy

Start with the basic 8-line version. Then proactively say: "I can also add leading edge support and a cancel method if you'd like." This shows depth without over-engineering the initial solution.

07

Common Interview Follow-up Questions

After implementing debounce, interviewers typically go deeper. Here are the most common follow-ups:

Q:What JavaScript concepts does debounce rely on?

A: Closures (the timer variable persists between calls), higher-order functions (takes a function, returns a function), and the event loop (setTimeout schedules a macrotask). Some interviewers also expect you to mention lexical scoping.

Q:Why not just use setTimeout directly in the event handler?

A: Without clearTimeout, every call would schedule a new timer. You'd end up with multiple pending calls instead of just one. Debounce ensures only the last call in a burst actually executes.

Q:How would you debounce an async function and get the return value?

A: Wrap the debounce to return a Promise. Store a resolve function in the closure. When the timer fires, call the async function and resolve the promise with its result. This is trickier and rarely asked, but shows advanced understanding.

Q:What happens if the component unmounts while a timer is pending?

A: The timer still fires and tries to update state on an unmounted component. In React 18+ this is less of an issue, but best practice is to add a cancel method and call it in useEffect cleanup.

Q:Can you implement debounce with requestAnimationFrame instead of setTimeout?

A: Yes, for visual/animation-related debouncing. Replace setTimeout with requestAnimationFrame and clearTimeout with cancelAnimationFrame. The delay becomes one frame (~16ms) instead of a custom value.

Q:How does lodash's debounce differ from this implementation?

A: Lodash adds: leading/trailing options, a maxWait parameter (guarantees execution after N ms even if calls keep coming), a flush method (execute immediately), and proper `this` context binding. It's about 80 lines vs our 8.

Q:What's the difference between debounce and requestIdleCallback?

A: Debounce delays execution by a fixed time. requestIdleCallback schedules work when the browser is idle — it's for low-priority tasks that shouldn't block rendering. They solve different problems but can complement each other.

Ready to implement it yourself?

We've set up a live playground with a search input and event log. Write your own debounce function and watch it work in real time.

Built for developers, by developers. Happy coding! 🚀