In RxJS, how does debounce() with a duration selector differ from debounceTime, and why does returning EMPTY from the selector hold the value?
answer
- a quiet period per value
- the selector returns an observable
- next ends the duration, complete does not
- merge a timer with a flush trigger
basics
~20 sdebounce() gets a duration observable per value from its selector and emits the value when that observable first emits. Since RxJS 7 completing does not end the duration, so EMPTY holds the value until a newer one or source completion.
solid answer
~40 s`debounceTime(ms)` uses one fixed quiet period. `debounce(value => duration$)` calls its selector for **every** value and subscribes to the returned `ObservableInput`; a newer value cancels that subscription and starts a new one. When the duration **emits a value**, the held source value is emitted. That enables per-value policies for autosave: save a title change after 300 ms but a body edit after 2 s, or `merge(timer(2000), flush$)` to save after the pause or at once when the user presses save. The trap: in RxJS 7 a duration that **completes without emitting** does not end the wait. `debounce(() => EMPTY)` therefore holds each value until a newer one replaces it or the source completes. Use `of(null)` or `timer(0)` for "no delay".
code
ts · 14 linesimport { Observable, Subject, debounce, timer, merge, of } from 'rxjs';
interface Edit { field: 'title' | 'body' | 'attachment'; value: string; }
declare const edits$: Observable<Edit>;
const flush$ = new Subject<void>();
const toSave$ = edits$.pipe(
debounce(edit =>
edit.field === 'attachment'
? of(null) // emits at once; EMPTY would hold the edit
: merge(timer(edit.field === 'title' ? 300 : 2000), flush$)
)
);go deeper
Recall that debounce() takes a function returning an observable, while debounceTime() takes a number of milliseconds.
Explain that the selector runs per value and that the duration ends only when it emits, not when it completes.
Use per-value durations and a flush trigger for autosave, and spot the EMPTY or NEVER selector that silently holds edits.
Decide when per-value timing policies are worth the added complexity over one fixed debounce shared across the product.
## From a number to an observable `debounceTime(dueTime)` applies the same quiet period to every value. `debounce(durationSelector)` generalises it: the selector receives each source value and returns an **`ObservableInput`** (an observable, a promise, an array…) whose first `next` marks the end of the quiet period. In RxJS 7.8 the operator works like this: 1. A value arrives; any previous duration subscription is unsubscribed. 2. The value becomes the held value, and the operator subscribes to `durationSelector(value)`. 3. When that duration **emits**, the held value is emitted and the duration subscription is closed. 4. When the source **completes**, a held value is emitted immediately and the output completes. `debounce(() => timer(1000))` behaves like `debounceTime(1000)`; `debounceTime` is implemented separately around a single rescheduled task, tuned for values arriving in quick succession. ## Per-value quiet periods for autosave Because the selector sees the value, the policy can depend on it: | edit | selector result | effect | |---|---|---| | title field | `timer(300)` | titles save quickly | | body field | `timer(2000)` | long text saves after a longer pause | | explicit save pressed | `merge(timer(2000), flush$)` | whichever comes first ends the wait | | attachment added | `of(null)` | emitted synchronously, no wait | The `merge(timer(2000), flush$)` pattern is the useful one: it keeps the debounce during normal typing, yet lets a save shortcut or a blur event flush the latest edit immediately, because `flush$` emitting ends the duration. ## The EMPTY trap A natural first attempt at "no delay for some values" is: ```ts debounce(edit => edit.urgent ? EMPTY : timer(1000)) ``` In RxJS 7 this is a bug. The release notes state that the duration observable **must emit a next notification to end the duration**; a complete notification no longer does. In the source, the duration subscriber's completion handler is a no-op. So for an urgent edit: - `EMPTY` completes at once, which the operator ignores; - the edit stays held; - it is emitted only if the **source completes** before another value arrives; a newer value simply replaces it and starts its own duration. The fix is a duration that emits: `of(null)` (synchronous) or `timer(0)` (next macrotask). The same RxJS 7 rule shapes `throttle`, `audit` and `sample`: their durations or notifiers must emit to trigger an emission; one that merely completes emits nothing. ## Comparison with the fixed-time operator - `debounceTime` — one duration, scheduler-driven, simplest and cheapest; right for most inputs. - `debounce` — a duration per value, can combine timers with user actions, and each value creates a subscription to a new duration observable. - Both flush on source completion and discard the held value on unsubscribe or error. ## Pitfalls - A selector that returns a **shared, already-emitted** stream (for example one built with a replaying subject) emits immediately on subscription and disables the debounce. - A selector returning `NEVER` holds values until a newer value or completion — occasionally intended, usually not. - Heavy work in the selector runs on every value; keep it to choosing and returning a duration. ## When to reach for it Most inputs need nothing more than `debounceTime`. `debounce` earns its place when the quiet period genuinely depends on the value, or when a user action must be able to cut the wait short. Interviewers use it to check whether a candidate understands that a *duration* in RxJS is an observable, and that only its first `next` counts.
- Why does debounce(() => merge(timer(2000), flush$)) save at once when flush$ emits?The duration ends on its first `next`. `merge` forwards whichever source emits first, so a `flush$` emission ends the wait before the timer does and the held edit is emitted immediately.
- Is debounce(() => timer(1000)) equivalent to debounceTime(1000)?In observable behaviour, yes: each value restarts a one-second quiet period and completion flushes the held value. `debounceTime` is implemented separately with a rescheduled task, so it avoids creating a timer observable per value.
saying these in an interview costs you the question
- Returning EMPTY from the debounce selector emits the value immediately.
- debounce() takes a number of milliseconds, just like debounceTime.
- A duration that completes ends the debounce the same as one that emits.
- The selector is called once, when the operator is subscribed.
- debounce() cannot combine a timer with a user action.