Design search-as-you-type: debouncing, cancelling, ignoring stale responses, caching and local suggestions.

26.5.4 · L5 · Interview

Predict first

Five keystrokes, a 150 ms debounce, and the first request is the slow one. What does the user see?

// keystrokes with a 150 ms debounce; responses can return out of order
let keys: [(t: Int, text: String)] = [(0, "s"), (60, "sw"), (120, "swi"), (500, "swif"), (560, "swift")]
let latency = ["swi": 700, "swift": 90]                        // the older request is the slow one
let debounce = 150
var fired: [(seq: Int, at: Int, q: String)] = []
for (i, k) in keys.enumerated() {
    let nextT = i + 1 < keys.count ? keys[i + 1].t : Int.max
    if nextT - k.t >= debounce { fired.append((fired.count + 1, k.t + debounce, k.text)) }
}
print("requests sent:", fired.map { "#\($0.seq) '\($0.q)' @\($0.at)ms" }.joined(separator: ", "))
let arrivals = fired.map { ($0.seq, $0.q, $0.at + (latency[$0.q] ?? 100)) }.sorted { $0.2 < $1.2 }
var latestSent = 0, shown = "", naiveShown = ""
for f in fired { latestSent = f.seq }
for (seq, q, t) in arrivals {
    naiveShown = q
    if seq == latestSent { shown = q }
    print("t=\(t)ms response for '\(q)' \(seq == latestSent ? "applied" : "discarded as stale")")
}
print("last response wins (naive):", naiveShown, "| latest-sequence wins:", shown)
  • Two requests; the slow “swi” reply arrives last, so “last response wins” shows swi while the latest-sequence rule shows swift
  • Five requests, all applied in order
  • One request, for “swift”

Mental model. A waiter who writes down every word you say and runs to the kitchen each time is useless. Wait until you pause, take the latest order, and tear up the earlier slips.

Every keystroke should not become a request. A debounce waits until the user pauses (about 150 to 300 ms) and then sends one query for the current text; in the check, five keystrokes produce only two requests (swi at 270 ms and swift at 710 ms). A throttle sends at most one per interval and suits live-updating results. Debouncing alone is not enough, because responses can return out of order: the slow reply for swi arrives at 970 ms, after the reply for swift at 800 ms. If the UI shows whichever response arrives last, the user sees results for swi under a field that says swift. The fix is to tag each request with a sequence number (or the query string) and apply a response only if it matches the latest request; also cancel the old task so the network and the server stop working on it. In Swift concurrency that is cancelling the previous Task (and checking Task.isCancelled or relying on the cancelled URLSession call), in Combine switchToLatest (Q305).

Around that core: show local results immediately (recent searches, contacts, a bundled prefix index) and merge server suggestions when they arrive, so the field feels instant offline; keep a small cache keyed by normalised query (lowercased, trimmed, diacritics folded) with a short expiry, and remember that “swi” results can seed the first guess for “swif” by filtering; require a minimum length to avoid the expensive one-letter query; handle IME composition (do not search on marked text mid-composition) and paste; and keep the request cheap (small payload, a server-side prefix trie or index, ranked and limited to about ten suggestions). Track keystroke-to-first-result latency, the share of requests cancelled, and the selection rate (did the user pick a suggestion?). Accessibility matters too: announce the number of results when they change and keep focus in the field (Q477).

Debounce reduces requests, and a latest-sequence check discards replies that arrive out of order.

Common traps

  • Showing the response that arrives last. Out-of-order replies show suggestions for text the user has already changed.
  • Debouncing without cancelling. Dead requests still cost the server and battery, and can still mutate state when they complete.

They might follow up with

  • Debounce or throttle? Debounce for “wait for a pause”, throttle for “update at most every N ms while typing”.
  • How do you test the race? Inject a fake client with controllable latency per query, as the check does with its latency table (Q445).

Related questions

  • 11.2.2 How do flatMap and switchToLatest differ, and when do you use each?
  • 11.7.3 Where should a catch go in a search pipeline, and what happens to the stream if you put it outside the…
  • 24.5.2 How do you ship accessibility as a bar, not a backlog: a definition of done, accessible-by-default…
  • 22.3.2 How do you test code that depends on time: debounce, timeouts, retries and expiry?
  • 13.5.2 How do you design a cache: memory and disk layers, TTL, eviction, and what to key it by?

← Design a map or ride-hailing client: tile maths, caching while…   Design the notification experience end to end: when to ask… →