The exact problem: a character counter debounced at 400 ms lags visibly behind the typing it counts, and a validation message throttled at 400 ms fires five times during one word — each choice would have been right for the other.
Context and Prerequisites
The debounce implementation is in debouncing validation triggers in React. This page is the choice between the two, which comes down to one question: is the intermediate result useful to the reader?
The Rule
Debounce answers “tell me when they stop”. Throttle answers “tell me regularly while they go”.
/** Debounce: resets on every call, fires once after quiet. */
export function debounce<A extends unknown[]>(fn: (...a: A) => void, ms: number) {
let t: ReturnType<typeof setTimeout> | null = null;
const wrapped = (...a: A) => {
if (t) clearTimeout(t);
t = setTimeout(() => { t = null; fn(...a); }, ms);
};
// A debounce without a flush cannot be submitted through: the pending call
// would land after the submit decision was made.
wrapped.flush = (...a: A) => { if (t) { clearTimeout(t); t = null; fn(...a); } };
wrapped.cancel = () => { if (t) { clearTimeout(t); t = null; } };
return wrapped;
}
/** Throttle: fires immediately, then at most once per interval. */
export function throttle<A extends unknown[]>(fn: (...a: A) => void, ms: number) {
let last = 0;
let pending: A | null = null;
let t: ReturnType<typeof setTimeout> | null = null;
return (...a: A) => {
const now = performance.now();
if (now - last >= ms) { last = now; fn(...a); return; }
// Trailing call: without it the LAST value in a burst is never applied,
// which for a counter means it stops on the wrong number.
pending = a;
t ??= setTimeout(() => {
t = null; last = performance.now();
if (pending) { fn(...pending); pending = null; }
}, ms - (now - last));
};
}
Step-by-Step Walkthrough
-
Ask whether an intermediate value helps. If not, debounce.
-
Give the debounce a flush. A debounce you cannot force is a debounce you cannot submit through.
-
Give the throttle a trailing call. Without it the last value of a burst never lands, and a counter stops on the wrong number.
-
Pick the interval from the purpose. Debounce around the pause between words — 300 to 500 ms. Throttle around the frame budget — 100 to 200 ms is plenty for a counter.
-
Announce separately. Visual updates can be frequent; announcements must not be. Debounce the announcement even when the visual is throttled.
-
Cancel on unmount. Both hold timers, and both can fire into a destroyed component.
Failure Modes and Edge Cases
1. A throttled remote request
Every interval, forever, while the reader types. Remote work is always debounced.
2. A debounced counter
The number visibly lags the text, which looks broken because the reader can see both.
3. Debouncing without flushing on submit
The submit decision is made against a validity that has not been computed yet. Flush, await, then decide.
4. Throttling a value that can go backwards
Throttling drops intermediate values, which is fine for a monotonic progress number and wrong for anything where the dropped value mattered.
5. One shared instance across fields
A single debounced function reused by every field means typing in one cancels the pending call from another. Create one per field.
Measuring before choosing an interval
Both intervals are usually picked by feel and left alone, which is fine until the work behind them grows. The measurement that settles it takes a minute: record a profile while typing a realistic sentence at a realistic speed, and look at two numbers. The first is how long one invocation of the wrapped function takes — if it is under a millisecond, the wrapper is buying you very little and a shorter interval costs nothing. The second is how many invocations the burst produced without the wrapper, because that is the multiplier on everything above.
A schema parse over a form of twenty fields is typically tens of microseconds, so debouncing it is about when the reader is told rather than about cost. A schema parse over a form of three hundred fields, or one with several refinements, can be a millisecond or more, at which point ten invocations per second is a tenth of the main thread spent on work nobody sees. The interval that is right for the first case is wrong for the second, and only the profile distinguishes them.
The same applies in reverse to throttled work. A character count is arithmetic on a string and can run every frame without anyone noticing. A password strength estimate that runs a dictionary check is not arithmetic, and throttling it at two hundred milliseconds still means five dictionary checks a second while the reader types their password. Where the throttled work is expensive, the honest answer is usually to throttle the display and debounce the computation — update a cheap approximation continuously and the real answer once the reader pauses.
Verification Checklist
Common Pitfalls
- A throttled remote request. It fires every interval for as long as the reader types, which is the one combination that is always wrong.
- A debounced counter. The number visibly lags the text the reader can see, which reads as broken rather than as considered.
- A debounce with no flush. The submit decision is made against a validity that has not been computed, so a valid form can be refused and an invalid one accepted.
- A throttle with no trailing call. The last value of a burst never lands, so the counter stops on the wrong number and stays there.
- One shared instance. A single debounced function reused across fields means typing in one cancels the pending call from another.
Related
- Synchronous Validation Patterns — where the debounced work runs
- Debouncing Validation Triggers in React — the implementation and its lifecycle
- Choosing Between Alert and Status Regions — why announcements are debounced regardless
← Synchronous Validation Patterns
Frequently Asked Questions
Can one utility do both?
There are libraries whose throttle is implemented as a debounce with a maxWait, and the result is a single function that behaves as either depending on its options. That is fine to use, but it does not remove the decision — you still have to know whether an intermediate value is useful, which is the part that matters. Writing the two separately makes the call site say which behaviour was intended.
Does a debounce always need a flush?
Any debounce whose result a decision depends on, yes. Validation is the obvious case: submitting while a debounced validation is pending means deciding against a result that has not been computed. Flush, await it, then decide. A debounce whose result is purely cosmetic — a decorative animation trigger — can be left to expire or be cancelled.
What interval should I use?
For a debounce, the length of a natural pause between words: 300 to 500 milliseconds. Shorter and it fires mid-word; much longer and the reader has moved on before anything happens. For a throttle, the frame budget rather than the pause: 100 to 200 milliseconds keeps a counter looking live without doing work nobody sees. Measure the actual cost before shortening either.