Debouncing vs Throttling — and How WhatsApp's Three Dots Actually Work
Notes on rate-limiting UI events · javascript, performance, event-handling
Every frontend dev runs into this eventually: some event fires way more often than you actually need it to. A keystroke event on every character. A scroll event dozens of times a second. A mousemove event on every pixel. Left alone, these events will happily flood your app with work it doesn't need to do.
Debouncing and throttling are the two standard answers to that problem. They sound interchangeable the first time you hear them, and I mixed them up for a while too. They're not interchangeable — they solve two different shapes of the same problem, and the easiest way to actually internalize the difference is to walk through a real feature that needs both at once: the little three-dot "someone is typing" bubble in WhatsApp.
- Debounce waits for a pause in activity, then fires once. Good for "tell me when the user is done."
- Throttle fires at a capped, steady rate no matter how long activity continues. Good for "keep me updated while it's happening."
- The WhatsApp three-dot indicator likely needs both — throttle to keep it alive during a long typing burst, debounce to know when to clear it.
The core difference
Both techniques take a function and a time window, and return a new function that fires less often. Where they differ is how they decide when to let a call through.
Debounce resets a timer on every call. The wrapped function only runs after the calls stop coming in for the full delay. If the user keeps triggering the event, the timer keeps getting pushed back — so a function under debounce can go a very long time without running at all, as long as the triggering event never stops.
function debounce(fn, delay) {
let timer
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
// usage: only search once the user stops typing for 300ms
const debouncedSearch = debounce((query) => fetchResults(query), 300)
input.addEventListener('input', (e) => debouncedSearch(e.target.value))
Throttle does the opposite. It lets the function run immediately, then locks it for a fixed window, ignoring every call that arrives inside that window. Once the window ends, the next call goes through and the lock resets. No matter how long the event keeps firing, the function runs at a predictable, capped rate.
function throttle(fn, limit) {
let inCooldown = false
return (...args) => {
if (inCooldown) return
fn(...args)
inCooldown = true
setTimeout(() => (inCooldown = false), limit)
}
}
// usage: update scroll position at most once every 200ms
const throttledScroll = throttle(() => updateScrollIndicator(), 200)
window.addEventListener('scroll', throttledScroll)
The mental model that finally made it click for me: debounce cares about silence, throttle cares about a schedule. Debounce says "call me back once things settle down." Throttle says "call me at most this often, for as long as things keep happening."
Where a single one of them falls short
It's tempting to treat these as "pick whichever one, they both mean 'don't run this too much.'" They don't behave the same under sustained activity, and that gap is exactly where a feature like a typing indicator breaks if you only reach for one.
Say you debounce a "user is typing" signal with a 3-second delay, and only that. If someone types continuously for 20 seconds without pausing, the debounce timer keeps getting reset on every keystroke — so the "typing" event never fires until they finally stop. The other person's client never even finds out typing started until it's already over. That's backwards for an indicator whose entire job is to show activity while it's happening.
Throttle alone doesn't fully solve it either — throttle can tell the other side "still going" every few seconds, but it has no concept of "activity has stopped," so nothing ever tells the receiver to clear the bubble once the person stops typing. You'd need a separate timeout for that anyway.
So the indicator needs two different signals doing two different jobs, on the same stream of keystrokes.
The three-dot bubble: what it's actually doing
Watch WhatsApp's typing bubble closely and its behavior lines up with exactly this split:
- While you're actively, continuously typing, the other person's three dots stay up the whole time — they don't get refreshed on every single keystroke, but they also don't vanish partway through a long burst of typing.
- The moment you stop typing for a couple of seconds, the dots disappear — even if you never sent anything.
That's a throttled "still typing" ping paired with a debounced "stopped typing" clear, running off the same keystroke stream:
- Throttle re-sends the "typing" signal at a capped rate (say, once every few seconds) for as long as keystrokes keep arriving — this is what keeps the receiver's indicator alive through a long, continuous burst without spamming an event per character.
- Debounce watches for a gap in keystrokes and, once the gap crosses the delay, sends "stopped typing" — this is what makes the dots actually disappear after a pause.
No need to hand-roll new logic for this — the same debounce and throttle from earlier do the job. Just wire up one of each, using the exact functions already defined above:
Piece 1 — keep the bubble alive. A throttled function that pings "typing" at most once every few seconds:
const pingTyping = throttle(() => socket.emit('typing'), 3000)
Piece 2 — clear the bubble. A debounced function that fires "stopped" once keystrokes go quiet:
const clearTyping = debounce(() => socket.emit('stopped'), 3000)
Now wire both to the same input. Every keystroke calls both — each one just does its own job independently:
messageInput.addEventListener('input', () => {
pingTyping() // refreshes the bubble, capped to once per 3s
clearTyping() // resets the "go quiet" timer on every keystroke
})
That's the whole thing. pingTyping is what keeps the bubble alive through a long, continuous burst without firing on every character. clearTyping is what notices the pause and clears it. They don't share any state or know about each other — they just both happen to listen to the same keystrokes, each doing the one job it's good at.
The short version
Debounce and throttle aren't two flavors of the same rate limiter — they answer different questions about a stream of events. Debounce answers "has this stopped?" Throttle answers "how often should I check in while this keeps going?" A feature that needs to reflect ongoing activity and also detect when it ends — like a typing indicator — usually needs both, each wired to the same events but doing a different job.
To be clear, this is a reasoned-through explanation of the behavior, not confirmed from WhatsApp's source — it's closed-source, so there's no way to check the actual implementation. But the pattern itself (throttle to sustain a live signal, debounce to detect its end) is standard enough that it shows up anywhere an app needs to say "this is still happening" and "this just stopped" off the same input.