TL;DR
- Debounce waits for inactivity; throttle limits execution frequency.
- I use debounce for search input and resize handlers, throttle for scroll and pointer movement.
- The implementation details that matter are cancellation, leading vs trailing behavior, and preserving
thisplus arguments. - These helpers are easy to write badly and surprisingly easy to misuse.
Context
This is one of those topics that sits halfway between algorithms and user experience. I keep it close because it shows up in interviews, frontend performance work, and “why does this UI feel jittery?” debugging.
The Approach
I start from intent:
- Debounce: run after a burst stops
- Throttle: run at most once during a time window
function debounce(fn, delay) {
let timerId
return function debounced(...args) {
clearTimeout(timerId)
timerId = setTimeout(() => fn.apply(this, args), delay)
}
}
function throttle(fn, delay) {
let lastRun = 0
return function throttled(...args) {
const now = Date.now()
if (now - lastRun >= delay) {
lastRun = now
fn.apply(this, args)
}
}
}
I usually extend these with:
cancel()flush()- leading and trailing edge options
For a search box, debounce avoids unnecessary calls:
const onInput = debounce(async (query) => {
const results = await search(query)
render(results)
}, 250)
For scroll position tracking, throttle avoids over-rendering:
window.addEventListener("scroll", throttle(updateProgressBar, 100))
Trade-offs
- Debounce improves efficiency but can make a UI feel laggy if the delay is too large.
- Throttle keeps the UI responsive but may skip intermediate states.
- A custom helper is fine for learning, but in production I still validate behavior against real user interaction before I keep it.