, ,

How to Write a Debounce Function in JavaScript Without Any Library

Posted by

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

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

  1. Every call to the returned function clears the previous timer.
  2. A new timer starts for the specified delay.
  3. 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.

One response

Leave a Reply

Your email address will not be published. Required fields are marked *