Course: JavaScript Advanced
Lesson 11: Debouncing and Throttling
Level
Advanced
Estimated Reading Time
10–15 minutes
Goal
By the end of this lesson, the learner will be able to:
- Explain the difference between debouncing and throttling and when to use each
- Implement a debounce function from scratch using closures and setTimeout
- Implement a throttle function from scratch using timestamps or flags
Frontend Development Context
High-frequency events are everywhere in frontend development. A user types in a search field, fires 10 keystrokes per second. A user scrolls a page, triggering hundreds of scroll events per second. A user resizes the window, firing resize events continuously. If you run expensive operations — API calls, DOM recalculations, canvas redraws — on every single one of these events, you can easily freeze the UI, hammer your API servers, or drain a mobile device's battery.
Debouncing and throttling are the standard tools for controlling event-driven call frequency. They are used in every production frontend: search-as-you-type with debounce prevents an API call on every keystroke; throttle on scroll events keeps infinite scroll or parallax animations from recalculating layout 60 times per second. Understanding how to implement these from scratch (not just use a library) gives you the knowledge to customize them for unusual timing requirements.
Explanation
Debouncing delays a function call until a specified time has passed since the last invocation. If the function is called again before the delay expires, the timer resets. The function only actually runs when the user "pauses" — stops triggering events — for the specified duration. This is perfect for search fields: you want to call the API after the user stops typing, not on every keystroke.
Throttling ensures a function runs at most once per specified interval, regardless of how many times it is called. If the function is called 100 times in one second and the throttle interval is 200ms, it runs at most 5 times. Unlike debounce, throttle guarantees regular execution during a continuous stream of events. This is ideal for scroll handlers, mouse move events, and window resize — cases where you want periodic updates, not just a final one.
A debounce implementation uses a closure to hold a timer reference. Every call clears the existing timer and sets a new one. Only the last call's timer actually fires. The returned function replaces the original, so callers use it exactly like the original function.
A throttle implementation can use either a timestamp comparison or a boolean flag. The timestamp approach checks whether enough time has passed since the last real call and only runs if it has. The flag approach sets a "cooling down" boolean after each call and clears it after the interval. Both approaches work; the timestamp version handles edge cases (like the system clock jumping) more robustly.
Both debounce and throttle return a new function — they are higher-order functions that wrap the original. The wrapping function is what gets attached to the event, and it controls when the original function is actually invoked.
Code Example
This example implements debounce and throttle from scratch, showing closures managing timer state, and demonstrates practical usage for search input and scroll handling.
// Debounce — delays execution until ms after the last call
function debounce(fn, ms) {
let timerId = null;
return function (...args) {
clearTimeout(timerId); // cancel any pending call
timerId = setTimeout(() => {
fn.apply(this, args);
timerId = null;
}, ms);
};
}
// Throttle — ensures fn runs at most once per ms interval
function throttle(fn, ms) {
let lastRun = 0;
let timerId = null;
return function (...args) {
const now = Date.now();
const remaining = ms - (now - lastRun);
if (remaining <= 0) {
// Enough time has passed — run immediately
clearTimeout(timerId);
timerId = null;
lastRun = now;
fn.apply(this, args);
} else if (!timerId) {
// Schedule a trailing call for the end of the interval
timerId = setTimeout(() => {
lastRun = Date.now();
timerId = null;
fn.apply(this, args);
}, remaining);
}
};
}
// --- Usage: debounced search ---
function callSearchAPI(query) {
console.log(`[API] Searching for: "${query}"`);
}
const debouncedSearch = debounce(callSearchAPI, 400);
// Simulate fast keystrokes — only the last one fires the API call
debouncedSearch("j");
debouncedSearch("ja");
debouncedSearch("jav");
debouncedSearch("java");
debouncedSearch("javas");
// After 400ms of silence: "[API] Searching for: "javas""
// --- Usage: throttled scroll handler ---
function updateScrollPosition(scrollY) {
console.log(`[Scroll] Y position: ${scrollY}px`);
}
const throttledScroll = throttle(updateScrollPosition, 200);
// Simulate rapid scroll events at ~60fps
let y = 0;
const scrollInterval = setInterval(() => {
y += 20;
throttledScroll(y);
if (y >= 600) clearInterval(scrollInterval);
}, 16); // 16ms ≈ 60fps
// Logs at most once per 200ms, regardless of 60fps event rate
Code Explanation
debounce uses a closure to keep timerId alive between calls. Each time the returned function is invoked, clearTimeout(timerId) cancels the previous pending call and a new setTimeout is set. This means the original fn is never called while invocations are arriving faster than ms milliseconds apart. Only when there's a pause longer than ms does the setTimeout callback actually fire and call fn.
throttle uses a timestamp comparison. lastRun records when fn last actually ran. On each call, remaining is calculated as the milliseconds left in the current interval. If remaining <= 0, enough time has passed — fn runs immediately and lastRun is updated. If remaining > 0 and no trailing timer is set, a timer is scheduled for the remaining time, ensuring fn fires at least once at the end of the interval even if calls stop arriving.
In the search simulation, five rapid calls are made but only the last one — "javas" — actually triggers the API call 400ms later. All intermediate calls simply reset the timer. In the scroll simulation, events arrive at 60fps (every 16ms) but throttledScroll fires at most once per 200ms, dramatically reducing the number of expensive scroll handler executions.
Both functions use fn.apply(this, args) to correctly forward the this context and all arguments to the original function, so they work correctly even when used as object methods or event listeners.
Pattern Highlights
✅ Positive Pattern: Use debounce for events where you only care about the final settled value (search input, form validation, window resize final state) and throttle for events where regular periodic execution is needed (scroll position, mouse tracking, animation frame updates).
⚠️ Neutral Note: A debounced function with a long delay feels unresponsive. A throttled function with a long interval feels choppy. Tune the timing to match the user's expectation — 300–400ms for search debounce is typical; 100–200ms for scroll throttle keeps animations smooth.
❌ Negative Pattern: Do not create a new debounced or throttled function inside a render function or event listener that fires frequently — each call to
debounce(fn, ms)creates a new independent timer closure; the timer is never shared and the debounce never fires.
Common Mistakes
Mistake 1: Recreating the debounced function on every render
If debounce(fn, ms) is called inside a React render or inside an event handler, a new closure is created every time — the timer is never shared between calls and debouncing doesn't work.
function SearchInput() {
function handleChange(e) {
// New debounce closure every time handleChange is called — broken!
const search = debounce(callAPI, 400);
search(e.target.value);
}
return <input onChange={handleChange} />;
}
Create the debounced function once, outside the render cycle (or with useMemo/useCallback in React), so all calls share the same timer closure.
Mistake 2: Using debounce where throttle is needed
Debounce during a continuous scroll event means the handler never fires while the user is scrolling — it only fires when they stop. If you're updating a sticky header or parallax element, it should update while scrolling, not after.
window.addEventListener("scroll", debounce(updateHeader, 200));
// updateHeader only runs when scrolling STOPS — header freezes during scroll
Use throttle for continuous feedback during scroll, and debounce only for cleanup actions after scrolling ends.
Mistake 3: Forgetting to pass arguments through
Implementing debounce or throttle without forwarding arguments drops the data the callback needs.
function debounce(fn, ms) {
let timer;
return function () { // no ...args
clearTimeout(timer);
timer = setTimeout(fn, ms); // fn called with no arguments!
};
}
The wrapped function must capture ...args and pass them to fn in the setTimeout callback, or the original function receives no data.
Quick Recap
- Debounce delays function execution until a specified time after the last invocation — use it for search fields, form validation, and resize events where only the final value matters.
- Throttle limits function execution to at most once per interval — use it for scroll handlers, mouse move events, and animations where regular periodic updates are needed.
- Both are implemented as higher-order functions that return a new function closing over a timer or timestamp; the returned function must be created once and reused, not recreated on every event.
- Tune delay values to match user expectations: too short and you defeat the purpose, too long and the UI feels sluggish.