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.You'll build a debounce that hands back a Promise for every call, then keeps its word: the last call in a burst resolves, and the calls it replaced reject instead of hanging.
Picture a document editor that autosaves. The user types, pauses, types again. You don't POST on every keystroke, so you debounce the save by a few hundred milliseconds. But autosave returns something the caller awaits — the saved revision. Regular debounce throws away the calls it skips and returns nothing, so those awaits would wait forever. You need every call to get a Promise, and you need to settle the ones you skip.
Each call does the usual debounce move — cancel the pending timer, start a new one — but it also carries a Promise you already handed to the caller. So each new call has one extra job: settle the promise of the call it just replaced. That replaced call did not run and never will, so you reject it with a marker error. Only the final call, the one that survives the quiet period, gets to run fn and resolve.
The obvious move is to take plain debounce and wrap the timer in a Promise:
function asyncDebounceLatest(fn, wait) {
let timeoutId = null;
return function (...args) {
clearTimeout(timeoutId); // cancel the previous fire
return new Promise((resolve) => {
timeoutId = setTimeout(() => {
resolve(fn(...args)); // fire once, resolve THIS call
}, wait);
});
};
}
The surviving call works — its timer fires and its promise resolves. But look at a call that gets replaced. clearTimeout cancels its timer, and that timer's callback held the only reference to its resolve. The callback never runs, so that promise never settles. Any code that did await save('v1') waits forever. The spec says a replaced call must reject with superseded — this version silently strands it.
You need to remember the pending call's reject so a newer call can settle it. One extra closure variable — pending — holds the resolve and reject pair of the latest still-live call.
function asyncDebounceLatest(fn, wait) {
let timeoutId = null; // timer for the current window
let pending = null; // { resolve, reject } of the latest un-settled call
return function debounced(...args) {
const context = this;
// A newer call arrived: cancel the old window...
if (timeoutId !== null) clearTimeout(timeoutId);
// ...and settle the call it replaced, so its promise never hangs.
if (pending !== null) pending.reject(new Error('superseded'));
return new Promise((resolve, reject) => {
pending = { resolve, reject }; // this call is now the one to beat
timeoutId = setTimeout(() => {
// The quiet period held: this call wins the window.
timeoutId = null;
pending = null; // nothing left to supersede
// fn may return a value or a Promise; adopt whichever it is.
Promise.resolve(fn.apply(context, args)).then(resolve, reject);
}, wait);
});
};
}
module.exports = { asyncDebounceLatest };
The two if lines are the whole difference. Cancelling the timer is ordinary debounce; rejecting pending is the async half — it turns an abandoned promise into a settled one. When the timer finally fires, that call clears pending first (so a later call starts a fresh window instead of rejecting the call that already ran) and then runs fn. Wrapping the result in Promise.resolve lets fn be sync or async without branching.
Say wait is 200 and three saves land at t=0, t=50, t=100. Watch each call's promise: two reject, one resolves.
save('v1'). timeoutId is null and pending is null, so nothing to cancel or reject. Store pending = { resolve1, reject1 }, schedule the window for t=200, hand back p1.save('v2'). timeoutId is set, so cancel the t=200 timer. pending is p1's pair, so reject1(new Error('superseded')) — p1 rejects right now. Store pending = { resolve2, reject2 }, schedule for t=250, hand back p2.save('v3'). Cancel the t=250 timer. reject2(new Error('superseded')) — p2 rejects. Store pending = { resolve3, reject3 }, schedule for t=300, hand back p3.timeoutId and pending back to null, run fn('v3'), and resolve3 with its result. p3 resolves; p1 and p2 already rejected with superseded.clearTimeout and forget to reject, replaced calls hang forever and any await on them stalls. Fix: keep the pending reject in the closure and call it the moment a newer call arrives.pending.reject('superseded') rejects with a string, so err.message is undefined and err instanceof Error is false. Fix: reject with new Error('superseded').pending when the timer fires — leave the old pair in pending and the next fresh-window call will wrongly reject the call that already ran. Fix: set pending = null inside the timer callback before running fn.fn(...args) raw instead of Promise.resolve(fn(...)) — if fn returns a rejected Promise, you want the surviving call to reject with that reason. Promise.resolve(...).then(resolve, reject) forwards both outcomes.fn — supersession here stops only calls still inside the wait window; once fn fires, that call is committed. Thread an AbortController into fn to also abort a request that is already running.fn on the first call of a burst instead of the last, and resolve that call, when you want immediate feedback.flush() and cancel() — expose methods to fire the pending call immediately, or drop it by rejecting pending, when a component unmounts.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
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.You'll build a debounce that hands back a Promise for every call, then keeps its word: the last call in a burst resolves, and the calls it replaced reject instead of hanging.
Picture a document editor that autosaves. The user types, pauses, types again. You don't POST on every keystroke, so you debounce the save by a few hundred milliseconds. But autosave returns something the caller awaits — the saved revision. Regular debounce throws away the calls it skips and returns nothing, so those awaits would wait forever. You need every call to get a Promise, and you need to settle the ones you skip.
Each call does the usual debounce move — cancel the pending timer, start a new one — but it also carries a Promise you already handed to the caller. So each new call has one extra job: settle the promise of the call it just replaced. That replaced call did not run and never will, so you reject it with a marker error. Only the final call, the one that survives the quiet period, gets to run fn and resolve.
The obvious move is to take plain debounce and wrap the timer in a Promise:
function asyncDebounceLatest(fn, wait) {
let timeoutId = null;
return function (...args) {
clearTimeout(timeoutId); // cancel the previous fire
return new Promise((resolve) => {
timeoutId = setTimeout(() => {
resolve(fn(...args)); // fire once, resolve THIS call
}, wait);
});
};
}
The surviving call works — its timer fires and its promise resolves. But look at a call that gets replaced. clearTimeout cancels its timer, and that timer's callback held the only reference to its resolve. The callback never runs, so that promise never settles. Any code that did await save('v1') waits forever. The spec says a replaced call must reject with superseded — this version silently strands it.
You need to remember the pending call's reject so a newer call can settle it. One extra closure variable — pending — holds the resolve and reject pair of the latest still-live call.
function asyncDebounceLatest(fn, wait) {
let timeoutId = null; // timer for the current window
let pending = null; // { resolve, reject } of the latest un-settled call
return function debounced(...args) {
const context = this;
// A newer call arrived: cancel the old window...
if (timeoutId !== null) clearTimeout(timeoutId);
// ...and settle the call it replaced, so its promise never hangs.
if (pending !== null) pending.reject(new Error('superseded'));
return new Promise((resolve, reject) => {
pending = { resolve, reject }; // this call is now the one to beat
timeoutId = setTimeout(() => {
// The quiet period held: this call wins the window.
timeoutId = null;
pending = null; // nothing left to supersede
// fn may return a value or a Promise; adopt whichever it is.
Promise.resolve(fn.apply(context, args)).then(resolve, reject);
}, wait);
});
};
}
module.exports = { asyncDebounceLatest };
The two if lines are the whole difference. Cancelling the timer is ordinary debounce; rejecting pending is the async half — it turns an abandoned promise into a settled one. When the timer finally fires, that call clears pending first (so a later call starts a fresh window instead of rejecting the call that already ran) and then runs fn. Wrapping the result in Promise.resolve lets fn be sync or async without branching.
Say wait is 200 and three saves land at t=0, t=50, t=100. Watch each call's promise: two reject, one resolves.
save('v1'). timeoutId is null and pending is null, so nothing to cancel or reject. Store pending = { resolve1, reject1 }, schedule the window for t=200, hand back p1.save('v2'). timeoutId is set, so cancel the t=200 timer. pending is p1's pair, so reject1(new Error('superseded')) — p1 rejects right now. Store pending = { resolve2, reject2 }, schedule for t=250, hand back p2.save('v3'). Cancel the t=250 timer. reject2(new Error('superseded')) — p2 rejects. Store pending = { resolve3, reject3 }, schedule for t=300, hand back p3.timeoutId and pending back to null, run fn('v3'), and resolve3 with its result. p3 resolves; p1 and p2 already rejected with superseded.clearTimeout and forget to reject, replaced calls hang forever and any await on them stalls. Fix: keep the pending reject in the closure and call it the moment a newer call arrives.pending.reject('superseded') rejects with a string, so err.message is undefined and err instanceof Error is false. Fix: reject with new Error('superseded').pending when the timer fires — leave the old pair in pending and the next fresh-window call will wrongly reject the call that already ran. Fix: set pending = null inside the timer callback before running fn.fn(...args) raw instead of Promise.resolve(fn(...)) — if fn returns a rejected Promise, you want the surviving call to reject with that reason. Promise.resolve(...).then(resolve, reject) forwards both outcomes.fn — supersession here stops only calls still inside the wait window; once fn fires, that call is committed. Thread an AbortController into fn to also abort a request that is already running.fn on the first call of a burst instead of the last, and resolve that call, when you want immediate feedback.flush() and cancel() — expose methods to fire the pending call immediately, or drop it by rejecting pending, when a component unmounts.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.