2 min read·Run it here

Design Debounced Search

A timer-backed async wrapper that collapses rapid inputs into one search, resolves pending promises, caches exact terms, and guards stale results.

Pattern
Timer -> pending promises -> stale guard
Time
O(1) per input
Space
O(pending + cache)

Debounced search is where callback mechanics, timers, promises, and UI state all meet.

The recipe

Say before you type: "Clear the old timer. Save the latest term. Resolve the whole pending batch when the timer fires."

Each search(term) returns a promise. During a burst, collect the promise resolvers:

private pending: Array<{ resolve: (value: string[]) => void; reject: (error: unknown) => void }> = [];

When the timer fires, copy the batch, clear pending, run the searcher once with the latest term, then resolve every waiter with the result.

Normalise the searcher's return value — Promise.resolve(this.searcher(term)).then(…) or await — because the declared type is Promise<string[]> | string[] and a synchronous searcher has no .then. Calling .then() on the raw return value is the trap, and it is a nasty one: the TypeError is thrown inside your timer callback, where nothing can catch it, so it surfaces as a dead run rather than a failed test.

Cache

An exact-term cache is just:

private cache = new Map<string, string[]>();

If the term is cached, return Promise.resolve(cached) and do not wait for the debounce delay.

Stale guard

A slow old request can finish after a newer one. Keep a sequence number. When a search starts, capture the current sequence. Only update latest() if the captured sequence is still current when it resolves.