Representative interview topic

Frontend interview: How do you prevent stale search responses from overwriting the latest results?

FrontendHard
Offer.cc Editorial TeamPublished Updated

Question

A user types hello quickly, triggering requests for h, he, hel, hell, and hello that may complete in any order. How would you prevent an old response from replacing the latest result while handling cancellation, caching, loading, errors, and accessible feedback?

Prompt and context

A search box requests suggestions whenever its input changes. A user types hello quickly, so the browser sends requests for h, he, hel, hell, and hello, but the network does not guarantee that responses arrive in that order. The old request may finish last and replace the hello results with hell; the user may also clear the input, change networks, or leave the page.

Design the request lifecycle, result-commit rule, cancellation strategy, cache and freshness policy, loading and error states, accessible feedback, and verification plan. Explain which query the visible result represents instead of only saying “add debounce.”

This fits senior frontend, React, and UI-infrastructure interviews. React’s official documentation uses fast typing to explain a data-fetching race condition and recommends ignoring stale responses during Effect cleanup. MDN documents that AbortController.abort() can terminate a fetch, response-body consumption, or stream. web.dev’s stale-while-revalidate guidance adds the cache trade-off of serving an acceptable old value while refreshing it. These sources support the technical representativeness of the topic, but do not establish a fixed company question or interview frequency. The category is frontend because the core skill is browser-side asynchronous state, render consistency, and interaction feedback.

What interviewers evaluate

First, can the candidate distinguish a request finishing from a request still being eligible to commit? A Promise completing first does not mean it represents the current query. Every request needs stable identity, and that identity or query key must still match at commit time.

Second, do they understand that cancellation is resource management? AbortController can reduce wasted work, but cancellation may happen after the server has processed the request. Abort is not a business rollback and cannot replace stale-result protection.

Third, can they model the complete state machine: empty input, first load, refreshing with existing results, success, empty results, retryable error, stale cache, and unmount all need explicit rules. Keeping old results while loading a new query or showing a skeleton depends on the query semantics and the risk of misleading the user.

Finally, can they verify behavior by controlling response order and covering fast typing, clearing, retries, cache hits, unmounts, and accessible announcements rather than testing only success responses that arrive in order?

Clarifying questions to ask first

  • Must every keystroke trigger a request? If the product waits for a pause, debounce is useful, but it only reduces requests and does not solve races among requests already sent.
  • May old results remain visible? Suggestions can often remain during refresh if they are labeled as belonging to the previous query; financial quotes or permission results may need to clear to avoid a wrong selection.
  • What invalidates the cache key? The query string, filters, language, account scope, and data version may all matter. Caching by page URL alone can mix different permissions or filters.
  • Does the server support cancellation or deduplication? The client still needs correctness guards; server cancellation improves resource use but cannot prove that an old request had no effect.
  • Does the input need live screen-reader feedback? Result counts or loading states can use an existing status region politely, but every character should not interrupt the user.

30-second answer framework

“I represent each query with a request key and only let a response update the results if that key still equals the current query. On cleanup I call AbortController.abort() to release network and body-reading work, but I do not treat cancellation as rollback; even if abort fails, the identity check drops the stale response. Debouncing after the user pauses reduces noise but does not replace race protection. The state distinguishes empty, loading, refreshing old results, success, empty results, and retryable errors. The cache uses the complete query key and an explicit freshness window. Tests force an old request to return after a new one and cover clearing, unmounting, retrying, caching, and screen-reader feedback.”

Deep-dive answer

1. Define a result-commit invariant

Keep currentKey, status, visibleData, and an optional cache. currentKey includes the normalized query and every filter, language, and account scope that changes the result. Each network request gets a unique requestId; its closure retains its key and controller.

Before committing a result, check that the request has not been invalidated and that its key still equals the current key. Only then may it write visible data, an error, or a success state. This invariant is safer than assuming “the last request usually finishes last,” because the network provides no such ordering guarantee.

Treat the UI as a pure projection of the current query key, the newest usable cache entry, active requests, and errors. Avoid several Effects synchronizing independent loading, data, and error values; clearing or rapidly replacing a query can otherwise write an old error into the new state.

2. Separate debounce, throttle, and race protection

Debounce combines rapid input into one request and is useful for suggestions. Throttle limits requests in a time window and is useful for scrolling or monitoring. Both control when a request starts; neither prevents an already sent request from arriving late.

Even with a 250-millisecond debounce, the user can type again while a request is in flight. Keep the identity check. React’s official example sets an ignore flag in Effect cleanup so an old response no longer calls setResults; an incrementing sequence, query-key comparison, or explicit state machine implements the same rule.

3. Treat cancellation as optimization, not proof of correctness

Give each active request its own AbortController. When the query changes, input is cleared, the component unmounts, or a replacement request starts, call abort(). Handle AbortError as expected cancellation: do not show it as a network failure and do not let it overwrite the new query’s state.

The signal may arrive too late to stop server processing or may only stop browser-side reading. “Cancel the old request” and “the old request produced no server-side effect” are different claims. Search GETs normally have no write side effect, but still need the requestId guard; an irreversible operation needs an explicit idempotency contract.

4. Choose among old results, skeletons, and cache

With no data on the first query, show a loading placeholder and accessible status text. During a refresh, keep old results if they remain safe, label the query they belong to, and show a lightweight refresh indicator. If old data could cause a harmful selection, clear it or make it non-actionable.

The cache key must include the complete query input. A hit may render immediately and then revalidate in the background, but record its time, source, and errors so stale or permission-changed data is not presented as current fact. Stale-while-revalidate serves an acceptable old value first and fetches a new one asynchronously; the product must define the acceptable freshness window.

5. Handle errors, empty results, and retries

Empty results are a successful state, not a network error. Bind an error to its current key; an error from an old query is discarded. A retryable error keeps the query and backoff information. A retry creates a new requestId instead of reusing a Promise already marked stale.

If the server returns 401, a changed permission scope, or invalid query conditions, clear or revalidate the cache instead of retrying forever. On network recovery, request only the current key so a recovery event cannot repopulate a query the user already cleared.

6. Preserve keyboard and assistive-technology feedback

Do not move input focus when results refresh. Use stable list identities so unmounting old results does not cause a screen reader to reread the whole list. Put loading, result counts, and errors in a pre-existing polite status region, and update it only for meaningful state changes.

Keyboard users should be able to keep typing, cancel, or select a result while loading. If a result is stale, confirm that its query key still matches before applying the selection. Color cannot be the only loading or error signal; associate error text with the input or list.

7. Test the state machine with controlled order

The transport test double should pause each request and release responses manually. Cover an old request succeeding after a new one, an old failure arriving after a new success, a stale response after clearing, a failed background refresh after a cache hit, a response after unmount, AbortError, and typing again during retry.

After every event assert the current key, visible result, status, cache timestamp, and announcement. Verify that equal payloads arriving in different orders do not cause duplicate rendering or lost focus. Track request count, cancellation rate, dropped stale responses, visible-result latency, and retry rate, but do not use fewer requests to hide wrong results.

High-quality sample answer

“I first normalize the complete query into a key containing text, filters, language, and permission scope. Each real request gets a requestId and AbortController, and records the current key. A response must pass both checks—its requestId is still active and its key still matches—before it can commit success, empty results, or an error. On input change I abort the old controller and silently handle AbortError, but I do not claim that abort rolled back server processing.

I send after a 250-millisecond pause to reduce noise. First load shows a placeholder. During refresh I keep old results only when they are safe, label them as belonging to the previous query, and otherwise clear them. Cache entries use the full key and may render briefly before background validation; an entry beyond the freshness window only shows loading.

The state machine distinguishes empty input, loading, refreshing old data, success, empty results, and retryable error. Errors are tied to the key, so a late old error is discarded. Focus remains in the input, result identities stay stable, and a pre-existing polite region announces meaningful state changes rather than every character.

I then force hell to return after hello, deliver a stale response after clearing, make cancellation fail, fail a cache refresh, deliver after unmount, and type again during retry. The final UI must be explainable only by the current key.”

Common mistakes

  • Only debounce → requests already sent can still race → keep a requestId or key guard.
  • Show the last response to finish → network completion order is not query order → only the current key may commit.
  • Treat abort as rollback → the server may already have processed it → use cancellation for cleanup and identity/version rules for correctness.
  • One cache entry for every query → filters, languages, or scopes can leak across results → include every result input in the key.
  • Let an old error overwrite a new query → users see an obsolete failure → bind errors to requestId and key.
  • Blank the UI for every load → users lose useful context → choose old results or a skeleton based on misleading-data risk.
  • Announce every keystroke → screen readers are repeatedly interrupted → announce meaningful loading, result, and error changes.
  • Test only ordered success → real races are absent → control response, cancel, clear, unmount, and retry order.

Follow-up questions and answers

Is a key enough for pagination or infinite scrolling?

Include filters, sort, cursor, or page in the request key and bind each page to a query version. A new query discards old pages. Loading another page for the same query may keep the previous page visible, but duplicate cursors and stale pages cannot overwrite the confirmed list. Use a server-provided next cursor instead of inferring order from a client page number.

What changes when the user types offline and reconnects?

Search usually does not need to persist every old request; keep the current input and the last acceptable cache. On reconnect, request only the current key and ignore stale offline work. If offline suggestions are a product requirement, cache age, data scope, and freshness must be visible so offline results are not presented as real-time.

Can React Query or SWR solve this alone?

A cache library can provide deduplication, caching, invalidation, and lifecycle management, but the product still defines the query key, acceptable staleness, retries, and interaction states. Library cancellation or a default stale time cannot decide permission scope, misleading-data risk, or accessible feedback. Verify the library’s race semantics and encode the rules in the query key and UI state.

Is cancellation safe if search becomes a POST with audit logging?

Do not assume it is. A POST may have server-side effects, and client abort does not roll back a transaction. Use an idempotency key, an explicit submission status, and a status-query endpoint; show success only after authoritative confirmation. POST is still reasonable for a complex side-effect-free query, but its retry and cancellation contract must be explicit.

Public sources

Related questions