Page loaded: Miguel Carino | Angular Front-End Architect

Photo by Ryan De Hamer on Unsplash

State & Reactivity

The Typeahead That Answered Out of Order

Search-as-you-type is a concurrency problem wearing a data problem's clothes — and switchMap is what keeps the last keystroke honest.

Miguel Carino
Focus
Front-end architecture, Angular, team leadership, and stakeholder communication
Experience
25+ years turning enterprise web complexity into maintainable products

The search box worked every time I tested it, and failed for the people who used it fastest. A support agent would type a customer's last name, and for a fraction of a second the results were right — then they'd flicker back to a shorter, older list and stay there. The name they'd finished typing was on screen. The results underneath it belonged to three keystrokes ago.

I could not reproduce it at my desk, because I type like someone being watched. The agents typed like people with a queue of callers waiting. That difference was the whole bug.

Here's what was actually happening. Every keystroke fired its own request to the search API, and the API answered whenever it answered — not in the order it was asked. "smith" left after "smit," but "smit" came back later, landed last, and won. The UI faithfully rendered whichever response arrived most recently, with no idea that a newer question had already been asked and abandoned. It was a search box that answered questions in the order the network felt like, not the order the user asked.

Think of it as a reference librarian you keep interrupting. You ask about "smit," then immediately about "smith," and this librarian is determined to answer both — so a beat after handing you the right book, they come back with the answer to the question you'd already moved on from, and set the wrong book on top.

The bug was concurrency, and the operator owned it

My first instinct was the usual one: this is a timing problem, so add more debounceTime. Debounce helped a little, because fewer requests means fewer chances to race. But it doesn't fix the race — it just narrows the window. A fast enough typer still outruns it, and now the search feels laggy for everyone else. I was treating a concurrency problem as if it were a rate problem.

The real decision was one I'd skimmed past: how the stream of keystrokes flattens into the stream of HTTP calls. In RxJS that choice is the operator you map with, and the four common ones are not interchangeable. They differ in exactly one way that matters here — what they do to the request that's already in flight when a new one arrives.

mergeMap keeps them all alive and lets every response through, which is precisely the out-of-order behavior I was seeing. concatMap queues them, so "smith" waits politely behind "smit" and the box lags further and further behind the typing. exhaustMap ignores new keystrokes until the current request finishes, which drops characters the user definitely typed. And switchMap cancels the previous inner observable the instant a new value shows up — for an Angular HttpClient call, that unsubscribe aborts the underlying request nobody is waiting for anymore.

For a typeahead, only one of those describes what the user means. Each keystroke replaces the last as the current intent, so the last request is the only one whose answer is still true.

What the pipe looks like, and what it produces

Here's the search wired the way it should have been from the start — the form control's value stream, cleaned up and flattened with switchMap:

this.results$ = this.searchControl.valueChanges.pipe(
  debounceTime(200),
  distinctUntilChanged(),
  switchMap((term) => this.api.search(term)),
);

The consumer side is an ordinary reactive form control and an async pipe in the template — no manual subscription to leak, no request bookkeeping to get wrong:

<label for="customer-search">Search customers</label>
<input id="customer-search" [formControl]="searchControl" />
<ul>
  @for (row of results$ | async; track row.id) {
    <li>{{ row.name }}</li>
  }
</ul>

And this is the behavior that falls out of it. Type "smith" quickly and the stream fires a request for the debounced values along the way; each new term makes switchMap unsubscribe from the one before it, so the earlier requests are canceled in the browser's network layer rather than allowed to return and overwrite:

type "smit"  → request A starts
type "smith" → request A CANCELED, request B starts
request B resolves → results for "smith" render, and stay

Request A never gets the chance to land late, because it was called off the moment it stopped being the current question. The list stopped arguing with the keyboard.

I still reach for debounceTime and distinctUntilChanged here, but for what they're actually good at — fewer requests, and not re-asking a question when someone types a letter and deletes it. They make the stream cheaper. switchMap is the one making it correct. That distinction is worth holding onto, because it's easy to pile on debounce and believe you've solved a race you've only made rarer.

The other operators aren't wrong; they're answers to different questions. exhaustMap is what I'd want on a "submit" button, where the first click should win and the impatient double-click should be ignored. concatMap is right when order is the whole point and nothing may be dropped — writes to a log, steps in a sequence. mergeMap is for when the requests are genuinely independent and you want all of them. Reaching for switchMap is a statement that only the latest matters, and on a search box, it does.

switchMap Is a Decision About Which Answer Still Matters

A typeahead doesn't feel broken because the data is wrong; every response it showed was a real answer to a real question. It feels broken because it answers a question the user already replaced. The fix isn't a faster backend or a longer debounce — it's naming, in one operator, that each keystroke retires the one before it. Pick switchMap and stale answers can't win, because they're canceled before they return. Pick it on purpose, and the search box finally agrees with the person typing into it.

You can watch the stale answer get discarded — the issued-search log showing an earlier, slower response never overtaking the latest — on the live RxJS patterns demo.