Writing a Debounce Utility From Scratch: Timers, Arguments and this
Build a reusable debounce function line by line, learn why it forwards arguments and this, when to throttle instead, and how to pair it with AbortController.
This article is published in English.
Sources: Debouncing in JavaScript: How to Stop Sending Useless API Requests
A search box that calls the backend on every keystroke turns one typed word into a burst of requests, most of which are thrown away. The fix is a tiny utility called debounce. Here you will implement it, understand each line, choose between debouncing and throttling, and combine it with request cancellation for a search flow that holds up under real typing.
How keystrokes turn into wasted requests
Picture a user searching for a framework. The input value changes one character at a time:
r
re
rea
reac
react
If every change triggers an API call, the server handles five requests for one query. A more specific search makes it worse:
r
re
rea
reac
react
react j
react js
react js d
react js de
react js dev
That is ten requests, and only the last reflects what the user wanted; the rest still cost bandwidth, server time and rate-limit budget.
The idea behind debouncing
Debouncing postpones a function call until a set amount of time has gone by with no further calls: let the user finish, then do the work.
Say the search call is debounced with a 500 ms delay and the user types the same five characters again:
r
re
rea
reac
react
Each keystroke restarts the countdown, so nothing is sent while typing continues. After 500 ms of quiet, one request goes out:
react → API request
The decision loop on every call:
Typing
↓
Wait 500ms
↓
Did user type again?
↓
Yes → Reset timer
↓
No → Execute function
A search box without any protection
Start with a plain input element:
<input id="search" placeholder="Search..." />
Grab a reference to it in JavaScript:
const searchInput = document.querySelector("#search");
Then attach an input listener that passes the value to a search function, which only logs, enough to count how often it fires:
searchInput.addEventListener("input", (event) => {
searchProducts(event.target.value);
});function searchProducts(query) {
console.log("API request:", query);
}
Now suppose someone types a product name:
iphone
The input event fires once per character, roughly in this sequence:
i
ip
iph
ipho
iphon
iphone
So the handler runs six times and logs six requests:
API request: i
API request: ip
API request: iph
API request: ipho
API request: iphon
API request: iphone
Against a real API that is six round trips, while the user only cares about:
iphone
Implementing debounce in a few lines
The utility is a closure: it takes a function and a delay, and returns a wrapper you call instead of the original:
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
Keep one timer per debounced function
let timer;
timer lives in the closure, so every call to the wrapper sees the same value; that is how one call knows an earlier one is still waiting. Each call to debounce gets its own independent timer.
Cancel the pending call
clearTimeout(timer);
Each invocation cancels the pending call, if any. Passing undefined to clearTimeout on the first run is harmless.
Schedule a fresh call
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
A new timeout runs the original function after the delay. With 500 ms, rapid calls play out like this:
Call
↓
500ms timer starts
Call again
↓
Old timer cancelled
↓
New 500ms timer starts
Call again
↓
Old timer cancelled
↓
New 500ms timer starts
The function only runs once a full 500 ms window passes without another call, and the last arguments win.
Wiring the utility into the search box
Replace the plain handler with a debounced version (this replaces the earlier searchProducts declaration, since both use the same name):
const searchProducts = debounce((query) => {
console.log("API request:", query);
}, 500);
searchInput.addEventListener("input", (event) => {
searchProducts(event.target.value);
});
When the user types the familiar five characters:
r
re
rea
reac
react
each keystroke cancels the previous timer, and eventually the quiet period completes:
react
↓
500ms
↓
API request
The result is a single API request instead of five.
Why the wrapper forwards arguments and this
Two details look optional but make the utility reusable. The first is the rest parameter:
return function (...args)
The second is how the original is invoked:
fn.apply(this, args);
Supporting any number of arguments
A generic debounce should not assume anything about the wrapped function's signature. It might wrap a one-argument search:
const search = debounce((query) => {
console.log(query);
}, 500);
or a function that takes two values:
const calculate = debounce((a, b) => {
console.log(a + b);
}, 500);
Collecting everything with rest syntax and passing it through keeps both working:
...args
Preserving the calling context
The same line also passes along this:
fn.apply(this, args);
The wrapper is a regular function, so it receives the caller's this, such as an object when the debounced function is stored as a method. The arrow function inside setTimeout has no this of its own, so it captures the wrapper's, and apply hands it on. A search handler never notices, but a shared utility that drops context causes confusing bugs in object-based code. Note that an arrow function passed as fn keeps its lexical this regardless.
Where debouncing pays off
The pattern fits any event that fires in quick bursts when only the final state matters.
Search suggestions
The naive flow sends a request for each keystroke:
User types → API request
The debounced flow waits for the pause first:
User types
↓
Wait
↓
User stops
↓
API request
Window resizing
Dragging a window edge emits resize events continuously, and a direct listener runs the expensive work each time:
window.addEventListener("resize", () => {
expensiveCalculation();
});
That can mean hundreds of executions per drag. Wrapped, the work runs once the resize settles:
const handleResize = debounce(() => {
expensiveCalculation();
}, 300);
window.addEventListener("resize", handleResize);
Auto-save
In an editor, debouncing turns a stream of edits into one save after the writer pauses:
User types
User types
User types
User types
...
User stops
↓
Save document
Caveat: a trailing debounce never fires while the user types nonstop, and a pending save is lost if the page closes. Editors usually add a maximum wait or save on visibilitychange.
Debouncing versus throttling
The two are easy to confuse. Debouncing waits for activity to stop, then runs once:
Typing → Typing → Typing → STOP
↓
Execute
Throttling runs the function at most once per fixed interval, however many events arrive:
Event → Execute
↓
100ms
↓
Ignore
↓
100ms
↓
Execute
Leading versus trailing execution adds nuance, but this distinction covers most cases.
Debounce does not fix stale responses
Debouncing limits how many requests start, not what happens to those already in flight. Suppose a user pauses long enough for a request to go out, then refines the query:
User searches: react
↓
Request A
User changes search: react native
↓
Request B
If request B returns before request A, the older response arrives last and replaces the newer results. The UI then shows matches for:
react
even though the input now says:
react native
The fix is to cancel outdated requests with AbortController, layered on top of the debounce:
Debouncing
↓
Reduce unnecessary requests
↓
AbortController
↓
Cancel outdated requests
This race is covered in depth in fixing race conditions debouncing cannot solve.
Putting both layers together
This flow keeps a module-level controller. Each time the debounced function runs, it aborts the previous request, creates a new controller and passes its signal to fetch. encodeURIComponent protects special characters, and an AbortError is ignored because it means intentional cancellation, not failure:
const searchInput = document.querySelector("#search");
let controller;
const searchProducts = debounce(async (query) => {
controller?.abort();
controller = new AbortController();
try {
const response = await fetch(
`/api/products?q=${encodeURIComponent(query)}`,
{
signal: controller.signal
}
);
const data = await response.json();
renderResults(data);
} catch (error) {
if (error.name !== "AbortError") {
console.error(error);
}
}
}, 500);
searchInput.addEventListener("input", (event) => {
searchProducts(event.target.value);
});
The debounce avoids a request per keystroke; the AbortController stops requests whose results no longer matter.
In production, also skip empty queries and check response.ok before parsing, since fetch does not reject on HTTP error statuses.
A quick decision model
When an event fires repeatedly, ask whether you need to respond to each one. If not, a second question decides. For input you want the final value, so debounce:
Input events
↓
Do I need every event?
↓
No
↓
Do I care about the final state?
↓
Yes
↓
Debounce
For scrolling you want regular updates during the gesture, so throttle:
Scroll events
↓
Do I need every event?
↓
No
↓
Do I need periodic updates while scrolling?
↓
Yes
↓
Throttle
Wrapping up
Debouncing is really about controlling how often expensive work runs, and the mechanism is two lines:
clearTimeout(timer);
timer = setTimeout(() => {
fn();
}, delay);
Reach for it when events like these fire in rapid bursts:
input
resize
search
auto-save
A few points to carry forward:
- The debounced function must be created once and reused; creating a new one per event (or per React render without
useMemooruseRef) gives each call its own timer and disables the effect. - Forwarding
...argsandthismakes the helper safely reusable. - Choose debounce for "final state" events and throttle for "steady updates while it happens" events.
- Debounce decides when a request begins;
AbortControllerdecides whether an outdated one is allowed to finish. Use both for network-backed search.