Debounce and throttle in JavaScript: stop running code on every keystroke
Mosaddik Billah 4 min read

On this page
Type “hello” into a search box that calls an API on every keystroke and you’ve just sent five requests: h, he, hel, hell, hello. Four of them are useless, they cost money if the API is paid, and they can arrive out of order, so the results for hel show up after the results for hello.
Debounce and throttle fix this. They’re small functions, about ten lines each, and once you know them you’ll spot places to use them everywhere.
The difference in one sentence each#
- Debounce: wait until things go quiet, then run once. “Search when the user stops typing.”
- Throttle: run at most once every so often, no matter how many events come in. “Update the position at most every 200 ms while scrolling.”
Debounce cares about the last event. Throttle cares about a steady rate.
Debounce#
function debounce(fn, wait = 300) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}Every call cancels the previous timer and starts a new one. So fn only runs when wait milliseconds pass without another call.
Using it:
const input = document.querySelector("#search");
const search = debounce((query) => {
console.log("Searching for", query);
// fetch(`/api/search?q=${encodeURIComponent(query)}`) ...
}, 300);
input.addEventListener("input", (event) => search(event.target.value));I ran the same function with five quick calls, 30 ms apart and a 100 ms wait. It logged once, with "hello". That’s the whole point.
What wait time to use? For a search box, 250 to 400 ms feels right: fast enough that it doesn’t feel laggy, slow enough to skip most keystrokes. For saving a draft while typing, a second or two is fine.
Throttle#
function throttle(fn, wait = 200) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn.apply(this, args);
}
};
}It remembers when it last ran and ignores calls until wait has passed. When I called it ten times, 30 ms apart, with a 100 ms wait, it ran on calls 0, 4 and 8. Steady, not every time.
Using it:
const onScroll = throttle(() => {
const scrolled = window.scrollY / (document.body.scrollHeight - window.innerHeight);
document.querySelector("#progress").style.width = `${scrolled * 100}%`;
}, 100);
window.addEventListener("scroll", onScroll, { passive: true });One limitation of this simple version: it drops the last call if it lands inside the wait window. For a progress bar that can mean it stops at 97% instead of 100%. If the final value matters, either use a debounce as well, or use a library version (see below) that runs once more at the end.
Which one do I need?#
| Situation | Use |
|---|---|
| Search or autocomplete as you type | Debounce |
| Saving a form or draft while typing | Debounce |
| Checking if a username is taken | Debounce |
| Window resize, recalculating a layout | Debounce |
| Scroll position, progress bars, infinite scroll | Throttle |
| Mouse move or drag | Throttle (or requestAnimationFrame) |
| Stopping double clicks on a submit button | Neither. Disable the button while the request runs. |
That last row matters. People reach for debounce to stop double submissions, but a disabled button is clearer for the user and can’t be beaten by a slow network.
Debouncing a search box in React#
In React you usually debounce the effect, not the function. Keep the input fast and let the request wait:
import { useEffect, useState } from "react";
function Search() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
setResults(await res.json());
} catch (err) {
if (err.name !== "AbortError") console.error(err);
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{results.map((r) => <li key={r.id}>{r.title}</li>)}
</ul>
</>
);
}Every time query changes, the cleanup function cancels the previous timer and aborts any request still in flight. That solves both problems from the start of this post: fewer requests, and no old results overwriting new ones.
If the cleanup function and AbortController are new to you, both are explained in React useEffect explained. For checking res.ok and handling errors properly, see JavaScript fetch explained.
A common mistake is writing const search = debounce(...) directly in the component body. React re-runs that line on every render, so you get a brand-new debounce with a brand-new timer each time, and nothing is ever debounced. If you do want a debounced function in a component, create it once with useMemo or useRef.
Should I use a library?#
For most projects, the ten-line versions above are enough, and they’re easy to read. If you need extras, like running on both the first and last call (leading and trailing options), cancelling a pending call, or flushing it immediately, use debounce and throttle from lodash (lodash.debounce is available on its own too). They’re well tested and handle the edge cases.
If you’re building a search, filter or live-update feature and want it done properly, I do API integration work for exactly this kind of thing.
Comments