Write a TypeScript function `debounceAsync` that debounces an async function, cancels stale calls with AbortController and always resolves the latest call. Include a short usage example.
debounceAsync: Debounce with Abort and Latest-Result Resolution
Behavior
- Debounces calls. Only the last call within
waitms actually runs. - Aborts stale work. If a new call arrives while a previous execution is in flight, that execution's
AbortSignalfires. - Always resolves to the latest call. Every pending caller, including superseded ones, receives the result of the most recent call. No promise is left hanging.
Implementation
type AsyncFn<A extends unknown[], R> = (signal: AbortSignal, ...args: A) => Promise<R>;
interface Waiter<R> {
resolve: (value: R) => void;
reject: (reason: unknown) => void;
}
export function debounceAsync<A extends unknown[], R>(fn: AsyncFn<A, R>, wait: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
let controller: AbortController | undefined;
let waiters: Waiter<R>[] = [];
let latestId = 0;
const settle = (cb: (w: Waiter<R>) => void) => {
const batch = waiters;
waiters = [];
batch.forEach(cb);
};
const debounced = (...args: A): Promise<R> => {
const id = ++latestId;
clearTimeout(timer);
controller?.abort(); // cancel any stale in-flight execution
return new Promise<R>((resolve, reject) => {
waiters.push({ resolve, reject });
timer = setTimeout(async () => {
const ctrl = new AbortController();
controller = ctrl;
try {
const result = await fn(ctrl.signal, ...args);
if (id === latestId) settle((w) => w.resolve(result));
} catch (err) {
// Ignore errors from superseded runs; the newer call will settle waiters.
if (id === latestId) settle((w) => w.reject(err));
} finally {
if (controller === ctrl) controller = undefined;
}
}, wait);
});
};
/** Cancel pending/in-flight work and reject all waiters. */
debounced.cancel = () => {
latestId++;
clearTimeout(timer);
controller?.abort();
settle((w) => w.reject(new DOMException("Cancelled", "AbortError")));
};
return debounced;
}Usage Example
const search = debounceAsync(async (signal, query: string) => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as string[];
}, 300);
// Rapid typing: only "react" is fetched, and all three promises resolve with its results.
search("r").then((r) => console.log("r →", r));
search("re").then((r) => console.log("re →", r));
search("react").then((r) => console.log("react →", r));
// On component unmount:
// search.cancel();Notes
- The signal is the first parameter. Your function must pass
signalto cancellable APIs such asfetch. Otherwise, aborting only discards the stale result and does not stop the work. - Stale errors are swallowed. An
AbortErrorfrom a superseded run never reaches callers. The latest call settles everyone. - Rejecting instead of sharing results. If you prefer that superseded callers reject rather than receive the latest result, reject the older waiters with an
AbortErrorinsidedebouncedbefore pushing the new waiter.

