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() — an experimental helper in @angular/core 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 (or any reactive function), debounced() returns a Resource<T> whose value() mirrors the source, but only settles after the source has been stable for N milliseconds:
import { Component, debounced, signal } from '@angular/core';
@Component({ selector: 'app-demo', template: '' })
export class Demo {
query = signal('');
debouncedQuery = debounced(this.query, 300);
typeFast() {
this.query.set('a');
this.query.set('an');
this.query.set('ang');
// debouncedQuery.value() is still '' and status() is 'loading'
// 300ms after the last set: value() is 'ang', status() is 'resolved'
}
}
Three facts:
debounced(source, wait)returns a read-onlyResource<T>, not aSignal<T>— you read.value(),.status()and.isLoading()value()is the last settled value — while the timer runs,status()is'loading'andvalue()keeps the previous value- Rapid writes to the source collapse — each new value discards the pending timer, so only the final value settles
debounced() is experimental in v22: ready to try, but the API may change before it is stable. Call it in an injection context (field initializer or constructor) or pass an injector option.
The primary use case: feed the debounced value into httpResource() (lesson 6.3) so a request only fires after the user stops typing.
A search box in 10 lines #
import { Component, debounced, signal } from '@angular/core';
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<{ id: number; name: string }[]>(() => ({
url: '/api/search',
params: { q: this.debouncedQuery.value() },
}));
}
What happens:
- User types —
query()updates on every keystroke debouncedQuery.status()switches to'loading';debouncedQuery.value()still holds the old query- After 300ms of typing-stillness,
debouncedQuery.value()updates and its status becomes'resolved' httpResource()'s request function re-evaluates because it readsdebouncedQuery.value()- 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.value() } }));
}
Functionally equivalent for the common case. The signal version:
- No
unsubscribeneeded - No
asyncpipe in the template — read with.value() distinctUntilChanged()is implicit (Object.is, or yourequaloption)- Auto-cancellation built into
httpResource - A free "still typing" flag:
debouncedQuery.isLoading()
The RxJS version wins when you need richer stream operators (combineLatest, scan, retry with backoff) — covered in Module 7.
Options #
The signature of debounced() in v22:
function debounced<T>(
source: () => T, // any signal or reactive function
wait: DebounceTimer<T>, // ms, or (value, lastSnapshot) => Promise<void> | void
options?: DebouncedOptions<T>, // { injector?, equal? }
): Resource<T>
Variations:
// Fixed delay in milliseconds
debounced(query, 300)
// Custom wait function — settles when the returned promise resolves
debounced(query, (value, lastSnapshot) => {
if (lastSnapshot.status === 'error') return; // no promise: settle immediately
const ms = value.length < 3 ? 500 : 200; // short queries wait longer
return new Promise<void>((resolve) => setTimeout(resolve, ms));
})
// Custom equality — only re-settle when the fields you care about change
debounced(filter, 200, { equal: (a, b) => a.category === b.category && a.minPrice === b.minPrice })
There are no leading / trailing flags: debounced() always settles at the end of a burst. If the source changes before a wait promise resolves, Angular discards that promise and starts a new wait.
Throttling vs debouncing #
debounced() debounces — it collapses a burst down to one trailing value. Angular v22 has no throttled() counterpart, so to throttle (update at most once per interval during a burst) bridge through RxJS:
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { throttleTime } from 'rxjs';
scrollY = signal(0);
throttledScrollY = toSignal(toObservable(this.scrollY).pipe(throttleTime(100)), { initialValue: 0 });
For visual updates during a scroll, throttle (steady updates during the burst). For network requests after typing, debounce (one request when the user stops).
Use with computed and effect #
The debounced resource's value is a signal, so it participates in the reactive graph like any other:
const query = signal('');
const debouncedQuery = debounced(query, 300);
// Computed off the debounced value
const normalizedQuery = computed(() => debouncedQuery.value().trim().toLowerCase());
// Effect on the debounced value
effect(() => {
if (normalizedQuery()) console.log('Searching for:', normalizedQuery());
});
The computed and the effect re-evaluate only when the debounced value settles — once per 300ms of stillness at most. The fast query writes are invisible to them.
Multiple consumers, one debouncer #
A single debounced() resource can feed many consumers. They all see the same settled value:
const query = signal('');
const debouncedQuery = debounced(query, 300);
const searchResults = httpResource(() => ({ url: '/api/search', params: { q: debouncedQuery.value() } }));
const suggestions = httpResource(() => ({ url: '/api/suggest', params: { q: debouncedQuery.value() } }));
const recentlyViewed = computed(() => loadRecent(debouncedQuery.value()));
All three consumers update on the same cadence. No synchronization concerns.
Edge cases #
Cleanup on destroy #
When the injector debounced() was created in is destroyed (for a component field, when the component dies), Angular destroys the resource and cancels any pending timer. No leak, no late update firing into a dead component.
First read #
debounced() settles to the source's initial value synchronously. The delay only applies to later changes:
const query = signal('hello');
const debouncedQuery = debounced(query, 300);
debouncedQuery.value(); // 'hello' immediately — no delay on the initial value
debouncedQuery.status(); // 'resolved'
This avoids the common bug where a debounced search would show "no results" for 300ms after first render.
Setting the same value #
The source signal ignores an identical set(), and debounced() also compares each new value with the pending or settled one (Object.is or your equal):
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 resource reads query(), not the debounced value |
Read debouncedQuery.value() |
Type error: debouncedQuery is not callable |
Calling the result like a signal | It is a Resource — use .value() |
NG0203 injection context error |
Created in ngOnInit or an event handler |
Use a field initializer, or pass { injector } |
value() throws |
The source threw; status is 'error' |
Keep the source pure, or guard with hasValue() |
| Test waits forever | Tests don't auto-advance timers | vi.useFakeTimers(), advance time, then TestBed.tick() |
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 a status readout and a computed consumer:
import { Component, computed, debounced, signal } from '@angular/core';
@Component({
selector: 'app-search',
template: `
<input [value]="query()" (input)="query.set($any($event.target).value)" />
<p>Raw: "{{ query() }}" (every keystroke)</p>
<p>Debounced: "{{ debouncedQuery.value() }}" (after pause)</p>
<p>Status: {{ debouncedQuery.status() }}</p>
<p>Length: {{ debouncedLength() }} characters</p>
`,
})
export class SearchComponent {
query = signal('');
debouncedQuery = debounced(this.query, 500);
debouncedLength = computed(() => this.debouncedQuery.value().length);
}
Type fast — "Raw" updates per keystroke, "Status" flips to loading, and "Debounced" waits for you to stop. The computed debouncedLength updates at the debounced cadence (that's what it reads from).
search_documentationFor v21, the cleanest option is a round trip through @angular/core/rxjs-interop: toSignal(toObservable(query).pipe(debounceTime(300)), { initialValue: '' }) — one line, and the result is a plain signal. Alternatively, write a tiny signal + setTimeout wrapper yourself (about 15 lines). When you move to v22, switching to debounced() is mechanical, with one difference: it returns a Resource, so reads become debouncedQuery.value().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.
1 comment



Pingback: Angular Signals: The Producer/Consumer Graph and Glitch-Free Reactivity [2026] - Code-JS