RxJS scan, reduce, debounceTime, debounce, delay and delayWhen Explained

Angular Tutorial: Beginner to Advanced · 10. HTTP and RxJS · 5 min read · Updated

Some operators remember the past (scan, reduce). Others control time (debounceTime, throttleTime, delay). Together they cover running totals, typing delays and timing effects.

What you will learn

  • scan versus reduce
  • debounceTime, debounce and throttleTime
  • delay and delayWhen

scan and reduce: accumulate values

Both take a function (accumulated, current) => newAccumulated and a starting value.

  • `scan` emits the running total after every value.
  • `reduce` emits once, the final total, when the stream completes.
// src/app/rxjs4/accumulate.ts
import { Observable, from, reduce, scan } from 'rxjs';

export const numbers$ = from([1, 2, 3, 4]);

export const runningTotal$ = numbers$.pipe(scan((total, n) => total + n, 0)); // 1, 3, 6, 10
export const finalTotal$ = numbers$.pipe(reduce((total, n) => total + n, 0)); // 10

// scan is the engine of "state over time": a tiny store
export interface CartEvent {
  type: 'add' | 'remove';
  item: string;
}

export function cart(events$: Observable<CartEvent>): Observable<string[]> {
  return events$.pipe(
    scan((items, event) => (event.type === 'add' ? [...items, event.item] : items.filter((i) => i !== event.item)), [] as string[]),
  );
}

Use scan for counters, history and shopping carts built from events. Use reduce when you only care about the result of a finite stream. Never use reduce on an endless stream, because it never emits.

debounceTime, debounce and throttleTime

All three tame fast streams, such as typing or scrolling.

  • `debounceTime(300)` waits until the stream has been quiet for 300 ms, then emits the last value. Perfect for search boxes.
  • `debounce(() => timer(300))` is the same idea, but the quiet period is decided by an Observable that can depend on the value.
  • `throttleTime(300)` emits the first value, then ignores everything for 300 ms. Perfect for scroll, resize and "do not spam the button".
// src/app/rxjs4/timing.ts
import { Observable, debounce, debounceTime, delay, delayWhen, throttleTime, timer } from 'rxjs';

export const settled = (source$: Observable<string>) => source$.pipe(debounceTime(300));

// Short words are sent quickly, long words wait longer
export const smart = (source$: Observable<string>) =>
  source$.pipe(debounce((text) => timer(text.length > 3 ? 500 : 100)));

export const limited = (source$: Observable<string>) => source$.pipe(throttleTime(300));

// delay shifts every value later by the same time
export const later = (source$: Observable<string>) => source$.pipe(delay(1000));

// delayWhen decides the delay for each value with an Observable
export const staggered = (source$: Observable<number>) =>
  source$.pipe(delayWhen((n) => timer(n * 100)));
  • `delay(ms)` postpones every value by a fixed time. It does not slow down errors.
  • `delayWhen(fn)` postpones each value until the Observable returned by fn emits. It can create staggered effects such as one item per 100 ms.

A debounced search in Angular

// src/app/rxjs4/search.ts
import { Component, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, map } from 'rxjs';

@Component({
  selector: 'app-search',
  imports: [ReactiveFormsModule],
  template: `
    <label for="q">Search</label>
    <input id="q" [formControl]="query" />
    <p>Typed: {{ query.value }}</p>
    <p>Searching for: {{ term() }}</p>
  `,
})
export class Search {
  protected readonly query = new FormControl('', { nonNullable: true });
  protected readonly searches = signal(0);

  protected readonly term = toSignal(
    this.query.valueChanges.pipe(
      debounceTime(300), // wait for a pause in typing
      map((v) => v.trim()),
      distinctUntilChanged(), // skip repeated values
    ),
    { initialValue: '' },
  );
}
// src/app/app.ts
import { Component } from '@angular/core';
import { Search } from './rxjs4/search';

@Component({
  imports: [Search],
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
  <app-search />
</main>

Tests with fake time

Time operators are easy to test with fake timers, because you decide when time passes:

// src/app/rxjs4/rxjs4.spec.ts
import { Subject, lastValueFrom, of, toArray } from 'rxjs';
import { cart, finalTotal$, runningTotal$ } from './accumulate';
import { later, limited, settled, smart, staggered } from './timing';

describe('accumulate and timing operators', () => {
  afterEach(() => vi.useRealTimers());

  it('scan emits the running total, reduce only the final one', async () => {
    expect(await lastValueFrom(runningTotal$.pipe(toArray()))).toEqual([1, 3, 6, 10]);
    expect(await lastValueFrom(finalTotal$.pipe(toArray()))).toEqual([10]);
  });

  it('scan builds state from events', () => {
    const events$ = new Subject<{ type: 'add' | 'remove'; item: string }>();
    let latest: string[] = [];
    cart(events$).subscribe((items) => (latest = items));
    events$.next({ type: 'add', item: 'apple' });
    events$.next({ type: 'add', item: 'pear' });
    events$.next({ type: 'remove', item: 'apple' });
    expect(latest).toEqual(['pear']);
  });

  it('debounceTime emits only the last value after a pause', () => {
    vi.useFakeTimers();
    const source$ = new Subject<string>();
    const seen: string[] = [];
    settled(source$).subscribe((v) => seen.push(v));

    source$.next('a');
    vi.advanceTimersByTime(100);
    source$.next('ab');
    vi.advanceTimersByTime(100);
    source$.next('abc');
    expect(seen).toEqual([]);
    vi.advanceTimersByTime(300);
    expect(seen).toEqual(['abc']);
  });

  it('debounce can depend on the value', () => {
    vi.useFakeTimers();
    const source$ = new Subject<string>();
    const seen: string[] = [];
    smart(source$).subscribe((v) => seen.push(v));
    source$.next('hi');
    vi.advanceTimersByTime(100);
    expect(seen).toEqual(['hi']);
    source$.next('hello');
    vi.advanceTimersByTime(100);
    expect(seen).toEqual(['hi']);
    vi.advanceTimersByTime(400);
    expect(seen).toEqual(['hi', 'hello']);
  });

  it('throttleTime emits the first value and ignores the rest for a while', () => {
    vi.useFakeTimers();
    const source$ = new Subject<string>();
    const seen: string[] = [];
    limited(source$).subscribe((v) => seen.push(v));
    source$.next('a');
    source$.next('b');
    vi.advanceTimersByTime(200);
    source$.next('c');
    expect(seen).toEqual(['a']);
    vi.advanceTimersByTime(200);
    source$.next('d');
    expect(seen).toEqual(['a', 'd']);
  });

  it('delay and delayWhen postpone values', () => {
    vi.useFakeTimers();
    const seen: string[] = [];
    later(of('x')).subscribe((v) => seen.push(v));
    vi.advanceTimersByTime(999);
    expect(seen).toEqual([]);
    vi.advanceTimersByTime(2);
    expect(seen).toEqual(['x']);

    const order: number[] = [];
    staggered(of(3, 1, 2)).subscribe((n) => order.push(n));
    vi.advanceTimersByTime(400);
    expect(order).toEqual([1, 2, 3]);
  });
});

Try it yourself

  1. Use scan to keep the last five search terms.
  2. Use throttleTime on a scroll event to log the scroll position at most every 200 ms.
  3. Change debounceTime to throttleTime in the search box. What changes when you type quickly?

Quick check

  1. What is the difference between scan and reduce?
  2. Which operator suits a search box, and which suits scroll events?
  3. What does delayWhen do that delay cannot?

Next, handle failures in error handling operators: throwError, catchError and retry.

Frequently asked questions

Debounce or throttle?

Debounce waits for quiet and emits the last value. Throttle emits the first and then pauses. Use debounce when only the final value matters, and throttle to limit the rate of updates.

Does scan start with the first value?

If you pass a seed, scan starts from it. Without a seed, the first value becomes the starting accumulator and is emitted as is.