Frontend Handbook
12Built

Debounced Search + API Cache

Search-as-you-type that debounces input, caches results, and drops stale responses.

How it works

Search-as-you-type that stays fast and correct on a real network: wait for a pause before searching, reuse recent results, cancel requests nobody needs any more, and never let a slow, older response overwrite a newer one. Type (or let the robot type), watch the request timeline, then switch each technique off to see the bug it prevents.

  • Debounce: search once input has been quiet for ~300 ms, not on every keystroke; Enter skips the wait.
  • Normalize, then cache: “Café Mug ” and “cafe mug” are one key; recent keys are answered from memory, stale ones refreshed in the background.
  • Responses arrive out of order: abort superseded requests to save work, and guard with a run id so only the latest search may change the screen.
  • Show what you're showing: label results with their query, keep them visible but dimmed while the next one loads.
Read more

Where you'd use it

  • Search boxes, autocomplete, address and user pickers, command palettes.
  • Filters that refetch as you change them (price sliders, faceted search).
  • Any input-driven request where the user changes their mind faster than the server answers.

How we built it

A framework-free SearchEngine owns the whole pattern; React subscribes with useSyncExternalStore and only forwards events, so the typing robot and real keystrokes take the same path. setInput normalizes the text with the same function the server uses (trim, collapse spaces, lowercase, strip accents, 100 characters). If the key didn't change, nothing happens; if it's shorter than the minimum, pending work is cancelled and the list cleared; otherwise a trailing timer is (re)started. A keystroke that resets the timer logs the cancelled wait as debounced. One shortcut: when the cache already holds a fresh answer for the key, there is nothing to save by waiting, so it is shown on that keystroke.

When the timer fires (or on Enter), run increments a run id. If a request for the same key is already in flight it is adopted instead of sent twice. Otherwise the cache is asked: fresh → shown instantly, no request; stale → shown at once with “refreshing…” while a background request revalidates it (stale-while-revalidate); miss → a request with its own AbortController. Starting a run aborts the older in-flight requests, and the mock API stops its delay when request.signal aborts. When a response settles it is written to the cache (even if it is no longer wanted: it is correct data for its key), then applied only if its run id is still the current one; otherwise it is logged as stale, dropped.

The cache is a module-level Map: re-inserting on every read keeps it in least-recently-used order, so eviction drops the first key. Entries are fresh for 30 s, usable but stale up to 5 minutes, at most 20; errors are never cached. The listbox follows the WAI-ARIA combobox pattern: focus stays in the input, aria-activedescendant names the highlighted option, and a polite status line announces results only when a search settles. The latest query is mirrored into ?q= with history.replaceState, so a link restores it without adding a history entry per keystroke.

Practice 4's useQuery is a general server-state cache (observers, dedupe, retries, focus refetch, garbage collection) keyed by arbitrary keys. This practice builds the narrower thing a search box needs on top of the same ideas: per-keystroke scheduling, key normalization, an LRU bound for many short-lived keys, and the "latest search wins" rule, which a general cache doesn't know about because it doesn't know which key the screen currently wants.

Best practices

  • Do: Debounce input-driven requests (trailing edge, ~250–400 ms) and let Enter search immediately.

    Avoid: Fetching on every keystroke, or throttling: throttle fires mid-word at a fixed rate; debounce waits for the pause.

  • Do: Decide which response may update the screen by comparing a run id (or the current key) when it arrives.

    Avoid: setResults(await fetch(...)) in an effect: whichever request finishes last wins, even if it's the oldest.

  • Do: Abort superseded requests with AbortController, and treat aborts as silent, not as errors.

    Avoid: Relying on abort alone for correctness: a response can already be resolved, and some sources can't be aborted.

  • Do: Normalize queries before using them as cache keys, and bound the cache (TTL + size).

    Avoid: Keying by raw text (“Lamp”, “lamp ”) or caching forever: duplicate entries and unbounded memory.

  • Do: Keep previous results visible (dimmed, labelled with their query) while the next ones load.

    Avoid: Clearing the list on every keystroke: the page flickers and jumps.

  • Do: Announce settled results in a polite live region; keep focus in the input with aria-activedescendant.

    Avoid: Moving DOM focus into the list, or announcing every intermediate state.

Try it with the demo controls

Typing robot
Types the phrase at a steady rate. Fast (50 ms per key) with debounce on: 1 request and the rest “Debounced”. Slow (400 ms per key): every key outlasts the delay, so you see requests being aborted.
All off (naive)
With “Shorter queries slower”, let the robot type: 7 requests whose bars finish out of order, and often a warning that the list shows results for an older, shorter query (“de”, “des”). Run it a few times: the order is random.
Stale-response guard
Turn only this off and abort off too: the bug comes back. Abort alone usually hides it, because aborted requests never resolve.
Cache
Search “lamp”, then “lamps”, then delete the s: “Cache hit”, no request. Set fresh time to 5 s and wait: the next hit shows “refreshing…” and a Refresh row.
Cache size
At 5 entries, search six different queries: the least recently used one disappears from the inspector.
Debounce delay / Minimum length
0 ms behaves like debounce off. Minimum 1 lets single letters search (expensive, mostly noise).
Server latency / Random extra
More randomness means more overlapping bars and more aborted or dropped responses.
Failure rate
The latest failure shows an error with Retry (straight to the network); older failures are dropped silently.
Keyboard
↓/↑ move through results, Enter picks, Escape hides the list, a second Escape clears the box.
URL
Search, then reload: the query comes back from ?q= and runs immediately; Back leaves the page instead of stepping through queries.

Trade-offs and limits

  • Debounce adds its delay to every search; a smaller delay or a leading-edge first search feels faster but sends more requests.
  • The cache lives in memory for this tab: it survives navigating within the app, not a reload, and results can be up to the fresh time out of date.
  • Normalization keeps word order (“desk lamp” and “lamp desk” are two keys with identical results); sorting words would merge them.
  • The run-id guard drops a slower response even when it would have been fine to show; it trades a few wasted responses for never being wrong.
  • Matching is “every word starts a word”, ranked by starts-with then name; real search needs typo tolerance and relevance ranking on the server.

Demo controls

Techniques

Searches 300 ms after you stop typing; Enter searches now. Arrow keys move through results, Enter picks, Escape closes.

Type at least 2 characters to search 5,616 products, or try one of these:

Picked product

Pick a result with Enter or a click.

Request timeline

Keystrokes
0
Debounced
0
Requests sent
0
Cache hits
0
Aborted
0
Stale dropped
0
Errors
0

Type in the box: every debounced keystroke, cache hit and request appears here, newest first.

Cache (0 of 20, most recently used first)

Empty. Successful responses are stored here by their normalized query.