Some events fire a lot. Typing in a search box fires an input event for every key. Scrolling fires scroll dozens of times a second. Dragging the window edge fires resize constantly. If each event triggers expensive work, like a network request, a re-render or a layout calculation, the page stutters, batteries drain and servers get flooded with requests nobody needed.
Two classic techniques fix this by controlling how often your code runs. Debounce waits until the events stop for a moment, then runs once: perfect for search boxes, where you only care about what the user finally typed. Throttle runs at most once every so often while events keep coming: perfect for scroll positions and progress bars, where you want regular updates but not hundreds per second.
You met a simple debounced search in the timers lesson. In this lesson you'll understand exactly how each technique works, see them side by side, write reusable debounce and throttle functions using closures, and learn which to choose for common situations.
Debounce vs throttle
| Debounce | Throttle | |
|---|---|---|
| Runs | Once, after events stop for a set time | At most once per time window, while events continue |
| Like… | A lift door that waits until people stop walking in | A tap that drips at a steady rate |
| Best for | Search-as-you-type, autosave, validating a field, window resize finished | Scroll position, infinite scroll checks, mouse move tracking, game input |
| With 10 keystrokes in 1 second, 300ms delay | Runs once, 300ms after the last key | Runs about 4 times |
Ask yourself: "Do I need the final value, or regular updates along the way?" Final value means debounce; regular updates mean throttle.
How debounce works
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const search = debounce((text) => console.log("Search for", text), 300);
input.addEventListener("input", (e) => search(e.target.value));Each call cancels the previous timer and starts a new one. Only when 300ms pass with no new calls does the timer survive and fn run. The variable timer lives in a closure, so every call to the returned function shares it.
How throttle works
function throttle(fn, interval) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn(...args);
}
};
}
window.addEventListener("scroll", throttle(() => console.log(scrollY), 200));This version runs immediately on the first event, then ignores further calls until interval milliseconds have passed. Fuller versions also run once more at the end (a "trailing" call) so the final position is never missed.
Leading, trailing and alternatives
| Option | Meaning |
|---|---|
| Trailing (debounce default) | Run after the pause |
| Leading | Run on the first event, then ignore the rest until things calm down: good for "submit" buttons to stop double clicks |
requestAnimationFrame | Throttle visual updates to once per screen frame |
{ passive: true } | Lets the browser scroll without waiting for your listener |
IntersectionObserver / matchMedia | Often remove the need for scroll and resize listeners entirely |
Libraries such as Lodash provide well-tested debounce and throttle with leading, trailing and maxWait options. Writing your own is a great exercise and a popular interview question.
JavaScript Debounce and Throttle Syntax
Debounce and throttle
const debounced = debounce(fn, 300);
input.addEventListener("input", (e) => debounced(e.target.value));
const throttled = throttle(fn, 200);
window.addEventListener("scroll", throttled, { passive: true });
let ticking = false;
addEventListener("scroll", () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => { update(); ticking = false; });
});JavaScript Debounce and Throttle Summary
- Fast events like
input,scrollandresizecan run expensive code far too often. - Debounce runs once after events stop for a set time: use it when you need the final value.
- Throttle runs at most once per interval while events continue: use it for regular updates.
- Both are built with closures that remember a timer or a timestamp.
- Observers,
matchMediaandrequestAnimationFramecan replace many scroll and resize listeners.
JavaScript Debounce and Throttle Examples
Each example runs in the page. Edit the code and press Run to see what changes.
Raw vs debounced vs throttled
Type quickly in the box and watch the three counters.
<input id="box" placeholder="Type quickly here" style="width: 100%; padding: 8px; font-size: 16px">
<p>Raw events: <b id="raw">0</b></p>
<p>Debounced (400ms): <b id="deb">0</b></p>
<p>Throttled (400ms): <b id="thr">0</b></p>function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
function throttle(fn, interval) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= interval) { last = now; fn(...args); }
};
}
const counts = { raw: 0, deb: 0, thr: 0 };
const show = (id) => (document.getElementById(id).textContent = ++counts[id]);
const onDeb = debounce(() => show("deb"), 400);
const onThr = throttle(() => show("thr"), 400);
document.querySelector("#box").addEventListener("input", () => {
show("raw");
onDeb();
onThr();
});Line-by-line explanation
- Lines 1 to 7Debounce: every call resets the timer.
- Lines 8 to 14Throttle: calls are ignored until the interval has passed.
- Lines 18 to 19Two wrapped versions of the same counter.
- Lines 21 to 25Every keystroke goes to all three, but they run very different numbers of times.
A debounced search with stale result protection
<input id="q" placeholder="Search fruit" style="padding: 8px; font-size: 16px">
<ul id="results"></ul>const fruit = ["apple", "apricot", "banana", "blackberry", "blueberry", "cherry", "grape", "mango"];
function fakeSearch(term) {
return new Promise((resolve) => {
const delay = 100 + Math.random() * 400;
setTimeout(() => resolve(fruit.filter((f) => f.includes(term))), delay);
});
}
let latest = 0;
let timer;
document.querySelector("#q").addEventListener("input", (e) => {
clearTimeout(timer);
timer = setTimeout(async () => {
const id = ++latest;
const term = e.target.value.trim().toLowerCase();
const found = term ? await fakeSearch(term) : [];
if (id !== latest) return;
document.querySelector("#results").replaceChildren(
...found.map((f) => Object.assign(document.createElement("li"), { textContent: f }))
);
console.log(`Searched "${term}": ${found.length} results`);
}, 300);
});Line-by-line explanation
- Lines 3 to 8A pretend server that answers after a random delay.
- Lines 13 to 14Debounce: only search 300ms after typing stops.
- Line 15Number each request.
- Line 18If a newer search started meanwhile, ignore this older answer so results never flash back.
Throttled scroll progress with requestAnimationFrame
Scroll inside the box.
<div id="article" style="height: 160px; overflow-y: auto; border: 1px solid #ddd; padding: 0 10px">
<p style="height: 600px">A long article. Keep scrolling...</p>
</div>
<div style="background: #e5e7eb; height: 8px; margin-top: 8px"><div id="bar" style="height: 8px; width: 0; background: #16a34a"></div></div>const article = document.querySelector("#article");
const bar = document.querySelector("#bar");
let ticking = false;
let updates = 0;
article.addEventListener("scroll", () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const max = article.scrollHeight - article.clientHeight;
bar.style.width = `${(article.scrollTop / max) * 100}%`;
ticking = false;
if (++updates % 10 === 0) console.log(`${updates} bar updates so far`);
});
}, { passive: true });Line-by-line explanation
- Lines 7 to 8If an update is already scheduled for this frame, skip.
- Line 9Do the work once, right before the next paint.
- Lines 10 to 11Width = how far through the article you are.
- Line 15
passivelets scrolling stay smooth.
Common JavaScript Debounce and Throttle Mistakes
Creating a new debounced function on every event
input.addEventListener("input", () => debounce(search, 300)()); // a fresh timer each time, so nothing is debouncedconst debouncedSearch = debounce(search, 300);
input.addEventListener("input", () => debouncedSearch());Debouncing a scroll position
window.addEventListener("scroll", debounce(updateProgressBar, 200)); // bar only moves after scrolling stopswindow.addEventListener("scroll", throttle(updateProgressBar, 100), { passive: true });Losing arguments and this
function debounce(fn, ms) { let t; return () => { clearTimeout(t); t = setTimeout(fn, ms); }; } // drops argumentsfunction debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; }JavaScript Debounce and Throttle in Real Projects
Where these are used
Google's search suggestions, address lookups and username availability checks debounce input before asking the server. Infinite scrolling feeds, sticky headers and reading progress bars throttle scroll handling. Editors like Google Docs debounce autosave, and analytics libraries throttle mouse-movement tracking. Lodash's debounce and throttle are among the most downloaded JavaScript utilities.
JavaScript Debounce and Throttle Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Cancel the previous timer so only the last call runs.
let timer;
function onType(text) {
(timer);
timer = setTimeout(() => console.log(text), 300);
}
onType("a"); onType("ab"); onType("abc");
log is debounced with 100ms. What is logged after a short wait?
const log = debounce((x) => console.log(x), 100);
log(1); log(2); log(3);
setTimeout(() => log(4), 300);Which technique suits a search box that queries a server?
Which technique suits updating a scroll progress bar?
debounce(fn, ms) should return a function that waits until it hasn't been called for ms milliseconds, then calls fn once with the latest arguments. Fix it.
function debounce(fn, ms) {
return (...args) => {
let timer;
clearTimeout(timer);
timer = setTimeout(fn, ms);
};
}
const calls = [];
const d = debounce((v) => calls.push(v), 50);
d("a"); d("b"); d("c");- Runs once with the latest value
- Separate bursts run separately
JavaScript Debounce and Throttle Quiz
5 questions to check what you learned.
What does debounce do?
- Runs code faster
- Runs code at a fixed rate forever
- Removes event listeners
- Waits until events stop for a while, then runs once
With a 1000ms throttle and constant events for 5 seconds, roughly how many times does the function run?
- Once
- Hundreds of times
- About 5 times
- Never
Why do debounce and throttle need closures?
- The returned function must remember the timer or last run time between calls
- To make them asynchronous
- Closures run faster
- To access the DOM
What does a leading debounce do?
- Runs only after the pause
- Runs on the first event, then ignores the rest until things calm down
- Runs twice
- Runs before the page loads
Why can a debounced search still show wrong results?
- Debounce changes the search term
- Debounce is unreliable
- Servers ignore debounced requests
- An older, slower request can finish after a newer one, so results must be checked as the latest
JavaScript Debounce and Throttle Challenge
Write throttle with a trailing call
Write throttle(fn, interval) that returns a function which:
- Calls
fnimmediately on the first call - Ignores further calls until
intervalms have passed since the last run, but remembers the latest arguments - When the interval ends, if there were ignored calls, runs
fnonce more with the latest arguments (a trailing call)
function throttle(fn, interval) {
}
const seen = [];
const t = throttle((v) => seen.push(v), 100);
t(1); t(2); t(3);
setTimeout(() => console.log(seen), 300);- First call runs immediately
- Trailing call uses the latest arguments
- No extra trailing call when quiet