Skip to content

Debounce and Search Patterns

When users interact with filters or search fields, you do not want to fire an API call on every keystroke or every change. Debouncing delays the response until the user pauses, reducing unnecessary requests. distinctUntilChanged skips emissions when the value has not changed.

Without debouncing, a valueChanges subscription on the Browse filter form fires an API call every time any control changes — even while the user is still adjusting filters:

User changes genre → API call 1
User changes year → API call 2 (while call 1 may still be running)
User changes sort → API call 3

Three unnecessary calls, with the risk of results arriving out of order (the problem switchMap prevents). With debouncing, only the final state after the user pauses triggers a call.

debounceTime(ms) waits for ms milliseconds of silence before emitting. If a new value arrives during the wait, the timer resets:

import { debounceTime } from 'rxjs/operators';
this.filterForm.valueChanges.pipe(
debounceTime(400)
).subscribe(values => {
this.loadMovies(values);
});

If the user changes three controls within 400ms, only one subscription fires — after 400ms of no further changes.

distinctUntilChanged() skips emissions when the value is the same as the previous emission. This is important when the user tabs through controls without changing values:

import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
this.filterForm.valueChanges.pipe(
debounceTime(400),
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
).subscribe(values => {
this.loadMovies(values);
});

For form groups, pass a custom comparison function since Angular compares object references by default (two different objects with the same values would not be considered equal without the custom comparator).

Here is how CinemaVault’s Browse component wires the filter form to the API:

@Component({ /* ... */ })
export class Browse implements OnInit, OnDestroy {
private fb = inject(FormBuilder);
private movieService = inject(MovieService);
private sub?: Subscription;
filterForm = this.fb.group({
genre: [''],
year: [''],
sortBy: ['popularity.desc']
});
movies: Movie[] = [];
loading = false;
ngOnInit(): void {
// Load movies immediately with defaults
this.loadMovies(this.filterForm.value);
// React to filter changes with debounce
this.sub = this.filterForm.valueChanges.pipe(
debounceTime(400),
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
switchMap(values => {
this.loading = true;
return this.movieService.discover(
values.genre ?? '',
values.year ?? '',
values.sortBy ?? 'popularity.desc'
).pipe(
catchError(() => of([]))
);
})
).subscribe(movies => {
this.movies = movies;
this.loading = false;
});
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
private loadMovies(values: typeof this.filterForm.value): void {
this.loading = true;
this.movieService.discover(
values.genre ?? '',
values.year ?? '',
values.sortBy ?? 'popularity.desc'
).subscribe(movies => {
this.movies = movies;
this.loading = false;
});
}
}

The pipeline: valueChanges → debounceTime(400) → distinctUntilChanged → switchMap (cancel old, start new) → API call.

valueChanges → emits on every form change
debounceTime(400) → waits 400ms for changes to settle
distinctUntilChanged() → skips if value hasn't actually changed
switchMap → cancels pending API call, starts fresh one
catchError → graceful fallback to empty array

Each operator in the chain serves a specific purpose. Together they create a search/filter experience that is responsive but not wasteful.

  1. Build a Search component with a text input using a FormControl (not a full FormGroup).
  2. Subscribe to searchControl.valueChanges with debounceTime(500) and distinctUntilChanged().
  3. In the subscription, call a mock search function that logs 'Searching: ' + query.
  4. Type rapidly and confirm the log appears only once, after you pause.
  5. Type the same query twice (clear and retype without changing). Confirm the log appears only once (thanks to distinctUntilChanged).
  • debounceTime(ms) delays emissions until the user pauses for ms milliseconds — prevents API calls on every keystroke.
  • distinctUntilChanged() skips emissions when the value matches the previous one — prevents redundant calls when no actual change occurred.
  • Use a custom comparator with form groups: distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)).
  • Combine with switchMap to cancel in-flight requests when new values arrive.
  • Unsubscribe in ngOnDestroy to prevent memory leaks.