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));
  };
}
Is the intermediate result useful to the reader? Debounce, because an intermediate result is noise: a validation message about a half-typed value, a remote uniqueness check on a partial address, and an autosave write of a sentence being composed. Throttle, because an intermediate result is the point: a character counter that must stay roughly current, a password strength meter that responds while typing, and an upload progress indicator. The test is whether the reader benefits from being told about a value that is not finished — if not, wait for quiet. debounce — the intermediate value is noise validation messages a verdict on "ada@exam" helps nobody remote uniqueness checks one request per pause, not per key autosave writes a sentence, not a syllable throttle — the intermediate value is the point character and word counters must stay roughly current password strength meters responsive is the whole feature upload progress a value that only moves forward Neither is right for announcements: a throttled announcement interrupts repeatedly, and a debounced one is still one utterance. The same burst, three behaviours Given ten keystrokes over one second: an unwrapped handler runs ten times, which is correct and often wasteful. A four hundred millisecond debounce runs once, four hundred milliseconds after the last keystroke, and the reader sees nothing until they pause. A two hundred millisecond throttle runs about five times during the burst plus a trailing call, so the reader sees it keep up. The right answer depends only on whether those intermediate runs produce something the reader benefits from. Wrapping Runs in that second The reader sees none 10 everything, including waste debounce 400ms 1, after the pause nothing until they stop throttle 200ms ~5, plus a trailing call it keeping up Ten keystrokes in a second is an ordinary typing speed, not a stress test — this is the normal case, not the edge.

Step-by-Step Walkthrough

  1. Ask whether an intermediate value helps. If not, debounce.

  2. Give the debounce a flush. A debounce you cannot force is a debounce you cannot submit through.

  3. Give the throttle a trailing call. Without it the last value of a burst never lands, and a counter stops on the wrong number.

  4. 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.

  5. Announce separately. Visual updates can be frequent; announcements must not be. Debounce the announcement even when the visual is throttled.

  6. 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.

The two failure shapes, and what they look like A debounce used where a throttle belongs produces visible lag: the counter sits on the wrong number while the reader types, updates late, and looks broken because the reader can see both the text and the count. A throttle used where a debounce belongs produces repetition: a verdict about a half-typed value, five times a second, and — if it is announced — a screen reader that talks continuously while the reader types. debounce where throttle belongs the counter lags visibly it updates after the reader stops and looks broken, because both the text and the count are visible throttle where debounce belongs a verdict on a half-typed value five times a second a request per interval, forever and continuous speech, if announced Only one of these is merely ugly. The other sends requests and interrupts readers for the whole time they are typing.

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

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.