In RxJS, why can an autosave built on debounceTime(1000) lose the user's last edit when the editor closes, and how do you flush it?
answer
- completion versus unsubscription
- a held value
- completion flushes, teardown discards
- stop the source upstream
basics
~10 sdebounceTime holds the latest edit until its quiet period ends; unsubscribing first discards it. Completing the source instead makes debounceTime emit the held value immediately, so end the stream upstream rather than unsubscribing.
solid answer
~40 s`debounceTime` keeps the latest value and a scheduled timer. RxJS 7.8 treats the two ways a stream can end differently: when the **source completes**, it emits the pending value at once and then completes; when the **subscriber unsubscribes**, the timer is cancelled and the pending value is thrown away. Closing an editor usually unsubscribes, so an edit made less than a second before closing is never saved. The fix is to end the stream by **completion upstream of `debounceTime`**: complete the `Subject` that carries edits, or place the stop notifier before the operator, as in `edits$.pipe(takeUntil(close$), debounceTime(1000), concatMap(save))`. Completion then flushes the last edit, the save runs, and the chain completes on its own once that save finishes.
code
ts · 18 linesimport { Subject, Observable, takeUntil, debounceTime, concatMap } from 'rxjs';
declare function save(draft: string): Observable<void>;
const edits = new Subject<string>();
const close$ = new Subject<void>();
edits
.pipe(
takeUntil(close$), // completes the upstream on close
debounceTime(1000), // completion flushes the held draft
concatMap(draft => save(draft))
)
.subscribe({ complete: () => console.log('all edits saved') });
edits.next('Hello');
edits.next('Hello, world');
close$.next(); // 'Hello, world' is saved at oncego deeper
Recall that debounceTime holds the latest value until a quiet period passes, so it can still be holding one when the stream ends.
Explain that completion flushes the held value immediately while unsubscription and errors drop it.
Design the close path so completion reaches debounceTime from upstream and the final save is allowed to finish, and test it.
Decide how much unsaved work the product may risk, and set a pattern for closing editors that does not depend on luck.
## What debounceTime is holding `debounceTime(dueTime)` is stateful. Between a value's arrival and the end of the quiet period it holds: - **`lastValue`**, the most recent value; - an **active scheduled task** that will emit it once `dueTime` passes with no newer value. For an autosave that means the latest draft is sitting inside the operator for up to a second after each keystroke. Whether it ever reaches the save depends on **how the stream ends**. ## Completion flushes, unsubscription discards The RxJS 7.8 source handles the two endings differently: | how the stream ends | what happens to the held value | then | |---|---|---| | source sends `complete` | emitted **immediately**, without waiting for the timer | output completes | | source sends `error` | dropped | the error is forwarded | | subscriber calls `unsubscribe()` | dropped, and the scheduled task is cancelled | nothing further is sent | Cancelling the task on unsubscribe was made explicit in RxJS 7.2.0 ("unschedule dangling task on unsubscribe before complete"), so no stray timer fires later. In a UI, closing a dialog or navigating away usually **unsubscribes**: the view is destroyed and its subscriptions are torn down. That is the third row: the last edit is gone. ## Flushing on close The principle: make the end of the stream a **completion that reaches `debounceTime` from upstream**, and keep the subscription alive long enough for the save. 1. **Complete the edit source.** If edits come through a `Subject`, call `edits.complete()` when the editor closes. `debounceTime` emits the pending draft, the save runs, and the chain completes by itself. 2. **Place the stop signal before `debounceTime`.** `edits$.pipe(takeUntil(close$), debounceTime(1000), concatMap(draft => save(draft)))`: when `close$` emits, `takeUntil` completes the upstream, `debounceTime` flushes, and `concatMap` finishes the save before completing. 3. **Do not also unsubscribe from outside** at the same moment, or the in-flight save is cancelled. Placement is the whole trick. The same notifier placed **after** `debounceTime` completes the output from below: the operators above it are unsubscribed, not completed, and the pending draft is discarded. Elsewhere a stop notifier usually belongs last so nothing keeps running; here the point is the opposite — the final save *should* keep running until it finishes. ## Choosing the quiet period The debounce duration is also the **window of risk**: - a long debounce (several seconds) saves bandwidth but holds more unsaved edits; - a short one (a few hundred milliseconds) saves often and loses little; - the flush-on-complete pattern makes the choice mostly about request volume, because the last edit is no longer at risk on an orderly close. A hard browser exit is a different problem; no RxJS operator runs after the page is gone. ## Testing the close path The behaviour is deterministic and cheap to test: 1. Push an edit, then trigger the close signal before the debounce period ends. 2. Assert that `save` was called once with the latest draft. 3. Assert that the stream completed only after the save completed. Run it with virtual time so the test does not wait a real second; without the flush the save assertion fails, which is exactly the regression the test exists to catch. ## Checklist for a reviewer - Does anything hold a value in time (`debounceTime`, `auditTime`, a trailing throttle) upstream of a side effect? - How does the stream end on close: completion or unsubscription? - Is the stop signal upstream of the timing operator, and is the save allowed to finish? - Is there a test that closes the editor within the debounce window and asserts the save happened?
- Why does edits$.pipe(debounceTime(1000), concatMap(save), takeUntil(close$)) lose the last edit?`takeUntil` completes its own output and unsubscribes everything above it. `debounceTime` is unsubscribed rather than completed, so its held draft is discarded, and any save in progress is cancelled.
- What does debounceTime do with the held value when the source errors?It drops it and forwards the error. Only a completion flushes the pending value; an error ends the stream without emitting it.
saying these in an interview costs you the question
- debounceTime emits its pending value when the subscriber unsubscribes.
- On completion debounceTime waits out the remaining quiet period first.
- A stop notifier after debounceTime flushes the pending value.
- A shorter debounce removes the risk entirely without any flush.
- An error also flushes the held value before it is forwarded.