debounced() creates a “delayed” version of a signal: the value updates only after the source has stayed still for a while. It’s the classic search box case: you don’t want a request on every keystroke, only once the user has stopped typing.
debounced is marked experimental (@experimental 22.0): the API may change. It’s safe to use, but keep it isolated (for example in a store or a search component), so that a possible change is easy to apply.
Example: search with debounce + httpResource
import { httpResource } from '@angular/common/http';
import { Component, debounced, signal } from '@angular/core';
@Component({
selector: 'app-user-search',
template: `
<input [value]="query()" (input)="query.set($any($event.target).value)" />
@if (search.isLoading()) { <span>waiting for you to finish typing…</span> }
@for (u of users.value().items; track u.id) {
<p>{{ u.name }}</p>
}
`,
})
export class UserSearch {
readonly query = signal('');
// debounced() returns a Resource: the value updates 300 ms after the LAST change.
readonly search = debounced(this.query, 300);
readonly users = httpResource<PageDto<UserDto>>(
() => `/api/users?q=${encodeURIComponent(this.search.value())}`,
{ defaultValue: { items: [], total: 0 } },
);
}∅∅- Ana Popescu · Cluj-Napoca
- Mihai Ionescu · București
- Ioana Dumitru · Iași
- Radu Stan · Timișoara
- Elena Marin · Brașov
- Andrei Georgescu · Cluj-Napoca
- Maria Constantin · Constanța
- Vlad Petrescu · Sibiu
- Cristina Pop · Oradea
- Bogdan Rusu · Iași
Debounce, throttle, or neither
Both reduce the number of requests, in different ways. Debounce waits until the user pauses and sends only the last value. Throttle sends at most once every X milliseconds while the events keep coming, so the screen keeps updating during a long burst.
Each dot is a request; the number is how many there were. Hover a dot to see the value it carried.
For search as you type, debounce is almost always right: nobody needs results for “ang” when they are about to type “angular”. Throttle fits continuous signals where the latest state matters during the movement: scroll position, a slider preview, resizing, a cursor shared in real time. The lab's debounce lane uses the real debounced(), with a wait function that reads the slider, so a new wait applies from the next keystroke.
import { debounced } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { fromEvent, map, throttleTime } from 'rxjs';
// debounce: wait for a pause, then the latest value (search as you type)
readonly term = debounced(this.query, 300);
// throttle: at most once per window, first and last value included (scroll, resize)
readonly scrollY = toSignal(
fromEvent(window, 'scroll').pipe(
throttleTime(100, undefined, { leading: true, trailing: true }),
map(() => window.scrollY),
),
{ initialValue: 0 },
);API
The result isn’t a plain Signal but a Resource: you get value(), and also isLoading() — useful for showing “I’m waiting for you to finish typing”.
const d = debounced(source, wait, options?);
// source: any reactive function — a signal, a computed, an input()
// wait: a number of ms OR (value, lastSnapshot) => Promise<void> | void
// options: { injector?, equal? }
// result: Resource<T> → d.value(), d.status(), d.isLoading()
// Example: no delay when the field is cleared, 400 ms otherwise
const term = debounced(this.query, (value) =>
value === '' ? undefined : new Promise<void>((resolve) => setTimeout(resolve, 400)),
);Before and now
// Now (v22, experimental)
readonly query = signal('');
readonly debouncedQuery = debounced(this.query, 300); // .value()// Before (RxJS): three conversions for a single debounce
readonly query = signal('');
readonly debouncedQuery = toSignal(
toObservable(this.query).pipe(debounceTime(300), distinctUntilChanged()),
{ initialValue: '' },
);The RxJS version isn’t wrong — if you already have an RxJS stream (with switchMap, retry, etc.), stay with it. But for “I just want a delayed value”, debounced is much simpler.
In forms: debounce() from Signal Forms
For fields of a signal form you don’t need debounced(): there is the debounce() rule in the schema, and async validators also have a debounce option (v22). See Signal Forms — advanced.
// Signal Forms has a dedicated debounce() rule for fields:
import { debounce, form, validateHttp } from '@angular/forms/signals';
readonly signup = form(this.model, (path) => {
debounce(path.username, 300); // the model updates 300 ms after typing
validateHttp(path.username, {
request: ({ value }) => `/api/username-available?username=${value()}`,
onSuccess: (res: { available: boolean }) =>
res.available ? undefined : { kind: 'taken', message: 'Username taken' },
onError: () => ({ kind: 'network', message: 'Could not verify' }),
});
});When YES, when NO
- Search / autocomplete.
- Filters that trigger requests (a price slider, a numeric input).
- Autosave after the user stops typing.
- Expensive calculations derived from text input.
- Signal form fields → the
debounce()rule. - Throttle (at most once every X ms, e.g. scroll) → RxJS
throttleTimeorrequestAnimationFrame. - Streams with multiple operators → stay with RxJS.