Angular 22 debounced() Signals: Native Rate Limiting Without RxJS [2026]
Search-as-you-type, slider-drag-to-filter, autosave-as-you-edit — every interactive UI eventually needs to debounce a fast-changing value. Before v22, you reached for RxJS: valueChanges.pipe(debounceTime(300), switchMap(...)). In v22 Angular ships debounced() — a signal helper that does the same job with no Observable in sight.
This is lesson 3.4, following lesson 3.3 on model(). The debounced() helper is the cleanest example yet of "signals replacing RxJS for the common case" — and it is the right shape for understanding when RxJS is still the better tool (lesson 7.1).
The shape #
Given any signal, debounced() returns a new signal that mirrors the source but only updates after the source has been stable for N milliseconds:
import { signal, computed } from '@angular/core';
import { debounced } from '@angular/core/signals';
const query = signal('');
const debouncedQuery = debounced(query, 300);
query.set('a');
query.set('an');
query.set('ang');
// debouncedQuery() is still '' here
// 300ms after the last set:
// debouncedQuery() becomes 'ang'
Three facts:
debounced(source, delayMs)returns a newSignal<T>— read-only- The new signal lags the source by
delayMsof stability - Rapid writes to the source collapse — only the final value propagates
The primary use case: pair with resource() (lesson 6.3) so an HTTP request only fires after the user stops typing.
A search box in 10 lines #
import { Component, signal } from '@angular/core';
import { debounced } from '@angular/core/signals';
import { httpResource } from '@angular/common/http';
@Component({
selector: 'app-search',
template: `
<input [value]="query()" (input)="query.set($any($event.target).value)" />
@if (results.isLoading()) { <p>Loading...</p> }
@for (item of results.value() ?? []; track item.id) { <li>{{ item.name }}</li> }
`,
})
export class SearchComponent {
query = signal('');
debouncedQuery = debounced(this.query, 300);
results = httpResource(() => ({
url: '/api/search',
params: { q: this.debouncedQuery() },
}));
}
What happens:
- User types —
query()updates on every keystroke debouncedQuery()does NOT immediately update- After 300ms of typing-stillness,
debouncedQuery()updates httpResource()'s factory re-evaluates because it readsdebouncedQuery()- New HTTP request fires; previous in-flight one is auto-aborted
No RxJS, no switchMap, no unsubscribe. The signal graph handles everything.
debounced() vs RxJS debounceTime #
A side-by-side:
// Classic — RxJS
class SearchOld {
query$ = new BehaviorSubject('');
results$ = this.query$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.http.get(`/api/search?q=${q}`)),
);
}
// Modern — signals + debounced + httpResource
class SearchNew {
query = signal('');
debouncedQuery = debounced(this.query, 300);
results = httpResource(() => ({ url: '/api/search', params: { q: this.debouncedQuery() } }));
}
Functionally equivalent for the common case. The signal version:
- No
unsubscribeneeded - No
asyncpipe in the template — read with() distinctUntilChanged()is implicit (signal equality)- Auto-cancellation built into
httpResource
The RxJS version wins when you need richer stream operators (combineLatest, scan, retry with backoff) — covered in Module 7.
Options #
The full signature of debounced():
function debounced<T>(
source: Signal<T>,
delayMs: number | Signal<number>,
options?: {
leading?: boolean; // emit immediately on first change
trailing?: boolean; // emit after the debounce period (default true)
}
): Signal<T>
Variations:
// Trailing — default. Emit after pause.
debounced(query, 300)
// Leading — emit immediately, then debounce subsequent
debounced(query, 300, { leading: true, trailing: false })
// Leading AND trailing — emit at start AND end of a burst
debounced(query, 300, { leading: true, trailing: true })
// Dynamic delay — debounce period that can itself be a signal
debounced(query, debounceMs) // debounceMs is signal<number>
For search-as-you-type, the default (trailing-only) is what you want. Leading is useful for "first click is instant, rapid follow-up clicks are ignored" — rate-limiting a button.
Throttling vs debouncing #
debounced() debounces — collapses bursts down to one trailing emit. To throttle (emit at fixed intervals during a burst), there is throttled() with the same shape:
import { throttled } from '@angular/core/signals';
const scrollY = signal(0);
const throttledScrollY = throttled(scrollY, 100); // updates at most once per 100ms
For visual updates during a scroll, throttling is what you want (smooth at 60fps cap). For network requests after typing, debouncing is what you want (one request when the user stops).
Use with computed and effect #
A debounced signal participates in the reactive graph like any other:
const query = signal('');
const debouncedQuery = debounced(query, 300);
// Computed off the debounced version
const normalizedQuery = computed(() => debouncedQuery().trim().toLowerCase());
// Effect on the debounced version
effect(() => {
if (normalizedQuery()) console.log('Searching for:', normalizedQuery());
});
The computed and the effect re-evaluate only when the debounced signal changes — every 300ms-of-stillness at most. The fast query writes are invisible to them.
Multiple consumers, one debouncer #
A single debounced() signal can feed many consumers. They all see the same debounced value:
const query = signal('');
const debouncedQuery = debounced(query, 300);
const searchResults = httpResource(() => ({ url: '/api/search', params: { q: debouncedQuery() } }));
const suggestions = httpResource(() => ({ url: '/api/suggest', params: { q: debouncedQuery() } }));
const recentlyViewed = computed(() => loadRecent(debouncedQuery()));
All three consumers update on the same cadence. No redundant computation, no synchronization concerns.
Edge cases #
Cleanup on destroy #
debounced() automatically cleans up when the component dies (it uses DestroyRef internally). If the debounce timer is still pending when the component is destroyed, the timer is cleared. No leak, no late update firing into a dead component.
First read #
debounced() returns the SOURCE's initial value at first read. The 300ms delay only kicks in for subsequent writes:
const query = signal('hello');
const debouncedQuery = debounced(query, 300);
debouncedQuery(); // 'hello' immediately — no delay on initial value
This avoids the common bug where a debounced search would show "no results" for 300ms after first render.
Setting the same value #
Due to Angular's default equality check, setting the same value does NOT restart the debounce timer:
query.set('hello');
query.set('hello'); // no-op, no timer reset
query.set('hello'); // still no-op
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| Search results never appear | The component reads query() not debouncedQuery() in the resource factory |
Always read the debounced signal in the consumer |
| Burst not collapsed | The debounce target reads the source directly somewhere else, bypassing the debouncer | Audit all reads — only the debounced version triggers updates |
| Leading emit fires twice | Set leading: true AND trailing: true (default) |
If you want leading-only, set trailing: false |
| Test waits forever | Tests don't auto-advance timers | Use Vitest's fake timers (vi.useFakeTimers()) and advance manually |
| Slow on huge bursts | The debounce reset on every change is cheap, but the source observers may not be | Audit which signals are downstream of the source — they all re-run on every burst write |
What's next #
Lessons 3.5 and 3.6 cover zoneless change detection — what triggers re-renders in 2026 Angular now that Zone.js is no longer the default. Lesson 3.7 is the signal-vs-observable decision tree. Lesson 3.8 closes Module 3 with linkedSignal().
Try it yourself #
A debounced typeahead with three computed consumers:
import { Component, signal, computed } from '@angular/core';
import { debounced } from '@angular/core/signals';
@Component({
selector: 'app-search',
template: `
<input [value]="query()" (input)="query.set($any($event.target).value)" />
<p>Raw: "{{ query() }}" (every keystroke)</p>
<p>Debounced: "{{ debouncedQuery() }}" (after pause)</p>
<p>Length: {{ debouncedLength() }} characters</p>
`,
})
export class SearchComponent {
query = signal('');
debouncedQuery = debounced(this.query, 500);
debouncedLength = computed(() => this.debouncedQuery().length);
}
Type fast — the "Raw" line updates per keystroke, the "Debounced" line waits for you to stop. The computed debouncedLength updates at the debounced cadence (because that's what it reads from).
find_examplesFor v21, you have three options. The cleanest is the small debouncedSignal helper available in @angular/core/rxjs-interop: bridge the signal to an Observable with toObservable(), pipe through debounceTime, and bridge back with toSignal() — 3 lines. Alternatively, write a tiny signal + setTimeout wrapper yourself (about 15 lines). Or wait for v22 — the migration to debounced() is mechanical.Lesson 3.5 picks up zoneless change detection — what now triggers Angular to re-render.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


