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.
Table of Contents
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.
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.
User triggers the function
A keystroke, scroll, resize, or click event fires. The debounced wrapper receives the call.
Timer starts (or resets)
If there's an existing timer, it gets cleared with clearTimeout(). A new setTimeout is created with the specified delay.
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.
If called again before delay
The timer resets back to zero. The previous pending call is cancelled. Only the latest call matters.
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
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
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:
| Line | What it does |
|---|---|
| let timer | Declared 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.
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)
"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)
"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.
Debounce vs Throttle
This is the most common follow-up question. They sound similar but solve different problems:
| Debounce | Throttle | |
|---|---|---|
| When it fires | After the last call + delay | At most once per interval |
| Resets on new call? | Yes — timer restarts | No — ignores calls until interval passes |
| Best for | Search input, form validation, resize end | Scroll events, mouse move, rate limiting |
| Analogy | Elevator door — resets when someone enters | Bus schedule — leaves every 10 min regardless |
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)
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.
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.
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.
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! 🚀