Async Debounce (Latest Wins)Loading saved progress…

Async Debounce (Latest Wins)

An async debounce collapses a burst of rapid calls into a single deferred call, resolves the promise of the last call with that call's result, and rejects every earlier call it replaced so no abandoned promise is left hanging. Regular debounce is fire-and-forget — it drops the calls it skips and returns nothing. When each call needs an answer (a save that returns the saved record, a lookup that returns rows), you need every call to hand back a Promise and a rule for the ones that get replaced.

Implement asyncDebounceLatest(fn, wait). It returns a debounced function; each invocation returns a Promise. Within a wait-millisecond window only the last invocation survives: when the window elapses, fn runs once with the latest arguments and its result settles the newest call's promise. Every superseded call rejects with new Error('superseded').

Signature

function asyncDebounceLatest(
  fn: (...args: any[]) => any, // the work to run once the burst settles
  wait: number,               // quiet period in ms before fn fires
): (...args: any[]) => Promise<any>;

Examples

const save = asyncDebounceLatest((draft) => draft.toUpperCase(), 200);

const p1 = save('v1');
const p2 = save('v2'); // within 200ms — supersedes v1
const p3 = save('v3'); // within 200ms — supersedes v2

// 200ms after the LAST call:
//   fn('v3') runs once
//   p3 resolves with 'V3'
//   p1 and p2 reject with Error('superseded')
const run = asyncDebounceLatest((n) => n * 2, 50);

const a = await run(10); // waits 50ms, fn(10) yields 20
const b = await run(21); // fresh window, fn(21) yields 42
// a === 20 and b === 42 — both windows fired normally

Notes

  • Every call returns a Promise — even the ones you expect to be replaced. The caller decides whether to await it.
  • Only the last call in a window runs fn — earlier calls never invoke fn; their results are discarded.
  • Superseded calls reject — with new Error('superseded'), message exactly superseded, so callers can tell a replaced call from a real failure.
  • fn may be sync or async — if it returns a Promise, the surviving call adopts it, resolving or rejecting with fn's outcome.
  • A new window opens once the timer fires — calls more than wait apart do not supersede each other.
  • Ignore cancel and flush — a plain wait-based window is the whole task here.

FAQ

How is this different from a normal debounce?
A normal debounce is fire-and-forget: it drops the calls it skips and returns nothing. This version hands back a Promise for every call and settles all of them — the last call resolves with fn's result, and every superseded call rejects with an Error whose message is "superseded".
Why reject superseded calls instead of leaving their promises pending?
A promise that never settles stalls any await on it and leaks memory. Rejecting with a known error lets the caller tell "my call was replaced" apart from a real failure and clean up, instead of hanging forever.
What happens to calls spaced further apart than the wait window?
Each starts its own window and runs normally — fn fires once per window and that window's promise resolves with its result. Nothing is superseded across separate windows.
Loading editor…