A debounce makes a function wait until the calls stop coming before it actually runs. You wire it onto an event that fires too often (a keystroke, a resize, a scroll), and instead of running on every event it runs once, after a quiet gap you choose. In 2026 you do not need Underscore or lodash for this. The whole thing is about ten lines of standard JavaScript:
// te_ prefix marks this as a TechEarl example helper
function teDebounce(fn, wait, { leading = false } = {}) {
let timer;
return function (...args) {
const callNow = leading && !timer;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!leading) fn.apply(this, args);
}, wait);
if (callNow) fn.apply(this, args);
};
}Use it by wrapping the handler once and binding the wrapped version:
const onSearch = teDebounce((event) => {
fetchResults(event.target.value);
}, 300);
input.addEventListener("input", onSearch);Type "react", and fetchResults fires once, 300ms after you stop, with the value "react". Not five times, once per letter. That is the entire point: collapse a burst of events into a single call.
The version below is the one I keep reaching for. It uses const, rest params (...args) instead of the old arguments object, a leading edge option, and a .cancel() method so a pending call can be thrown away. Then I cover throttle (a different tool for a different problem), the AbortController variant, the React story, and the this gotcha that bites people who reach for an arrow function.
How the implementation works
Each call clears the previous timer and starts a new one. As long as calls keep arriving inside the wait window, the timer keeps getting reset and fn never runs. The moment a full wait passes with no new call, the timer fires and fn runs with the most recent arguments. fn.apply(this, args) forwards both the this context and the arguments the handler was called with, so the wrapped function behaves like the original.
The leading option flips the timing. With leading: false (the default) the call happens on the trailing edge, after the quiet gap. With leading: true it fires immediately on the first call and then ignores everything until the burst ends. Leading is what you want for something like a "save" button you do not want double-fired: react instantly, then go quiet.
A cancel method (and the AbortController version)
A pending debounced call sometimes needs to be dropped before it runs: a component unmounts, a modal closes, the user navigates away. Hang a .cancel() on the returned function:
function teDebounce(fn, wait, { leading = false } = {}) {
let timer;
const debounced = function (...args) {
const callNow = leading && !timer;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!leading) fn.apply(this, args);
}, wait);
if (callNow) fn.apply(this, args);
};
debounced.cancel = () => {
clearTimeout(timer);
timer = null;
};
return debounced;
}Call onSearch.cancel() in a cleanup path and any queued call is gone. If your codebase already passes AbortSignal around (it is the standard cancellation primitive now, used by fetch, event listeners, and more), you can wire the debounce to a signal instead of exposing your own method:
function teDebounce(fn, wait, { signal } = {}) {
let timer;
signal?.addEventListener("abort", () => clearTimeout(timer));
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
const controller = new AbortController();
const onSearch = teDebounce(handleSearch, 300, { signal: controller.signal });
// later, one abort() cancels the pending call and any fetch sharing the signal
controller.abort();The win is that one controller.abort() tears down the pending timer and any in-flight request that shares the same signal, which kills the stale-response race that plagues search-as-you-type.
Debounce vs throttle: pick by intent
These get conflated constantly, but the rule is short:
- Debounce: wait for quiet. Run once, after the events stop. Good when you only care about the final state. Search-as-you-type, autosave, validating a field after the user finishes typing.
- Throttle: at most once per N ms. Run on a fixed cadence while events keep coming. Good when you want steady updates during the activity, not just at the end. A scroll position readout, a progress indicator, a drag handler.
Resize the window with a debounce and your relayout fires once, when you let go. Resize with a throttle and it fires every 200ms during the drag. Same event, opposite behavior, and the wrong one feels broken.
A throttle is also short, no library required:
function teThrottle(fn, interval) {
let last = 0;
let timer;
let lastArgs;
let lastThis;
return function (...args) {
lastArgs = args;
lastThis = this;
const now = Date.now();
const remaining = interval - (now - last);
if (remaining <= 0) {
clearTimeout(timer);
timer = null;
last = now;
fn.apply(this, args);
} else if (!timer) {
// trailing call so the final event isn't dropped
timer = setTimeout(() => {
last = Date.now();
timer = null;
fn.apply(lastThis, lastArgs);
}, remaining);
}
};
}That fires on the leading edge and guarantees a trailing call, so the last event in a burst is never lost (a common bug in naive throttles that only check elapsed time). Note the lastArgs/lastThis capture: every call refreshes them, so the trailing invocation runs with the most recent event, not the one that happened to schedule the timer. A throttle that closes over the scheduling call's arguments instead will fire stale data on the trailing edge, which is the subtle version of the same bug.
requestAnimationFrame often beats throttle for scroll
If the throttled work is visual, painting, animating, reading layout, do not pick a millisecond interval at all. Use requestAnimationFrame. It runs your callback right before the browser's next paint, so it is effectively a throttle locked to the display's refresh rate (about 16ms at 60Hz, but it self-adjusts) with far better fidelity than a guessed setTimeout value:
let ticking = false;
window.addEventListener("scroll", () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
updateParallax(window.scrollY);
ticking = false;
});
});The ticking flag is the throttle: at most one update per frame, and that update lands exactly when the browser is about to paint. For a scroll-position readout or a sticky header, this is smoother than any timer-based throttle. Keep the timer-based throttle for non-visual rate limiting (network calls, logging, analytics beacons).
React: useDeferredValue and useDebouncedValue
In React you usually do not hand-roll a debounce around a setState. There are two tools, and they solve different halves of the problem:
useDeferredValuedefers a low-priority re-render so the input stays responsive while an expensive list updates behind it. No delay to pick, and it is interruptible. But the re-render still happens, so it will not cut your network requests. It is a rendering-priority tool, not a rate limiter.- A debounced value (a small custom hook, or
useDebouncedValuefrom a utility library) holds back the state change itself for N ms. That is what actually reduces the number of fetches when the user types.
The pattern I reach for combines them: debounce the value that triggers the network call, and wrap the expensive render in useDeferredValue for the concurrent-rendering smoothness. Use debounce to cut requests, defer to cut jank. The native setTimeout, sleep, and timing toolkit post covers the underlying timer behavior if you want to build the hook yourself.
The arrow-function this caveat
The implementation uses function (...args) for the returned wrapper, not an arrow function, on purpose. An arrow function captures this from where it is defined, so it cannot forward the call site's this to fn. If you wrote the wrapper as an arrow and your debounced handler relies on this (a method on an object, an event handler where this is the element), you would get the wrong context.
The safe rules: keep the returned wrapper a regular function so this flows through fn.apply(this, args), or sidestep the whole issue by binding the method up front (teDebounce(this.handle.bind(this), 300)) or wrapping a class field arrow (handle = () => { ... }) so the function already carries its context. Either is fine. What breaks is making the wrapper itself an arrow and then expecting it to relay this.
Real 2026 use cases
- Search-as-you-type. Debounce the input by 250 to 400ms so the API is hit once per pause, not per keystroke. Pair it with the
AbortControllervariant above to cancel the previous request and dodge out-of-order responses. - Resize. Debounce expensive relayout or chart re-draws on
window.resizeso the work runs once when the user stops dragging, not hundreds of times mid-drag. - Autosave. Debounce a draft save by a second or two after the last edit. Leading-edge throttle if you also want an immediate first save, then a steady cadence.
- Scroll. Reach for
requestAnimationFrame, not a debounce, when the work is visual. Use a throttle (orrAF) for a scroll-linked analytics beacon.
That is the complete toolkit. Ten lines for debounce, a dozen for throttle, requestAnimationFrame for paint-bound work, and the framework hooks where a framework owns the render. The library was never doing anything you cannot read in one screen of code.
FAQ
See also
- Modern JavaScript array methods: the mutation trap,
at,flat, and the corrected grouping API, the same "native beats the library" angle applied to arrays. - setTimeout, sleep, and timing in JavaScript: the timer primitives debounce and throttle are built on, plus async delays and polling done right.
- A practical guide to JavaScript Promises: how
async/awaitand cancellation fit together, useful when your debounced handler kicks off a fetch.
Sources
Authoritative references this article was fact-checked against.
- setTimeout (MDN Web Docs)developer.mozilla.org
- AbortController (MDN Web Docs)developer.mozilla.org
- Window requestAnimationFrame() (MDN Web Docs)developer.mozilla.org
- useDeferredValue (React documentation)react.dev





