Mert Özen Further With Every Line
Frontend RxJS — #06

Time-Based Operators and Schedulers: debounceTime, throttleTime, auditTime, timeout and Polling

Mert Özen Nov 14, 2026 22 min 12 views
Time-Based Operators and Schedulers: debounceTime, throttleTime, auditTime, timeout and Polling

debounceTime emits the last value once the source goes quiet, throttleTime the first value of a window, auditTime the latest value at the end of a window; for polling, use exhaustMap or repeat({ delay }) instead of mergeMap so requests never overlap. In Bookstore we autosave a review draft, put timeout in the right place, poll an order's status only while the page is visible, and measure where the schedulers run in the event loop.

Version note: The examples in this post were run with RxJS 7.8.2, TypeScript 6.0, Node.js 24 and Vite 8.3; timed examples run in virtual time on RxJS's TestScheduler, browser examples in headless Chromium driven by Playwright 1.65.

Predict First

Bookstore, the online bookstore used as the shared example across this site's series, lets customers write reviews. Ada is writing one for Dune, and the draft should be saved automatically, but not on every keystroke. We put debounceTime(500) in front of the save:

import { concat, debounceTime, NEVER } from "rxjs";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";

runInVirtualTime("review draft autosave", () => {
  // Ada writes a review of Dune: three quick edits, a pause, two more edits
  const draft$ = concat(
    emitAt(
      [0, "Great"],
      [200, "Great book"],
      [400, "Great book, slow start"],
      [1500, "Great book, slow start but"],
      [1700, "Great book, slow start but worth it"],
    ),
    NEVER, // the text box stays open: the stream never completes
  );

  draft$.pipe(debounceTime(500)).subscribe((text) => log(`save: "${text}"`));
});

How many saves do you see, at which milliseconds, and with which text? Is "Great" ever saved?

In the previous post the timing came from outside: when the user clicked, how long the API took. In this post we control time ourselves. We compare debounceTime, throttleTime, auditTime, sampleTime, delay and bufferTime on the same input, use timeout in the right place, write a polling loop that stops while the page is hidden, and look at what RxJS's schedulers actually do in the event loop. Time-based examples run in virtual time with the runInVirtualTime, emitAt and log helpers from the earlier posts, so every run prints the same milliseconds; the browser examples run against the mock Bookstore API in the snapshot.

Six Time Operators on the Same Input

The fastest way to see the difference is to feed every operator the same marble. The input is a quick burst (A, B, C), a pause, and then a value every 3 ms, a bit like a user who types fast, stops, and then types steadily:

import { asyncScheduler, auditTime, bufferTime, debounceTime, delay, sampleTime, throttleTime } from "rxjs";
import { showMarbles } from "../../tools/marble.ts";

// one character = 1 ms of virtual time: a quick burst (A B C), a pause, then a value every 3 ms
const keys = "ABC------D--E--F--G---|";

showMarbles("debounceTime(4)", (cold) => cold(keys).pipe(debounceTime(4)), 24);
showMarbles("throttleTime(4)", (cold) => cold(keys).pipe(throttleTime(4)), 24);
showMarbles("throttleTime(4) trailing", (cold) => cold(keys).pipe(throttleTime(4, asyncScheduler, { leading: false, trailing: true })), 24);
showMarbles("auditTime(4)", (cold) => cold(keys).pipe(auditTime(4)), 24);
showMarbles("sampleTime(5)", (cold) => cold(keys).pipe(sampleTime(5)), 24);
showMarbles("delay(2)", (cold) => cold(keys).pipe(delay(2)), 24);
showMarbles("bufferTime(5)", (cold) => cold(keys).pipe(bufferTime(5)), 24);
debounceTime(4)
  in : ABC------D--E--F--G---| 
  out: ------C---------------(G|) 
throttleTime(4)
  in : ABC------D--E--F--G---| 
  out: A--------D-----F------| 
throttleTime(4) trailing
  in : ABC------D--E--F--G---| 
  out: ----C--------E---F---G| 
auditTime(4)
  in : ABC------D--E--F--G---| 
  out: ----C--------E-----G--| 
sampleTime(5)
  in : ABC------D--E--F--G---| 
  out: -----C----D----F----G-| 
delay(2)
  in : ABC------D--E--F--G---| 
  out: --ABC------D--E--F--G-| 
bufferTime(5)
  in : ABC------D--E--F--G---| 
  out: -----a----b----c----d-(e|) a=[ 'A', 'B', 'C' ] b=[ 'D' ] c=[ 'E', 'F' ] d=[ 'G' ] e=[]

Read the output line by line, because each operator answers a different question:

OperatorWhat it emitsTypical use
debounceTimeThe last value, once the source has been silent for the given timeSearch box, autosave, resize end
throttleTimeThe first value, then ignores everything for the given timeButton that must not fire twice in a row, analytics events
throttleTime with trailingThe latest value at the end of each window; a new window starts right awayContinuous progress reporting
auditTimeThe latest value, a fixed time after the first value that opened the windowScroll and resize handlers, UI updates
sampleTimeThe latest value at fixed clock ticks, if a new one arrivedDashboards that refresh at a fixed rate
bufferTimeAn array of everything that arrived in each period, even an empty oneBatching log or analytics events

Three details in the marbles are worth a second look. debounceTime emitted nothing during the second burst: values came every 3 ms, the window was 4 ms, so the source was never quiet long enough. Only the completion at frame 22 released G, together with the |. A user who types steadily never triggers a debounced search; that is the intended behaviour, but it is also why a debounced stream that never completes can hold its last value forever. throttleTime with trailing and auditTime look alike but are not: after emitting E at frame 13, throttle opened a new window immediately and emitted F at 17, while audit waited for the next value (F at 15) to open a window and then emitted the latest one, G, at 19. And bufferTime emitted an empty array at the end even though nothing arrived; if you send each buffer to the server, filter out the empty ones.

Autosave with debounceTime and the Edit That Gets Lost

Now the answer to the opening question:

--- review draft autosave
  900 ms  save: "Great book, slow start"
 2200 ms  save: "Great book, slow start but worth it"

Two saves. "Great" and "Great book" never reached the server, because a new edit always arrived within 500 ms; at 900 ms the text had been quiet for 500 ms and the latest version was saved. The same happened after the second burst at 2200 ms. That is exactly what a draft needs: fewer requests, and the last version always wins.

A real autosave needs two more things. If Ada types a character and deletes it, the text is the same as the last save, so distinctUntilChanged should skip it. And the save itself is a write: as we saw in the higher-order mapping post, switchMap could cancel a PUT that is already on its way, so saves go through concatMap, one after another. The more interesting question is what happens when Ada leaves the page 300 ms after her last edit, while that edit is still waiting inside debounceTime:

import { concat, concatMap, debounceTime, distinctUntilChanged, NEVER, takeUntil } from "rxjs";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeRequest } from "../04/fake-api.ts";

function saveDraft(text: string) {
  return fakeRequest(`PUT /reviews/dune/draft (${text.length} chars)`, 300, () => text);
}

// what Ada types into the review box (the box stays open, so the stream never completes)
function typing() {
  return concat(
    emitAt(
      [0, "Great book"],
      [300, "Great book, slow start"],
      [1400, "Great book, slow start"], // a typo typed and deleted again: same text
      [2600, "Great book, slow start but worth it"],
    ),
    NEVER,
  );
}

const report = { next: (text: string) => log(`saved: "${text}"`), complete: () => log("autosave stopped") };

runInVirtualTime("takeUntil after debounceTime", () => {
  const leave$ = emitAt([2900, "leave"]); // Ada clicks "Back to catalog" 300 ms after her last edit
  typing()
    .pipe(
      debounceTime(800),
      distinctUntilChanged(),
      concatMap((text) => saveDraft(text)),
      takeUntil(leave$), // leaving drops the edit still waiting in debounceTime
    )
    .subscribe(report);
});

runInVirtualTime("takeUntil before debounceTime", () => {
  const leave$ = emitAt([2900, "leave"]);
  typing()
    .pipe(
      takeUntil(leave$), // the source completes, so debounceTime emits the waiting edit at once
      debounceTime(800),
      distinctUntilChanged(),
      concatMap((text) => saveDraft(text)),
    )
    .subscribe(report);
});
--- takeUntil after debounceTime
 1100 ms  -> PUT /reviews/dune/draft (22 chars)
 1400 ms  <- PUT /reviews/dune/draft (22 chars)
 1400 ms  saved: "Great book, slow start"
 2900 ms  autosave stopped
--- takeUntil before debounceTime
 1100 ms  -> PUT /reviews/dune/draft (22 chars)
 1400 ms  <- PUT /reviews/dune/draft (22 chars)
 1400 ms  saved: "Great book, slow start"
 2900 ms  -> PUT /reviews/dune/draft (35 chars)
 3200 ms  <- PUT /reviews/dune/draft (35 chars)
 3200 ms  saved: "Great book, slow start but worth it"
 3200 ms  autosave stopped

In the first version the last sentence, "but worth it", is lost: takeUntil at the end unsubscribed from everything, including the value that debounceTime was holding. In the second version the same operator sits before debounceTime. Now leaving the page completes the source, and debounceTime sends its pending value downstream as soon as the source completes, without waiting for the rest of the 800 ms. The save goes out at 2900 ms and the stream completes only after the server answers.

The rule behind it is easy to remember: unsubscribing drops a pending value, completing flushes it. In an Angular component this matters because destroying the component unsubscribes; if the last draft must be saved, either complete the source on a leave event as above, or move the save into a service that outlives the component.

timeout: first, each, with and the Wrong Place

RxJS 7's timeout takes a config object. first limits the wait for the first value, each the gap between any two values, and with decides what happens on a timeout: without it you get a TimeoutError, with it the stream switches to the Observable you return.

import { concat, debounceTime, NEVER, of, switchMap, timeout } from "rxjs";
import { books } from "../../data/books.ts";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeRequest } from "../04/fake-api.ts";

const dune = books[0];

runInVirtualTime("timeout first + with: fall back to the cached stock", () => {
  const cachedStock = { isbn: dune.isbn, stock: 12, cached: true };
  fakeRequest(`GET /books/${dune.isbn}/stock`, 3000, () => ({ isbn: dune.isbn, stock: 11, cached: false }))
    .pipe(timeout({ first: 1000, with: () => of(cachedStock) }))
    .subscribe((s) => log(`stock ${s.stock} (cached: ${s.cached})`));
});

runInVirtualTime("timeout each: a live stream that goes silent", () => {
  // stock updates arrive every 2 s, then the warehouse feed stops after 6 s
  const stock$ = concat(emitAt([2000, 12], [4000, 11], [6000, 10]), NEVER);
  stock$.pipe(timeout({ each: 5000 })).subscribe({
    next: (n) => log(`stock ${n}`),
    error: (err: Error) => log(`${err.name}: ${err.message}`),
  });
});

function searchBooks(term: string) {
  return fakeRequest(`GET /books?q=${term}`, 300, () => books.filter((b) => b.title.toLowerCase().includes(term)).length);
}

runInVirtualTime("timeout outside switchMap: measures the user, not the request", () => {
  const terms$ = concat(emitAt([0, "dun"], [400, "dune"]), NEVER); // the user stops typing, the box stays open
  terms$
    .pipe(
      debounceTime(300),
      switchMap((term) => searchBooks(term)),
      timeout(2000), // wrong place
    )
    .subscribe({ next: (n) => log(`${n} result(s)`), error: (err: Error) => log(`search box dead: ${err.name}`) });
});

runInVirtualTime("timeout inside switchMap: measures each request", () => {
  const terms$ = concat(emitAt([0, "dun"], [400, "dune"]), NEVER);
  terms$
    .pipe(
      debounceTime(300),
      switchMap((term) => searchBooks(term).pipe(timeout(2000))),
    )
    .subscribe({ next: (n) => log(`${n} result(s)`), error: (err: Error) => log(`search box dead: ${err.name}`) });
});
--- timeout first + with: fall back to the cached stock
    0 ms  -> GET /books/9780441172719/stock
 1000 ms  x  GET /books/9780441172719/stock cancelled
 1000 ms  stock 12 (cached: true)
--- timeout each: a live stream that goes silent
 2000 ms  stock 12
 4000 ms  stock 11
 6000 ms  stock 10
11000 ms  TimeoutError: Timeout has occurred
--- timeout outside switchMap: measures the user, not the request
  300 ms  -> GET /books?q=dun
  600 ms  <- GET /books?q=dun
  600 ms  1 result(s)
  700 ms  -> GET /books?q=dune
 1000 ms  <- GET /books?q=dune
 1000 ms  1 result(s)
 3000 ms  search box dead: TimeoutError
--- timeout inside switchMap: measures each request
  300 ms  -> GET /books?q=dun
  600 ms  <- GET /books?q=dun
  600 ms  1 result(s)
  700 ms  -> GET /books?q=dune
 1000 ms  <- GET /books?q=dune
 1000 ms  1 result(s)

The first scenario is a stock query on the book detail page. The warehouse service is slow today (3 seconds); after one second we cancel the request and show the stock from the last successful answer, marked as cached so the screen can say "may be out of date". The x line shows that timeout really unsubscribed from the request; with HttpClient that means the browser aborts it. The second scenario uses each for a live stream: if the warehouse feed is silent for five seconds after the last update at 6000 ms, the stream fails at 11000 ms and the error handler can reconnect or show a warning.

The last two scenarios show the most common mistake with timeout. Both use the same search box, and the requests take 300 ms. In the first one timeout(2000) is outside switchMap, on the outer stream. It does not measure the request; it measures the time between two search results. The user stopped typing after "dune", so two seconds after the last result the outer stream timed out, and the search box died: from now on typing does nothing. Moved inside switchMap, the same timeout measures each request separately, and the box keeps working however long the user waits. Put timeout next to the thing you want to measure; for HTTP that is the inner Observable.

Polling: timer, switchMap and the Overlapping Request

On the My Orders page, the status of order 1042 should update by itself: Paid, Shipped, Delivered. The textbook solution is timer(0, 5000) plus a flattening operator. Which one you choose decides what happens when a request takes longer than the polling period. In the next example the second call takes 6.5 seconds and the others 300 ms; statusApi in the snapshot (src/06/status-api.ts) answers Paid, Paid, Shipped, Shipped, Delivered in that order:

import { defer, exhaustMap, mergeMap, repeat, switchMap, takeWhile, timer, type Observable } from "rxjs";
import { log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { statusApi, type Status } from "./status-api.ts";

function poll(title: string, build: (getStatus: () => Observable<Status>) => Observable<Status>) {
  runInVirtualTime(title, () => {
    // the second call is slow (6.5 s), the others take 300 ms
    build(statusApi([300, 6500]))
      .pipe(takeWhile((status) => status !== "Delivered", true)) // stop after Delivered
      .subscribe((status) => log(`screen: ${status}`));
  });
}

poll("timer + mergeMap", (getStatus) => timer(0, 5000).pipe(mergeMap(() => getStatus())));
poll("timer + switchMap", (getStatus) => timer(0, 5000).pipe(switchMap(() => getStatus())));
poll("timer + exhaustMap", (getStatus) => timer(0, 5000).pipe(exhaustMap(() => getStatus())));
poll("defer + repeat({ delay })", (getStatus) => defer(() => getStatus()).pipe(repeat({ delay: 5000 })));
--- timer + mergeMap
    0 ms  -> GET /orders/1042/status #1
  300 ms  <- GET /orders/1042/status #1
  300 ms  screen: Paid
 5000 ms  -> GET /orders/1042/status #2
10000 ms  -> GET /orders/1042/status #3
10300 ms  <- GET /orders/1042/status #3
10300 ms  screen: Shipped
11500 ms  <- GET /orders/1042/status #2
11500 ms  screen: Paid
15000 ms  -> GET /orders/1042/status #4
15300 ms  <- GET /orders/1042/status #4
15300 ms  screen: Shipped
20000 ms  -> GET /orders/1042/status #5
20300 ms  <- GET /orders/1042/status #5
20300 ms  screen: Delivered
--- timer + switchMap
    0 ms  -> GET /orders/1042/status #1
  300 ms  <- GET /orders/1042/status #1
  300 ms  screen: Paid
 5000 ms  -> GET /orders/1042/status #2
10000 ms  x  GET /orders/1042/status #2 cancelled
10000 ms  -> GET /orders/1042/status #3
10300 ms  <- GET /orders/1042/status #3
10300 ms  screen: Shipped
15000 ms  -> GET /orders/1042/status #4
15300 ms  <- GET /orders/1042/status #4
15300 ms  screen: Shipped
20000 ms  -> GET /orders/1042/status #5
20300 ms  <- GET /orders/1042/status #5
20300 ms  screen: Delivered
--- timer + exhaustMap
    0 ms  -> GET /orders/1042/status #1
  300 ms  <- GET /orders/1042/status #1
  300 ms  screen: Paid
 5000 ms  -> GET /orders/1042/status #2
11500 ms  <- GET /orders/1042/status #2
11500 ms  screen: Paid
15000 ms  -> GET /orders/1042/status #3
15300 ms  <- GET /orders/1042/status #3
15300 ms  screen: Shipped
20000 ms  -> GET /orders/1042/status #4
20300 ms  <- GET /orders/1042/status #4
20300 ms  screen: Shipped
25000 ms  -> GET /orders/1042/status #5
25300 ms  <- GET /orders/1042/status #5
25300 ms  screen: Delivered
--- defer + repeat({ delay })
    0 ms  -> GET /orders/1042/status #1
  300 ms  <- GET /orders/1042/status #1
  300 ms  screen: Paid
 5300 ms  -> GET /orders/1042/status #2
11800 ms  <- GET /orders/1042/status #2
11800 ms  screen: Paid
16800 ms  -> GET /orders/1042/status #3
17100 ms  <- GET /orders/1042/status #3
17100 ms  screen: Shipped
22100 ms  -> GET /orders/1042/status #4
22400 ms  <- GET /orders/1042/status #4
22400 ms  screen: Shipped
27400 ms  -> GET /orders/1042/status #5
27700 ms  <- GET /orders/1042/status #5
27700 ms  screen: Delivered

With mergeMap, the slow second request was still running when the third one started, and its late answer arrived after the third: at 10300 ms the screen said Shipped, at 11500 ms it went back to Paid. The customer sees the order go backwards. This is the most common polling bug, and it does not show up on a fast local API; it shows up on a slow mobile connection.

switchMap cancelled the slow request when the next tick came, so the order of the screen is right. The price is that if the server is always slower than the period, every request is cancelled and the screen never updates. exhaustMap ignored the tick at 10000 ms because a request was still running, so there is never more than one request in flight. defer plus repeat({ delay: 5000 }) goes one step further: it waits five seconds after each answer, not five seconds after each start, so the gap between requests stays the same however slow the server is. For status polling, exhaustMap or repeat({ delay }) is the safer default; takeWhile(…, true) stops polling after the final status and, thanks to the true, still lets "Delivered" through.

Stopping the Polling While the Page Is Hidden

A customer who opens the order page in a tab and forgets about it should not send a request every five seconds all day. The browser fires visibilitychange on document when the tab is hidden or shown, and document.visibilityState tells you which one. That becomes a stream of booleans; switchMap starts the polling for true and returns EMPTY for false, which unsubscribes from the running timer. First in virtual time:

import { distinctUntilChanged, EMPTY, exhaustMap, switchMap, takeWhile, tap, timer } from "rxjs";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { statusApi } from "./status-api.ts";

runInVirtualTime("poll only while the page is visible", () => {
  const getStatus = statusApi();
  // in the browser: fromEvent(document, "visibilitychange") mapped to document.visibilityState === "visible"
  const visible$ = emitAt([0, true], [7000, false], [19000, true]);

  visible$
    .pipe(
      distinctUntilChanged(),
      tap((visible) => log(visible ? "page visible: polling starts" : "page hidden: polling stops")),
      switchMap((visible) => (visible ? timer(0, 5000).pipe(exhaustMap(() => getStatus())) : EMPTY)),
      takeWhile((status) => status !== "Delivered", true),
    )
    .subscribe((status) => log(`screen: ${status}`));
});
--- poll only while the page is visible
    0 ms  page visible: polling starts
    0 ms  -> GET /orders/1042/status #1
  300 ms  <- GET /orders/1042/status #1
  300 ms  screen: Paid
 5000 ms  -> GET /orders/1042/status #2
 5300 ms  <- GET /orders/1042/status #2
 5300 ms  screen: Paid
 7000 ms  page hidden: polling stops
19000 ms  page visible: polling starts
19000 ms  -> GET /orders/1042/status #3
19300 ms  <- GET /orders/1042/status #3
19300 ms  screen: Shipped
24000 ms  -> GET /orders/1042/status #4
24300 ms  <- GET /orders/1042/status #4
24300 ms  screen: Shipped
29000 ms  -> GET /orders/1042/status #5
29300 ms  <- GET /orders/1042/status #5
29300 ms  screen: Delivered

No request went out between 7 and 19 seconds. When the page became visible again, polling started from scratch with an immediate request, so the customer sees the current status at once instead of waiting up to five seconds.

The same flow in the browser, against the mock API (npm run web and node api/server.ts in the snapshot; the page is web/order-status.html). The mock API answers the status endpoint of order 1042 with Paid three times, then Shipped twice, then Delivered:

import { distinctUntilChanged, EMPTY, exhaustMap, fromEvent, map, startWith, switchMap, takeWhile, timer } from "rxjs";
import { fromFetch } from "rxjs/fetch";

type OrderStatus = { id: number; status: "Paid" | "Shipped" | "Delivered" };

const statusText = document.querySelector<HTMLParagraphElement>("#status")!;

const getStatus = () =>
  fromFetch("http://localhost:5180/api/orders/1042/status", {
    selector: (response) => response.json() as Promise<OrderStatus>,
  });

const visible$ = fromEvent(document, "visibilitychange").pipe(
  map(() => document.visibilityState === "visible"),
  startWith(document.visibilityState === "visible"),
  distinctUntilChanged(),
);

visible$
  .pipe(
    switchMap((visible) => (visible ? timer(0, 5000).pipe(exhaustMap(() => getStatus())) : EMPTY)),
    takeWhile((order) => order.status !== "Delivered", true),
  )
  .subscribe((order) => {
    statusText.textContent = `Order ${order.id}: ${order.status}`;
    console.log(statusText.textContent);
  });

We ran the page in headless Chromium with Playwright. Headless Chromium never hides a page by itself, so the test script overrode document.visibilityState at 7 seconds and 19 seconds and fired a real visibilitychange event; the requests and console lines are what the page did:

  0.0 s request /api/orders/1042/status
  0.2 s page: Order 1042: Paid
  5.0 s request /api/orders/1042/status
  5.2 s page: Order 1042: Paid
  7.0 s visibility: hidden
 19.0 s visibility: visible
 19.0 s request /api/orders/1042/status
 19.2 s page: Order 1042: Paid
 24.0 s request /api/orders/1042/status
 24.2 s page: Order 1042: Shipped
 29.0 s request /api/orders/1042/status
 29.2 s page: Order 1042: Shipped
 31.0 s final text: Order 1042: Shipped
[api] listening on http://localhost:5180
[api] #1 GET /api/orders/1042/status
[api] #1 200 Paid
[api] #2 GET /api/orders/1042/status
[api] #2 200 Paid
[api] #3 GET /api/orders/1042/status
[api] #3 200 Paid
[api] #4 GET /api/orders/1042/status
[api] #4 200 Shipped
[api] #5 GET /api/orders/1042/status
[api] #5 200 Shipped

Five requests in 31 seconds instead of seven, and nothing while the page was hidden. startWith matters here: without it the stream would wait for the first visibilitychange, and a page that opens visible would never poll. In Angular you would put the same pipe in a service and give the result to the template with toSignal; that part comes in the Angular post of this series.

Schedulers: When Is a Value Delivered?

Every time-based operator has a last parameter that is easy to miss: a scheduler. A scheduler decides when, and in which part of the event loop, work runs. The event loop itself is covered in the "JavaScript Event Loop Derinlemesine" post of the JavaScript İleri series; here we look at where RxJS's four schedulers land in it, in real time, without TestScheduler:

import { asapScheduler, asyncScheduler, observeOn, of, queueScheduler, subscribeOn } from "rxjs";

// real time, no TestScheduler: in which order does each line print?
console.log("1. script start");
setTimeout(() => console.log("setTimeout(0)"), 0);
Promise.resolve().then(() => console.log("Promise.then"));

of("asyncScheduler").pipe(observeOn(asyncScheduler)).subscribe((v) => console.log(`observeOn ${v}`));
of("asapScheduler").pipe(observeOn(asapScheduler)).subscribe((v) => console.log(`observeOn ${v}`));
of("queueScheduler").pipe(observeOn(queueScheduler)).subscribe((v) => console.log(`observeOn ${v}`));
of("no scheduler").subscribe((v) => console.log(v));
of("subscribeOn asyncScheduler").pipe(subscribeOn(asyncScheduler)).subscribe((v) => console.log(v));

console.log("2. script end");
1. script start
observeOn queueScheduler
no scheduler
2. script end
Promise.then
observeOn asapScheduler
setTimeout(0)
observeOn asyncScheduler
subscribeOn asyncScheduler

queueScheduler and no scheduler at all both ran synchronously, before "script end". queueScheduler differs only when work schedules more work from inside itself: it queues the nested work instead of running it recursively, which keeps the call stack flat. asapScheduler ran as a microtask, right after the Promise.then that was queued before it and before any timer; in RxJS 7.8.2 it is built on Promise.resolve().then. asyncScheduler is a timer (in RxJS 7.8.2 it uses setInterval internally), so it ran after setTimeout(0). This is also the default scheduler of debounceTime, throttleTime, timer, interval and delay, which is why TestScheduler can replace it with virtual time.

observeOn and subscribeOn move different things. observeOn(scheduler) delivers each notification (next, error, complete) from that point on through the scheduler. subscribeOn(scheduler) delays the subscription itself, so the source starts producing later; the last line printed after the timer because the whole of started on asyncScheduler. In application code you rarely need either; their main use is moving heavy synchronous work out of the current task or aligning UI work with frames, which brings us to the fourth scheduler.

animationFrameScheduler: One Update per Frame

animationFrameScheduler runs work with requestAnimationFrame, just before the browser paints. The classic use is a scroll handler: on Bookstore's long catalog page, a progress bar and a "Back to top" button follow the scroll position. auditTime(0, animationFrameScheduler) means "after an event, wait until the next frame, then take the latest value":

fromEvent(window, "scroll", { passive: true })
  .pipe(
    tap(() => scrollEvents++),
    auditTime(0, animationFrameScheduler), // at most one update per frame, with the latest position
    map(() => window.scrollY),
  )
  .subscribe((y) => {
    scrollUpdates++;
    progress.value = y / (document.documentElement.scrollHeight - window.innerHeight);
    toTop.hidden = y < 600;
    scrollStats.textContent = `scroll events: ${scrollEvents}, screen updates: ${scrollUpdates}`;
  });

The page (web/catalog-scroll.html) counts both the scroll events and the screen updates. We drove it with Playwright: 30 mouse wheel steps, then 200 scrollBy calls in a single task, then a click on "Back to top":

30 mouse wheel steps     -> scroll events: 28, screen updates: 28 | Back to top visible: true
200 scrollBy in one task -> scroll events: 29, screen updates: 29
click Back to top        -> scroll events: 30, screen updates: 30 | scrollY: 0 | Back to top visible: false
200 stock messages       -> stock messages: 200, screen updates: 1 | Dune stock: 12
Chromium 158.0.8127.18

The first three lines contain a surprise: scroll events and screen updates are equal, and 200 scrollBy calls produced a single scroll event. The browser already fires scroll at most once per frame, as part of its rendering steps, so in Chromium auditTime(0, animationFrameScheduler) does not reduce the number of scroll updates here. What it still gives you is the timing: the DOM write happens in the frame callback, right before paint, and if the handler ever runs for a source that is not tied to frames, the pipe stays correct.

The last line shows such a source. Stock messages from a WebSocket can arrive many times within one frame; the page simulates 200 of them in one task:

fromEvent(stockFeed, "stock")
  .pipe(
    tap(() => stockMessages++),
    map((event) => (event as CustomEvent<number>).detail),
    auditTime(0, animationFrameScheduler),
  )
  .subscribe((count) => {
    stockUpdates++;
    stock.textContent = `Dune stock: ${count}`;
    stockStats.textContent = `stock messages: ${stockMessages}, screen updates: ${stockUpdates}`;
  });

fromEvent(document.querySelector("#simulate")!, "click").subscribe(() => {
  for (let i = 1; i <= 200; i++) stockFeed.dispatchEvent(new CustomEvent("stock", { detail: 212 - i }));
});

200 messages, one screen update, and the screen shows the last value (12). Updating the DOM 200 times would have produced the same picture, because the user only sees what is painted. The lesson for both cases: measure what your event source really does before throttling it, and use animationFrameScheduler where many values can arrive within one frame.

In Production

Choose time values from measurements, not habit. A debounce of 300 ms is a good start for a search box; for autosave, 800 ms to 2 seconds is more common, because each save is a write. For timeouts, look at the real latency distribution of the endpoint (for example the 99th percentile in your APM tool) and set the timeout a little above it; a timeout below normal latency turns a slow API into a broken one.

Polling is a load problem multiplied by the number of open tabs. Ten thousand customers with the order page open and a five-second period means two thousand requests per second for a page that rarely changes. Stopping while hidden, stopping after the final status, and a longer period for older orders cut most of that. If the status really has to be instant, server push is the better tool; the "Real-time Seçenekleri: Polling, Long Polling, Server-Sent Events, WebSocket ve SignalR — Hangisi Ne Zaman?" post of the Real-time Uygulamalar series compares the options.

Finally, browsers slow down timers in hidden tabs. According to the Chrome team, since Chrome 88 timers in a hidden page are checked once per second, and repeating timers in a page hidden for more than five minutes only once per minute. Your interval is not guaranteed to fire on time in a hidden tab, which is one more reason to stop polling explicitly instead of relying on the browser.

Common Pitfalls

  • Polling with mergeMap. A slow request overlaps the next one, answers arrive out of order and the status on the screen goes backwards. Use exhaustMap or repeat({ delay }).
  • Putting timeout on the outer stream of a search box. It measures the user's pause, not the request, and kills the box; put it inside switchMap.
  • Ending an autosave with takeUntil after debounceTime. The pending edit is dropped; complete the source instead, or save from a service that outlives the component.
  • Using throttleTime when you need the final value. Its default is { leading: true, trailing: false }, so the last value of a burst is lost; use auditTime or debounceTime.
  • Sending every bufferTime result to the server. Empty periods produce empty arrays; filter them out.
  • Expecting auditTime(0, animationFrameScheduler) to reduce scroll events. The browser already delivers them once per frame; the gain is for sources that are not tied to frames.

A Small Experiment

★☆☆ In 01-predict.ts, replace debounceTime(500) with auditTime(500) and then with throttleTime(500). Predict the saved texts before you run it. Which one would you never use for autosave, and why?

★★☆ Add a "Save now" button to 03-autosave.ts: a click saves the current text immediately, and the pending debounced save for the same text must not run again afterwards. Hint: merge the click stream with the debounced stream before distinctUntilChanged.

★★★ Change web/order-status.ts so that the polling period grows when the status does not change (5, 10, 20 seconds, at most 60) and resets to 5 seconds after a change. Use repeat({ delay: (count) => timer(…) }) or expand, and check the server log to see that the gaps really grow.

In this post we made a stream wait, skip and repeat. In the next post we produce values ourselves: Subject, BehaviorSubject, ReplaySubject and AsyncSubject, what each gives a late subscriber, why a Subject exposed as a public field becomes a problem, and why in Angular 22 state belongs in a signal while events can stay in a Subject.