The Challenge

While building a frontend feature that required filtering and sorting data, I hit a specific scenario: the user input needed debouncing to minimize how often filter and sort operations ran, yet the very first operation should trigger without any waiting.

RxJS turned out to be the perfect tool for this problem.

Debouncing every keystroke

If the initial action can also wait, a fairly standard setup would be:

import { of, BehaviorSubject, combineLatest } from 'rxjs';
import { debounceTime, delay, tap, map } from 'rxjs/operators';

class FrameworksService {
  private querySubject = new BehaviorSubject<string>('');

  private query$ = this.querySubject.pipe(
    debounceTime(2000)
  );

  private collection$ = of([
    'Angular',
    'React',
    'Vue'
  ]).pipe(
    delay(10)
  );

  filteredCollection$ = combineLatest(
    this.query$,
    this.collection$
  ).pipe(
    tap(() => console.log('filtering')),
    map(([query, collection]) => collection.filter(item => item.includes(query)))
  );

  filter(query: string) {
    this.querySubject.next(query);
  }
}
    
const frameworksService = new FrameworksService();

frameworksService.filter('a');

frameworksService.filteredCollection$.subscribe(console.log);

setTimeout(() => {
  frameworksService.filter('Angular');
  frameworksService.filter('React');
  frameworksService.filter('Vue');
  frameworksService.filter('React');
}, 5000);

But here, each and every input event is subject to the 2-second debounce. That means you will have to wait the full debounce interval just to see the very first console.log, which indicates the filtering operation has fired.

I, on the other hand, wanted that initial operation to happen without delay.

Debouncing all but the first entry

This is where RxJS operators really earn their keep.

The solution involved creating two separate streams to represent the query:

private querySubject = new BehaviorSubject<string>('');

private initialQuery$ = this.querySubject.pipe(
  first()
);

private debouncedQuery$ = this.querySubject.pipe(
  skip(1),
  debounceTime(2000)
);

Then, those two streams were merged together:

private query$ = merge(
    this.initialQuery$,
    this.debouncedQuery$
);

The remainder of the FrameworksService class didn’t change at all.

With this approach, you’ll get the first console.log right away, but every subsequent query gets debounced as expected.