đ Welcome to Chapter 9!
Imagine a user typing in a search box. They type "i", "ip", "iph", "ipho", "iphon", "iphone" â 6 keystrokes in 2 seconds.
Do you really want to make 6 API calls? Of course not! You want to wait until they stop typing and then search once.
That's what timing operators solve. They control when and how often values flow through your stream.
âąī¸ debounceTime() â Wait for the Silence
debounceTime(ms) waits until there's a pause of ms milliseconds, THEN lets the latest value through.
Think of it like this: "Ignore everything until they stop for a moment."
Like a person who waits for you to finish talking before responding.
import { debounceTime } from 'rxjs/operators';
// User types fast: 'i' â 'ip' â 'iph' â 'ipho' â 'iphon' â 'iphone'
// debounceTime(400) waits 400ms of silence, then sends 'iphone' only
searchControl.valueChanges
.pipe(debounceTime(400))
.subscribe(term => {
this.searchProducts(term); // Only called ONCE with 'iphone'!
});
Timeline:
Type 'i' â [400ms timer starts]
Type 'ip' â [reset timer]
Type 'iph' â [reset timer]
Type 'iphone'â [reset timer]
... 400ms silence ...
EMIT: 'iphone' â
Real Angular Example â Smart Search Box
import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Observable, Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, startWith, takeUntil } from 'rxjs/operators';
@Component({
selector: 'app-search-box',
template: `
<div class="search-wrapper">
<input
[formControl]="searchControl"
placeholder="Search products..."
(keyup.escape)="clearSearch()">
<span *ngIf="isLoading" class="spinner">đ</span>
<button *ngIf="searchControl.value" (click)="clearSearch()">â</button>
</div>
<div class="results-count" *ngIf="(results$ | async) as results">
Found {{ results.length }} results
</div>
<div class="results">
<div *ngFor="let item of results$ | async" class="result-item">
<strong>{{ item.name }}</strong>
<span>ā§ŗ{{ item.price }}</span>
</div>
</div>
`
})
export class SearchBoxComponent implements OnInit, OnDestroy {
searchControl = new FormControl('');
results$!: Observable<Product[]>;
isLoading = false;
private destroy$ = new Subject<void>();
constructor(private productService: ProductService) {}
ngOnInit(): void {
this.results$ = this.searchControl.valueChanges.pipe(
startWith(''),
debounceTime(400), // Wait 400ms after typing stops
distinctUntilChanged(), // Don't search if value is the same
tap(() => this.isLoading = true),
switchMap(term =>
this.productService.search(term || '').pipe(
catchError(() => of([]))
)
),
tap(() => this.isLoading = false),
takeUntil(this.destroy$)
);
}
clearSearch(): void {
this.searchControl.setValue('');
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
Without debounceTime, a user typing "iphone 14" (8 keystrokes) would make 8 API calls. With it: 1 call. đ
⥠throttleTime() â Only Once Per Time Window
throttleTime(ms) lets the FIRST value through, then ignores everything for ms milliseconds.
"One value per window â take the first, ignore the rest."
Like a bouncer who lets one person in, then waits before letting the next.
import { throttleTime } from 'rxjs/operators';
import { fromEvent } from 'rxjs';
// User scrolls constantly â fire only once every 200ms
fromEvent(window, 'scroll')
.pipe(throttleTime(200))
.subscribe(() => {
this.checkIfNearBottom();
});
debounceTime vs throttleTime:
User clicks rapidly: click, click, click, click, click
(every 100ms)
debounceTime(300): ................ EMIT (only after 300ms silence)
throttleTime(300): EMIT ............. EMIT (first click, then every 300ms)
Use debounceTime when you want to wait for the user to FINISH (search, form input)
Use throttleTime when you want regular updates while something is happening (scroll, resize, mouse move)
đ¯ distinctUntilChanged() â Skip Identical Values
This one is simple: don't emit if the value is the same as the last one.
import { distinctUntilChanged } from 'rxjs/operators';
// User clicks in the same input field without changing value
searchControl.valueChanges
.pipe(
debounceTime(400),
distinctUntilChanged() // Don't search again if value didn't change!
)
.subscribe(term => this.search(term));
You can also compare objects with a custom comparator:
this.filterForm.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged((prev, curr) =>
prev.category === curr.category && prev.maxPrice === curr.maxPrice
)
)
đ interval() â Emit on a Schedule
interval(ms) emits incrementing numbers (0, 1, 2, 3...) every ms milliseconds. It runs forever until you unsubscribe.
import { interval } from 'rxjs';
import { takeUntil, take } from 'rxjs/operators';
// Emit every 1 second: 0, 1, 2, 3...
interval(1000).subscribe(n => console.log(n));
Real Example â Auto-Refresh Dashboard
@Component({
selector: 'app-live-dashboard',
template: `
<div class="dashboard">
<h2>đ´ Live Orders</h2>
<p class="last-updated">Last updated: {{ lastUpdated | date:'HH:mm:ss' }}</p>
<div *ngFor="let order of orders$ | async" class="order-row">
#{{ order.id }} â {{ order.status }} â ā§ŗ{{ order.total }}
</div>
</div>
`
})
export class LiveDashboardComponent implements OnInit, OnDestroy {
orders$!: Observable<Order[]>;
lastUpdated = new Date();
private destroy$ = new Subject<void>();
constructor(private orderService: OrderService) {}
ngOnInit(): void {
// Load immediately, then refresh every 30 seconds
this.orders$ = interval(30000).pipe(
startWith(0), // Emit immediately (don't wait for first interval)
switchMap(() => {
this.lastUpdated = new Date();
return this.orderService.getLiveOrders();
}),
takeUntil(this.destroy$)
);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
The dashboard refreshes every 30 seconds automatically â no manual button needed!
â˛ī¸ timer() â Emit Once After a Delay
timer(delay) emits once after the specified delay.
timer(delay, interval) emits once after delay, then every interval.
import { timer } from 'rxjs';
// Show a toast notification, then hide it after 3 seconds
this.showToast = true;
timer(3000).pipe(
takeUntil(this.destroy$)
).subscribe(() => {
this.showToast = false;
});
Real Example â Auto-dismiss Notifications
@Component({
selector: 'app-toast',
template: `
<div *ngFor="let toast of toasts" class="toast" [class]="toast.type">
{{ toast.message }}
<button (click)="dismiss(toast.id)">â</button>
</div>
`
})
export class ToastComponent implements OnInit, OnDestroy {
toasts: Toast[] = [];
private destroy$ = new Subject<void>();
constructor(private toastService: ToastService) {}
ngOnInit(): void {
this.toastService.toasts$
.pipe(takeUntil(this.destroy$))
.subscribe(toast => {
this.toasts.push(toast);
// Auto-remove after 4 seconds
timer(4000)
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.dismiss(toast.id);
});
});
}
dismiss(id: number): void {
this.toasts = this.toasts.filter(t => t.id !== id);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
đĄ fromEvent() â Turn DOM Events into Observables
fromEvent converts any DOM event into an Observable stream!
import { fromEvent } from 'rxjs';
import { debounceTime, map } from 'rxjs/operators';
// Infinite scroll â detect when user is near bottom
const scroll$ = fromEvent(window, 'scroll').pipe(
throttleTime(200),
map(() => {
const scrollTop = window.scrollY;
const windowHeight = window.innerHeight;
const docHeight = document.documentElement.scrollHeight;
return (scrollTop + windowHeight) / docHeight * 100; // % scrolled
}),
filter(pct => pct > 80) // Only when 80% scrolled
);
scroll$.pipe(
takeUntil(this.destroy$)
).subscribe(() => {
this.loadMoreItems();
});
đī¸ Full Real-World Example â Infinite Scroll List
@Component({
selector: 'app-infinite-list',
template: `
<div class="scroll-container" #scrollContainer>
<div *ngFor="let item of items" class="list-item">
<strong>{{ item.title }}</strong>
<p>{{ item.body }}</p>
</div>
<div *ngIf="isLoadingMore" class="loading-more">
Loading more... âŗ
</div>
<div *ngIf="noMoreItems" class="end-message">
đ You've reached the end!
</div>
</div>
`
})
export class InfiniteListComponent implements OnInit, OnDestroy {
items: Item[] = [];
currentPage = 1;
isLoadingMore = false;
noMoreItems = false;
private destroy$ = new Subject<void>();
constructor(
private itemService: ItemService,
private el: ElementRef
) {}
ngOnInit(): void {
// Load initial items
this.loadPage(1);
// Watch for scroll near bottom
fromEvent(this.el.nativeElement.querySelector('.scroll-container'), 'scroll')
.pipe(
throttleTime(300),
filter(() => !this.isLoadingMore && !this.noMoreItems),
filter((event: any) => {
const el = event.target;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 200;
return nearBottom;
}),
takeUntil(this.destroy$)
)
.subscribe(() => {
this.currentPage++;
this.loadPage(this.currentPage);
});
}
private loadPage(page: number): void {
this.isLoadingMore = true;
this.itemService.getItems(page).subscribe({
next: (newItems) => {
if (newItems.length === 0) {
this.noMoreItems = true;
} else {
this.items = [...this.items, ...newItems];
}
this.isLoadingMore = false;
},
error: () => {
this.isLoadingMore = false;
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
đ§ Quick Reference â When to Use Which
debounceTime(400) â User is typing in a search box. Wait for them to pause before searching.
throttleTime(200) â User is scrolling or resizing. Handle events at most once per 200ms.
distinctUntilChanged() â Don't trigger again if the value hasn't actually changed.
interval(30000) â Refresh dashboard data every 30 seconds.
timer(3000) â Auto-dismiss a notification after 3 seconds.
fromEvent(el, 'scroll') â Convert a DOM scroll event into an Observable.
đ§ Chapter 9 Summary â What You Learned
-
debounceTime(ms)â emits only after the stream is silent formsmilliseconds. Best for search inputs. -
throttleTime(ms)â emits the first value, then ignores formsmilliseconds. Best for scroll/resize events. -
distinctUntilChanged()â skips emission if value is same as previous. Essential for search and forms. -
interval(ms)â emits 0, 1, 2... everymsmilliseconds. Best for polling/auto-refresh. -
timer(ms)â emits once after delay. Best for auto-dismiss notifications. -
fromEvent(element, event)â turns any DOM event into an Observable. Best for scroll, click, resize.
đ Coming Up in Chapter 10 â The Final Chapter!
We wrap up the series with the most important real-world patterns every Angular developer should know:
- The async pipe + loading state pattern
- The smart/dumb component pattern with Observables
- The state management pattern using BehaviorSubject
- Performance tips â avoiding common RxJS pitfalls
See you in the final chapter! đ
đ RxJS Deep Dive Newsletter Series | Chapter 9 of 10
Top comments (0)