I have reviewed code where a developer used debounceTime on a scroll handler and could not figure out why the sticky header update felt broken. The header only updated after the user stopped scrolling. The fix was one word: throttleTime. The two operators are not interchangeable. They solve different problems, and reaching for the wrong one produces bugs that look like timing issues but are actually semantic ones.
Debounce: Fire Only After Things Go Quiet
debounceTime(ms) delays emission until the source has been silent for the specified duration. Every new event resets the timer. Nothing fires until events stop arriving.
The clearest example: search-as-you-type. A user types 'angular signals'. That is 15 keystrokes, each triggering a valueChanges emission. With debounceTime(350), only one HTTP request fires, after the user pauses. Without it, you send 15 requests, the first 14 of which are wasted.
Throttle: Fire Regularly, But Not Too Often
throttleTime(ms) allows one emission per interval, regardless of how many events arrive. Events keep flowing in, but only one passes through per time window.
The clearest example: scroll position tracking. The scroll event can fire on every rendered frame during a scroll, 60 or more times per second. You want your UI to update while the user is scrolling, not after they stop. throttleTime(150) gives you roughly 6 updates per second, which is smooth enough for any visual feedback without burning CPU.
The One-Line Mental Model
- Debounce: you care about the FINAL value after activity stops
- Throttle: you care about REGULAR updates while activity is ongoing
- Use debounce for: search inputs, form validation triggers, window resize where final dimensions matter
- Use throttle for: scroll position, mouse position tracking, real-time dashboard refresh caps
Search Component: debounceTime + distinctUntilChanged + switchMap
These three operators belong together on every search input. debounceTime reduces API calls. distinctUntilChanged prevents firing when the value has not actually changed (user tabs in and out without editing). switchMap cancels the in-flight request if a new one starts.
import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Product, ProductService } from './product.service';
@Component({
selector: 'app-product-search',
standalone: true,
imports: [ReactiveFormsModule],
template: `<input [formControl]="searchControl" placeholder="Search products" />`
})
export class ProductSearchComponent {
private productService = inject(ProductService);
searchControl = new FormControl('');
results: Product[] = [];
constructor() {
this.searchControl.valueChanges.pipe(
debounceTime(350),
distinctUntilChanged(),
switchMap(query => this.productService.search(query ?? '')),
takeUntilDestroyed()
).subscribe(products => this.results = products);
}
}Scroll Tracker: throttleTime
On long-form application pages, tracking scroll depth to understand user drop-off is a common requirement. throttleTime keeps the event rate sane without delaying updates until scrolling stops.
import { Component, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { fromEvent, map, throttleTime } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ selector: 'app-scroll-tracker', standalone: true, template: '' })
export class ScrollTrackerComponent {
private document = inject(DOCUMENT);
scrollPercent = 0;
constructor() {
const view = this.document.defaultView;
if (!view) {
return;
}
fromEvent(view, 'scroll').pipe(
throttleTime(150),
map(() => {
const scrollTop = view.scrollY;
const docHeight = this.document.documentElement.scrollHeight - view.innerHeight;
return Math.round((scrollTop / docHeight) * 100);
}),
takeUntilDestroyed()
).subscribe(percent => this.scrollPercent = percent);
}
}Everything goes through the injected document and its defaultView, never the window or document globals. That is the whole point of injecting DOCUMENT: on the server there is no window, defaultView is null, and the component returns early instead of throwing during server-side rendering.
Mistakes I Have Seen in Production
- debounce on scroll: the sticky header or progress bar only updates after the user stops. Looks broken.
- throttle on search: requests still fire mid-typing. You reduce the count but do not eliminate the waste.
- debounceTime without switchMap: you reduce requests, but stale responses can still arrive out of order and overwrite newer results.
- debounceTime set above 500ms: users notice the lag and assume the app is slow. 300-400ms is the sweet spot for search.
- throttleTime set above 300ms on scroll: visible stuttering on the UI elements that track position.
auditTime: The One You Probably Have Not Used
auditTime(ms) is like throttleTime but fires at the END of the interval with the most recent value, rather than the FIRST value that arrived. This is useful when you want throttle behavior but need the latest value in the window, not the first. I use it for real-time form autosave: fire every 2 seconds, but with the most current form state, not the state from 2 seconds ago.
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { auditTime, switchMap } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DraftService } from './draft.service';
@Component({
selector: 'app-application-draft',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form">
<input formControlName="amount" />
</form>
`
})
export class ApplicationDraftComponent {
private fb = inject(FormBuilder);
private draftService = inject(DraftService);
form = this.fb.group({ amount: [0] });
constructor() {
this.form.valueChanges.pipe(
auditTime(2000),
switchMap(value => this.draftService.save(value)),
takeUntilDestroyed()
).subscribe();
}
}Debounce is about the final answer. Throttle is about regular progress. Get that right and the choice is always obvious.
References
- RxJS debounceTime. https://rxjs.dev/api/operators/debounceTime
- RxJS throttleTime. https://rxjs.dev/api/operators/throttleTime
- RxJS auditTime. https://rxjs.dev/api/operators/auditTime
- RxJS Marbles interactive. https://rxmarbles.com/