skip to content

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?

level: seniorimportance: should knowfreq 34%

answer

  1. completion versus unsubscription
  2. a held value
  3. completion flushes, teardown discards
  4. stop the source upstream

basics

~10 s

debounceTime 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 lines
ts
import { 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 once

go deeper

for a junior

Recall that debounceTime holds the latest value until a quiet period passes, so it can still be holding one when the stream ends.

for a middle

Explain that completion flushes the held value immediately while unsubscription and errors drop it.

for a senior

Design the close path so completion reaches debounceTime from upstream and the final save is allowed to finish, and test it.

for a principal

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.