A search box that fires a network request on every keystroke sends five requests for “redis”, and four of them are stale before the response comes back. A scroll handler that recalculates layout runs hundreds of times a second and makes the page stutter. Same problem in both cases: the DOM event fires far more often than the handler needs to run. Debounce and throttle fix it in two different ways.
What debounce does and what throttle does
Debounce waits until the event has stopped firing for a set period, then runs once. Every new call resets the clock. If the event never stops, the function never runs. Search input is the obvious fit: wait until the user pauses typing, then send one request for what they last typed.
Throttle caps how often the call can run. During a burst it still runs, at most once per interval. Use it for scroll, where the page should keep updating while the user scrolls, only not on every single event.
| Debounce | Throttle | |
|---|---|---|
| Runs during a burst | Not until the burst ends | Yes, at most once per interval |
| What you get | The last call in the burst | A steady sample of calls throughout |
| Typical use | Search input, form validation, save-on-type | scroll, resize, mousemove, drag |
| If the burst never stops | Never runs | Keeps running on schedule |
Both are wrappers. You pass in your callback and a delay, get back a function with the same signature, and attach that to the event instead of the original.
How to write a debounce function
| |
Each call cancels the pending setTimeout and schedules a new one, so only the last call in a burst lives long enough to fire. The timing follows the setTimeout rules from how the JavaScript event loop schedules callbacks: the callback never runs before delay, and it can run later if the call stack is busy when the timer expires.
Wired to a search input:
| |
Type “redis” at a normal pace and the console shows one line, searching for: redis, about 300ms after the last keystroke. Not five lines for five letters.
How to write a throttle function
| |
This is the leading-edge version. The first call in a window runs immediately; everything after it is dropped until interval milliseconds have passed.
| |
Scroll continuously for two seconds and you get roughly ten evenly spaced log lines, instead of the hundreds of scroll events the browser actually fired.
Lodash debounce and throttle options: leading, trailing, maxWait
Each hand-rolled function above hard-codes one choice. The debounce fires only on the trailing edge, after the quiet period. The throttle fires only on the leading edge, the instant a window opens, so the final call of a burst is lost if it lands mid-window. That matters for scroll: the last position the user stopped at may never be handled.
Lodash’s _.debounce(fn, wait, options) and _.throttle(fn, wait, options) make both edges configurable through leading and trailing booleans, and the defaults differ. Debounce defaults to leading: false, trailing: true: wait, then fire. Throttle defaults to both true: fire immediately, then once more at the end of a burst that lasted longer than one interval. Internally, lodash’s throttle is its debounce with maxWait set to the interval, so the two are closer than the names suggest.
maxWait is a debounce option that caps how long the first call can be postponed. Without it, someone typing steadily with gaps shorter than the delay never triggers the handler, even after ten seconds of activity. With maxWait: 2000, it fires at least every two seconds.
Leading-only debounce covers the double-submit case. _.debounce(sendMail, 300, { leading: true, trailing: false }) runs on the first click and ignores further clicks until 300ms pass with no clicks at all. The first click counts; the rest are dropped, not delayed.
Which events need debounce and which need throttle
| Event | Pick | Why |
|---|---|---|
| Search-as-you-type | Debounce | Only the final query matters; earlier keystrokes produce requests you’d throw away |
Window resize | Debounce or throttle | Debounce if you only need the final size (recompute layout once); throttle if the UI should visibly track the resize |
scroll | Throttle | The page should keep responding throughout, not only after the user stops |
mousemove (drag, tooltip-follow) | Throttle, via requestAnimationFrame | Updates faster than the screen’s refresh rate are wasted work |
| Button click (prevent double-submit) | Debounce (leading-only) or disable-on-click | The first click should count; the rest should be ignored, not delayed |
| Autosave on keystroke | Debounce with maxWait | Save after a pause, but guarantee a save during long continuous typing |
When the handler drives something visual (a drag, a tooltip following the cursor, a scroll-linked animation), replace Date.now with requestAnimationFrame:
| |
The call rate now matches the browser’s paint rate instead of a millisecond figure you picked. The update runs once per frame, right before the frame is drawn, so it can’t run more often than anyone can see and doesn’t drift out of step with rendering when the main thread is under load.
Common debounce and throttle mistakes
Creating the debounced function inside the handler. In React, onChange={(e) => debounce(handleSearch, 300)(e.target.value)} calls debounce() on every keystroke, and each call returns a new wrapper with its own empty timer. There is never a previous timer to cancel, so every keystroke still fires a request, only 300ms late. Create the debounced function once (useMemo, useRef, or module scope outside a component) and call that same instance.
Not cancelling on teardown. A debounced autosave that fires after the user has navigated away writes to a component that no longer exists, or saves from a stale closure. Lodash’s debounced and throttled functions already have a .cancel() method; for the hand-rolled debounce, add one that calls clearTimeout(timer). Call it when the component unmounts or the page unloads.
Testing with real timers. Waiting out a debounce with await new Promise(r => setTimeout(r, 300)) works, but add that to every timing test and the suite gets slow enough that people stop running it locally. Use fake timers instead: vi.useFakeTimers() in Vitest or jest.useFakeTimers() in Jest, then move the clock with vi.advanceTimersByTime(300). A fake timer is a test double for the clock, not for your code, which is the distinction drawn in unit vs integration vs end-to-end tests.
Debouncing something that needs to feel live. A progress bar or drag handle that only moves after the user stops reads as broken. If the requirement is “update smoothly while this keeps happening”, you want throttle.
Should you use debounce or throttle by default?
Use debounce when only the final state matters: search inputs, autosave, validating a field once the user stops typing. Use throttle when the user expects to see progress while something continues, like scrolling, resizing or dragging, and switch to the requestAnimationFrame version when the output is a visual update.
For production code, import debounce and throttle from a maintained library (lodash, or a hook like use-debounce in React) instead of shipping the versions above. They exist to show the mechanism. The library versions already handle cancellation, maxWait, trailing calls and this binding, which is where a hand-rolled copy usually breaks.