Debounce and throttle are the most common JavaScript performance implementation questions. Learn the difference and build both from scratch.
Picture an elevator. People keep pressing the button. The elevator doesn't leave the moment the first person presses it — it waits. If someone else presses within the next few seconds, the timer resets. Only after nobody has pressed for a few seconds does it finally close the doors and go. That's debounce — wait for the activity to stop, then act. Now picture a turnstile at a subway entrance. No matter how fast you push against it, it only lets one person through per second. The rate is fixed. You can push faster, but the turnstile doesn't speed up. People get queued or dropped, but the throughput stays controlled. That's throttle — act at most once per interval, regardless of how many triggers occur. Both solve the same root problem: an event fires far more often than you can or should respond to it. A user typing fires keydown/keyup dozens of times per second. A scroll event fires hundreds of times per scroll. A resize event fires continuously during window drag. Neither debounce nor throttle is universally better — they answer different questions. Debounce: "when has the activity finally stopped?" Throttle: "how often should I respond while activity is ongoing?"
function debounce(fn, delay) {
let timerId = null
return function(...args) {
// Cancel any pending execution
clearTimeout(timerId)
// Schedule a new one
timerId = setTimeout(() => {
fn.apply(this, args)
timerId = null
}, delay)
}
}
// Usage:
const handleSearch = debounce((query) => {
fetch(`/api/search?q=${query}`).then(renderResults)
}, 300)
searchInput.addEventListener('input', (e) => handleSearch(e.target.value))
// User types "javascript" — 9 keystrokes, but only 1 API call
// The call fires 300ms after the last keystroke
// Leading edge: fire IMMEDIATELY on first call, then ignore until quiet
// Trailing edge (default): fire AFTER quiet period ends
// Leading + trailing: fire on both
function debounce(fn, delay, { leading = false, trailing = true } = {}) {
let timerId = null
let lastArgs = null
return function(...args) {
const shouldCallNow = leading && !timerId
lastArgs = args
clearTimeout(timerId)
timerId = setTimeout(() => {
if (trailing && lastArgs) {
fn.apply(this, lastArgs)
}
timerId = null
lastArgs = null
}, delay)
if (shouldCallNow) {
fn.apply(this, args)
}
}
}
// Leading debounce — great for button click handlers
// First click fires immediately, subsequent rapid clicks ignored until quiet
const handleSubmit = debounce(submitForm, 1000, { leading: true, trailing: false })
submitButton.addEventListener('click', handleSubmit)
// Prevents double-submit on fast double-click, but feels instant on first click
function throttle(fn, interval) {
let lastCallTime = 0
return function(...args) {
const now = Date.now()
if (now - lastCallTime >= interval) {
lastCallTime = now
fn.apply(this, args)
}
// Calls within the interval are silently dropped
}
}
// Usage:
const handleScroll = throttle(() => {
updateScrollIndicator()
lazyLoadImages()
}, 100)
window.addEventListener('scroll', handleScroll)
// Scroll fires ~100 times per second
// handleScroll executes at most 10 times per second (every 100ms)
// Problem with basic throttle: if the last event falls in the ignored window,
// the final position/state is never processed
function throttle(fn, interval) {
let lastCallTime = 0
let trailingTimer = null
return function(...args) {
const now = Date.now()
const remaining = interval - (now - lastCallTime)
if (remaining <= 0) {
// Enough time has passed — execute immediately
clearTimeout(trailingTimer)
trailingTimer = null
lastCallTime = now
fn.apply(this, args)
} else if (!trailingTimer) {
// Schedule a trailing call for the end of the interval
trailingTimer = setTimeout(() => {
lastCallTime = Date.now()
trailingTimer = null
fn.apply(this, args)
}, remaining)
}
}
}
// Now the final scroll/resize position is always processed
// For visual updates, throttle to the display frame rate — not a fixed ms interval
function rafThrottle(fn) {
let frameRequested = false
return function(...args) {
if (!frameRequested) {
frameRequested = true
requestAnimationFrame(() => {
fn.apply(this, args)
frameRequested = false
})
}
}
}
// Perfect for: scroll-linked animations, sticky header position, parallax effects
const updateParallax = rafThrottle(() => {
const scrollY = window.scrollY
heroImage.style.transform = `translateY(${scrollY * 0.5}px)`
})
window.addEventListener('scroll', updateParallax)
// Runs at most once per animation frame (~60fps)
// No visual intermediate states are skipped — always uses the latest scroll position
// DEBOUNCE — when you only care about the FINAL value after activity stops
const searchInput = debounce((value) => {
fetchSuggestions(value) // only run when user stops typing
}, 300)
const resizeHandler = debounce(() => {
recalculateLayout() // only run when resize is done
}, 200)
const autoSave = debounce(() => {
saveToServer(editorContent) // save 2 seconds after last edit
}, 2000)
// THROTTLE — when you need REGULAR updates during ongoing activity
const scrollHandler = throttle(() => {
updateScrollProgress() // update every 100ms while scrolling
}, 100)
const mouseMoveHandler = throttle((e) => {
updateCustomCursor(e) // track cursor position at 30fps
}, 33)
const logActivity = throttle(() => {
sendHeartbeat() // send heartbeat at most once per 10 seconds
}, 10000)
// NEITHER — when every event matters
button.addEventListener('click', handleClick) // don't throttle discrete clicks
input.addEventListener('focus', handleFocus) // don't debounce focus events
function debounce(fn, delay) {
let timerId = null
function debounced(...args) {
clearTimeout(timerId)
timerId = setTimeout(() => {
fn.apply(this, args)
timerId = null
}, delay)
}
// Cancel pending execution
debounced.cancel = function() {
clearTimeout(timerId)
timerId = null
}
// Check if a call is pending
debounced.pending = function() {
return timerId !== null
}
return debounced
}
// Usage:
const debouncedSearch = debounce(search, 300)
searchInput.addEventListener('input', (e) => debouncedSearch(e.target.value))
// When component unmounts / user navigates away:
debouncedSearch.cancel() // prevent stale API call from firing after navigation
// useDebounce — debounce a value
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer) // cleanup on value change or unmount
}, [value, delay])
return debouncedValue
}
// Usage:
function SearchComponent() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 300)
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery)
}, [debouncedQuery]) // only fetches 300ms after user stops typing
return setQuery(e.target.value)} />
}
// useThrottle hook:
function useThrottle(fn, interval) {
const fnRef = useRef(fn)
useEffect(() => { fnRef.current = fn }, [fn])
return useCallback(
throttle((...args) => fnRef.current(...args), interval),
[interval] // stable reference — throttle function not recreated on renders
)
}Many devs think debounce and throttle are the same thing with different timing — but actually they answer different questions. Debounce asks "has the activity stopped?" and fires after a quiet period. Throttle asks "how often should I respond?" and fires on a regular schedule regardless of when the last trigger was. Using debounce on a scroll handler means the handler only fires after the user stops scrolling — great for some cases, wrong for anything that should update during the scroll.
Many devs think debounce means the call is delayed by the specified time — but actually debounce means the call is delayed by the specified time AFTER THE LAST TRIGGER. If a user types a character, waits 200ms, types another, and waits 200ms, the debounced function never fires because a new 200ms countdown starts each time. The delay is always from the last event, not from the first. The total delay could be much longer than the debounce duration.
Many devs think throttle guarantees the last event is always processed — but actually basic throttle (timestamp comparison only) silently drops events that fall in the cooldown window. If a user scrolls to position 1000 but the last throttled call only saw position 850, the final scroll position is never processed. A throttle implementation with a trailing call (using setTimeout for the remaining window) is needed to guarantee the last event is eventually processed.
Many devs think they should always use lodash's debounce/throttle rather than writing their own — but actually for many use cases a simple custom implementation is preferable. Lodash's debounce is ~100 lines with many options most code never uses. A 10-line custom debounce tailored to one use case is more readable, has no dependency, and is easier to modify. The right choice depends on whether you need lodash's edge cases (leading/trailing options, cancel, flush, maxWait).
Many devs think debouncing in React should be done with useCallback wrapping a debounced function — but actually useMemo or useRef is the right hook for a debounced function, because you need a stable reference to the same debounced instance across renders. If you recreate the debounced function inside useCallback with dependencies, you may reset the internal timer on every render, defeating the purpose. The debounced function should be created once and referenced via useRef.
Many devs think throttle with a short interval is equivalent to debounce — but actually they behave differently even with the same timing. Throttle always fires on the first trigger (at t=0), then gates subsequent calls. Debounce never fires on the first trigger — it always waits for the quiet period. At the same interval, throttle is more responsive (immediate first call) but fires more often; debounce is more conservative (waits for silence) but gives you the final value.
Search-as-you-type autocomplete — every major search interface (Google, GitHub, Algolia) uses debounce on the search input. Firing an API request on every keystroke would mean "javascript" triggers 10 sequential requests, with the results arriving out of order and the last one potentially not being the final query. Debouncing at 200-300ms means one request fires after the user pauses, the correct query is sent, and the UI doesn't flicker with intermediate results.
Window resize handler — layout recalculation in response to resize is expensive. Recalculating a masonry grid or a responsive chart on every pixel of a window drag fires hundreds of times per second. Debouncing the resize handler means the recalculation fires once after the user releases the window edge, costing one expensive calculation instead of hundreds. The user doesn't perceive the delay because the layout is correct by the time they stop dragging.
Infinite scroll — scroll handlers that trigger data loading should be throttled, not debounced. With debounce, loading would only trigger after the user stops scrolling — too late for a smooth experience. With throttle (or rAF throttle), the position is checked on a regular cadence during scrolling, and a fetch is triggered when the user gets close to the bottom, giving time for data to arrive before they reach the end.
React's useTransition and the debounce overlap — React 18's startTransition marks state updates as non-urgent, letting React defer them. This solves the typing-in-a-search-box problem differently than debounce: the input value updates immediately (urgent), while the filtered results state update is deferred (non-urgent). React's scheduler is smarter than a fixed debounce delay — it defers exactly as long as needed, not a fixed 300ms. Both patterns have their place: useTransition for React render cost, debounce for external API calls.
Game development loops in browser games use a hybrid: requestAnimationFrame for rendering at the display refresh rate, and a fixed timestep for physics/game logic updates. The game loop is neither debounced nor throttled in the traditional sense — it's rate-controlled to maintain consistent simulation regardless of rendering performance. Understanding rAF throttle is foundational to browser game architecture.
Form auto-save — applications like Google Docs, Notion, and Figma auto-save on a debounced interval from the last edit. A 2-second debounce means the save fires 2 seconds after the user stops typing, not on every keystroke. Combined with optimistic UI (showing "Saved" before the server responds) and a leading-edge save on blur (when the user switches tabs), the experience feels both responsive and reliable, all built on the same simple debounce primitive.
No questions tagged to this topic yet.
Tag questions in Admin → Questions by setting the "Topic Page" field to javascript-debounce-throttle-interview-questions.
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.