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').
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>;
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
fn — earlier calls never invoke fn; their results are discarded.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.wait apart do not supersede each other.wait-based window is the whole task here.