Stop spamming your APIs, optimize your event handlers, and learn how debounce really works from scratch.

Introduction
If your JavaScript app ever:
- Calls an API on every keystroke,
- Recalculates something during a scroll, or
- Runs expensive logic during window resize,
…you’ve probably needed a debounce function.
Most developers just grab it from Lodash (_.debounce
) and move on.
But do you actually know how debounce works under the hood?
In this post, you’ll learn:
- ✅ What “debouncing” means
- ✅ How to write your own
debounce()
from scratch - ✅ How to add options like “immediate” execution
- ✅ Common gotchas and real-world use cases
By the end, you won’t need Lodash or Google.
What Is Debouncing?
Debouncing means limiting how often a function runs by delaying its execution until after a certain period of inactivity.
Imagine a user typing “hello” in a search bar; your app shouldn’t call the search API five times (once per letter). Instead, it should wait until the user stops typing for, say, 300ms, then run the search once.
Visually:
Keystrokes: H — E — L — L — O
Timer: |<-------300ms------->|
Call API: ✅ once
In other words:
A debounce ensures your function executes only after the activity stops for a defined delay.
Step 1: The Naive Implementation
Let’s start with the simplest version:
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
Usage:
const log = debounce((text) => console.log("Typed:", text), 500);
document.querySelector("input").addEventListener("input", (e) => {
log(e.target.value);
});
✅ Each keystroke resets the timer
✅ Function runs only after the user stops typing
Step 2: How It Works
- Every call to the returned function clears the previous timer.
- A new timer starts for the specified delay.
- If no more calls occur during that delay, the original function runs.
This is why debouncing is great for:
- Input search boxes
- Resize events
- Scroll or mousemove handlers
Step 3: Add “Immediate” Option (Leading Edge)
Sometimes, you want the function to run immediately on the first call, then ignore subsequent ones until the delay ends.
function debounce(fn, delay, immediate = false) {
let timer;
return function (...args) {
const callNow = immediate && !timer;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!immediate) fn.apply(this, args);
}, delay);
if (callNow) fn.apply(this, args);
};
}
Usage:
const log = debounce(() => console.log("Clicked!"), 1000, true);
window.addEventListener("click", log);
✅ Runs immediately on first click
✅ Ignores further clicks until the delay passes
Step 4: Add .cancel()
Support
Sometimes you might want to cancel a debounced function manually, for example, when a component unmounts in React.
We can enhance our function:
function debounce(fn, delay, immediate = false) {
let timer;
function debounced(...args) {
const callNow = immediate && !timer;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!immediate) fn.apply(this, args);
}, delay);
if (callNow) fn.apply(this, args);
}
debounced.cancel = () => clearTimeout(timer);
return debounced;
}
Usage:
const search = debounce(() => console.log("Searching..."), 400);
search("query");
search.cancel(); // Cancels before it runs
✅ Clean and flexible
✅ Great for component lifecycles
Step 5: Use Cases in Real Apps
1. Search Input in React
import { useEffect, useState } from "react";
function useDebounce(fn, delay) {
const [timer, setTimer] = useState(null);
return (...args) => {
if (timer) clearTimeout(timer);
const newTimer = setTimeout(() => fn(...args), delay);
setTimer(newTimer);
};
}
export default function SearchBox() {
const [query, setQuery] = useState("");
const debouncedSearch = useDebounce((val) => console.log("Search:", val), 500);
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
debouncedSearch(e.target.value);
}}
placeholder="Search..."
/>
);
}
✅ No API spam
✅ Instant typing feedback
✅ Pure React, no libraries needed
2. Window Resize Optimization
const onResize = debounce(() => {
console.log("Resized:", window.innerWidth);
}, 300);
window.addEventListener("resize", onResize);
Without debounce, resize handlers can fire hundreds of times per second; with debounce, they fire once per stable width.
3. Prevent Multiple Form Submissions
const handleSubmit = debounce(() => {
console.log("Form submitted!");
}, 2000, true);
document.querySelector("form").addEventListener("submit", handleSubmit);
✅ Immediate first submission
✅ Ignores rapid double-clicks
Step 6: Debounce vs Throttle
They sound similar, but they solve different problems:

👉 Think:
- Debounce = “Call after user stops typing.”
- Throttle = “Call every 200ms while scrolling.”
Step 7: Common Gotchas
⚠️ Arrow functions and this
If you use arrow functions as event handlers, you this
might not refer to what you expect. Use fn.apply(this, args)
to preserve context safely.
⚠️ Too small a delay
If your delay is <100ms, you might as well not debounce; humans can’t perceive such short intervals.
⚠️ Stateful dependencies (React)
If you use debounce inside components, use useCallback
to avoid creating new debounced functions on every render.
Final Version
Here’s the complete version ready for production use:
function debounce(fn, delay = 300, immediate = false) {
let timer;
function debounced(...args) {
const context = this;
const callNow = immediate && !timer;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!immediate) fn.apply(context, args);
}, delay);
if (callNow) fn.apply(context, args);
}
debounced.cancel = () => clearTimeout(timer);
return debounced;
}
✅ Supports both leading/trailing edge
✅ Preserves this
✅ Can be canceled
✅ Works in any environment
Conclusion
Debouncing isn’t just an optimization; it’s a performance habit.
By understanding it deeply (not just copy-pasting it), you can:
- Prevent redundant API calls
- Improve responsiveness
- Write cleaner, more efficient code
✅ Key takeaway:
A good debounce function gives your app room to breathe and your backend a break.
Call to Action
Have you ever written your own debounce function, or do you rely on Lodash?
Share your version (or a cool use case) in the comments 👇
And if your teammate’s app keeps spamming API calls while typing, send them this post; they’ll thank you, and so will their server.
Leave a Reply