Debounce & throttle: build them, don't import them
Debounce and throttle are two of the most reached-for utilities in frontend work, and two of the most reflexively imported. People pull in lodash for a six-line function they could write — and worse, half of them couldn't tell you which one they actually need. They're not interchangeable. Pick the wrong one and you either drop events you meant to keep or fire far more often than you intended.
So let's build both from scratch. The implementations are small, and writing them once is the fastest way to never confuse the two again.
The problem
Some events fire absurdly often. An input event on a search box fires on every keystroke. scroll and resize can fire dozens of times a second. mousemove is worse. If you do real work in those handlers — a network request, a layout calculation, a re-render — you're doing it far more often than the user could possibly need, and the page stutters.
Debounce and throttle are both ways to limit how often the expensive work runs. They just limit it differently, and that difference is the whole point.
Debounce: wait until things go quiet
Debounce says: "Don't run the function until the events stop for a bit." Every time the event fires, you reset a timer. The function only runs once the events pause for wait milliseconds. If they never pause, it never runs.
function debounce(fn, wait) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), wait);
};
}Each call clears the pending timeout and sets a fresh one. So as long as calls keep coming faster than wait, the timeout keeps getting pushed forward and fn never fires. The moment there's a wait-long gap, the last scheduled timeout survives and runs — with the arguments from that last call.
The canonical use case is search-as-you-type. You don't want to hit the API on every keystroke; you want to wait until the user pauses typing, then fire one request with what they've typed so far:
const search = debounce((query) => {
fetch(`/api/search?q=${encodeURIComponent(query)}`);
}, 300);
input.addEventListener("input", (e) => search(e.target.value));Type "rea", "reac", "react" quickly and you get exactly one request — for "react" — fired 300ms after you stop. The intermediate values never hit the network. That's debounce: collapse a burst into a single trailing call.
Throttle: at most once per interval
Throttle says something different: "Run the function, but no more than once every interval milliseconds." It doesn't wait for quiet. It runs, then ignores calls until the interval has passed, then runs again.
function throttle(fn, interval) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn(...args);
}
};
}The first call runs immediately (last starts at 0, so now - last is huge). After that, any call within interval of the last run is dropped; the first call after the interval elapses runs and resets the clock. So during a continuous stream of events, fn fires on a steady cadence — once per interval — instead of on every event.
The canonical use cases are scroll position and rate-limited analytics. You want updates while the user scrolls, not just when they stop, but you don't need 60 of them per second:
const onScroll = throttle(() => {
updateScrollProgressBar(window.scrollY);
}, 100);
window.addEventListener("scroll", onScroll);Now the progress bar updates roughly every 100ms during a scroll — smooth enough to look continuous, cheap enough not to hurt.
The difference in one sentence
Debounce waits for a pause and fires once at the end; throttle fires on a fixed cadence throughout.
Put another way: if events keep streaming in forever, a debounced function never runs (there's no pause), while a throttled function runs steadily (once per interval). That's the test I use when I'm deciding which one a problem needs:
- Do I only care about the final state after activity settles? → Debounce. Search input, validating a field after typing, saving a draft after edits stop, recomputing layout after a resize finishes.
- Do I want regular updates during continuous activity? → Throttle. Scroll-linked UI, drag handlers, mousemove tracking, capping how often an event fires off an analytics ping.
Mix these up and the bug is subtle but real: debounce a scroll progress bar and it sits frozen until the user stops scrolling, then jumps; throttle a search box and you fire a request mid-word every interval whether the user paused or not.
Edge cases worth handling
The tiny versions above are correct and ship-able, but real-world usage exposes three things the toy implementations skip.
Leading vs trailing. My debounce is trailing — it fires at the end of the burst. My throttle is leading — it fires at the start. Often you want to choose, or want both edges. A debounce with a leading edge fires immediately on the first call, then suppresses the rest of the burst. A throttle with a trailing edge guarantees one final call after the last event, so you don't miss the final position. The trailing throttle matters more than it looks: with the leading-only version above, if the very last scroll event lands inside the cooldown window, its update is dropped and your progress bar stops a few pixels short.
Cancellation. A debounced save has a pending timeout you may need to abandon — the component unmounts, the user hits Escape, navigation happens. Without a way to cancel, that timeout fires against a stale or torn-down context. Expose a .cancel() that clears the pending timer:
function debounce(fn, wait) {
let t;
function debounced(...args) {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), wait);
}
debounced.cancel = () => clearTimeout(t);
return debounced;
}In React, that cancel is exactly what you call in a cleanup function so a pending debounced callback can't fire after unmount.
this and argument binding. This is the one that bites people. The arrow-function versions at the top capture args correctly but throw away this — an arrow function has no this of its own. If you debounce a method that relies on its receiver, the arrow version breaks it. Use a regular function and forward both the arguments and the receiver with fn.apply(this, args), as in the cancellation example above. That way obj.method = debounce(obj.method, 200) still sees the right this when it eventually fires. Forwarding ...args alone isn't enough; you have to forward this too.
Should you ever import them?
Sure — lodash's debounce/throttle are well-tested and handle every edge above and then some. If you already depend on lodash, use them. But "I need to debounce a search box" is not a reason to add a dependency, and reaching for one reflexively means you never learned what the function does. The six-line version covers the common case completely. Build it once, understand exactly where the timer lives and what fires when, and the import becomes a choice instead of a crutch.