Putting together an autocomplete search with RxJS is a classic example that requires only a modest amount of code. In this piece, we go beyond that baseline: we build a custom autocomplete operator that is fully "safe," a concept we'll clarify as we progress.
For a typical autocomplete implementation, we'd reach for a debounceTime operator to limit how often we react to each keystroke, and we'd choose switchMap over mergeMap so that any in-flight request is cancelled the moment a new one comes in.
A standard version of that autocomplete could be written as follows:
const term$ = new BehaviorSubject('');
const results$ = term$
.pipe(
// wait until the user stops typing for a second
debounceTime(1000),
// higher order observable, abort previous
// call if still busy
switchMap(term => getAutocompleteSuggestions(term))
)
The problem
Once the user pauses for a second, the browser dispatches a fresh XHR request. If the user resumes typing while that request is still pending and a newer one is about to start, the browser cancels the older request to prevent race conditions. This cancellation happens because switchMap unsubscribes from the previous observable.
That all sounds fine, except for one detail. What happens if the user starts typing while an XHR request is still active? With the debounceTime operator in place, the ongoing request won't be cancelled until a full second elapses. During that interval, new results can arrive and be displayed to the user—even though those results are stale and no longer relevant.
Our goal is to cancel the XHR request the instant the user types again, skipping the one-second wait entirely. That's a good reason to build a personalized operator. But before we write one from scratch, let's see if we can solve this by chaining existing operators.
One approach is to attach a takeUntil operator to the observable responsible for triggering the XHR call. Once takeUntil receives a value, it completes the source observable. Here, we want the observable to complete (and thus cancel the request) whenever the user types again. Based on that, we could attempt something like the following:
const term$ = new BehaviorSubject('');
const results$ = term$
.pipe(
debounceTime(1000),
switchMap(term =>
getAutocompleteSuggestions(term)
.pipe(
takeUntil(term$) // this still won't work
)
)
)
)
Unfortunately, this approach doesn't work yet.
The term$ observable is set up as a BehaviorSubject for two distinct reasons:
- It lets us provide an initial value to the subject
- Internally, a
BehaviorSubjectbehaves like aReplaySubject(1), always retaining the most recent value. That's crucial if we need to subscribe to it later (which is exactly what happens withtakeUntil).
Since term$ retains its latest value, the takeUntil operator always has something to emit, so every XHR request gets cancelled right away. That's not what we're after. Instead, we need to ignore the first emitted value from term$ on each occasion. The skip operator does exactly that, as shown in the example below:
const term$ = new BehaviorSubject('');
const results$ = term$
.pipe(
debounceTime(1000),
switchMap(term =>
getAutocompleteSuggestions(term)
.pipe(
takeUntil(
//skip 1 value
term$.pipe(skip(1))
)
)
)
)
)
Now the behavior we want is in place:
- User enters 'l'
- The application pauses for a second
- It then issues an XHR request
- User enters 'lu'
- Even if the prior XHR request isn't done yet, it gets cancelled immediately (no more waiting that extra second)
As a result, the user never ends up with outdated information on screen.
Extracting the logic into a custom operator
Putting this logic inline each time isn't practical, so we'll package it into a dedicated operator.
Creating a custom operator turns out to be straightforward. In essence, an operator is a function that returns another function, and that inner function takes the source observable as its input.
const autocomplete = (/* additional parameters */) =>
(source$) => source$.pipe(/* do stuff */ )
We accept time and selector as arguments, then compose them with the operators we've already discussed to form our own utility.
The resulting operator looks like this:
const autocomplete = (time, selector) => (source$) =>
source$.pipe(
debounceTime(time),
switchMap((...args: any[]) => selector(...args)
.pipe(
takeUntil(
source$
.pipe(
skip(1)
)
)
)
)
)
Integrating this operator into our app is quite simple:
const term$ = new BehaviorSubject('');
const results$ = term$
.pipe(
autocomplete(1000, term => getAutocompleteSuggestions(term))
)
You can check out the full source code over on StackBlitz.
Conclusion
Going with just debounceTime and switchMap isn't a cure-all. Carelessly showing stale results to users is something we'd rather avoid, and crafting bespoke operators is surprisingly simple. Hope you found this read worthwhile.
Special thanks
A warm shout-out to the people who helped review this piece:
- Nicholas Jamieson @ncjamieson
- Philippe Martin @feloy2
- Jan-Niklas Wortmann @niklas_wortmann
- Maarten Tibau @maartentibau
- Kwinten Pisman @kwintenp

•