Reactive Programming

Reactive programming models computation as streams of values that flow through composed operators. Async, push-based, with explicit handling of backpressure (producer outpaces consumer). RxJS, Reactor (Java), Akka Streams, ReactiveX family.

The model is powerful for specific situations — high-throughput streaming, complex async event composition, backpressure-sensitive pipelines. For everything else, async/await is usually simpler and equally good.

The mental model

In imperative async code:

result = await fetchData(url)
processed = transform(result)
return await save(processed)

In reactive:

fetchData(url)
    .map(transform)
    .flatMap(save)
    .subscribe(result => ..., error => ...)

A stream emits zero or more values; operators (map, filter, flatMap) transform; subscribers consume. Async by default; composable.

When reactive wins

When async/await wins

The 2020s consensus: reactive is overused. For most async code, async/await with proper error handling is simpler.

The 5 things reactive does that async/await doesn't

1. Backpressure

Async/await: producer awaits consumer's response, then produces next. Implicit one-at-a-time.

Reactive: producer can produce continuously; consumer requests N items; if producer is faster, it can buffer, drop, or apply policy.

source$.pipe(
    bufferCount(100),    // batch in 100s
    concatMap(batch => process(batch))  // wait for one before next
)

For streams where production rate genuinely outpaces consumption rate, this matters.

2. Time-based operators

source$.pipe(
    debounceTime(300),   // emit only after 300ms of silence
    distinctUntilChanged(),
    switchMap(query => searchApi(query))
)

Standard auto-complete. The combination of debounce, distinct, switchMap (cancels previous on new emission) is hard to write cleanly with async/await.

3. Cancellation propagation

When a subscriber unsubscribes, the upstream operations cancel. AbortController in browser fetch is the equivalent for plain async; reactive bakes it in.

4. Composing multiple streams

combineLatest([userStore$, settingsStore$, currentRoute$]).pipe(
    map(([user, settings, route]) => deriveState(user, settings, route))
)

Re-emits whenever any of the sources emit. Async/await would require manual coordination.

5. Hot vs cold streams

A cold stream replays its values for each subscriber. A hot stream is shared; multiple subscribers see the same emissions.

const sharedClicks$ = clicks$.pipe(share()); // hot

Useful for caching expensive computations; for multicasting events.

Backpressure strategies

When producer outpaces consumer, what happens?

Reactive frameworks make this explicit. Pick a policy per stream.

Common operators

OperatorWhat it does
mapTransform each value
filterKeep matching values
flatMap / mergeMapTransform to a stream; flatten; concurrent
concatMapTransform to a stream; flatten; sequential
switchMapTransform to a stream; cancel previous on new
debounceTimeEmit only after silence
throttleTimeEmit at most once per period
distinctUntilChangedDrop consecutive duplicates
takeTake first N
takeUntilStop on signal
combineLatestCombine latest from multiple sources
zipCombine pairs
mergeInterleave
concatSequential
shareMulticast
retryRetry on error
catchErrorHandle errors

The vocabulary is extensive. Most production reactive code uses 10-15 operators heavily; the rest are edge-case-only.

Anti-patterns

Where reactive shines in 2026

Where reactive has lost ground

The high-water mark of reactive programming was around 2018-2020. Since then, language-level async (async/await, virtual threads) has handled what reactive promised, more simply.

Concrete example: search-as-you-type

The canonical reactive use case:

const searchResults$ = searchInput$.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    filter(query => query.length >= 2),
    switchMap(query => searchApi(query).pipe(
        catchError(err => of([]))
    ))
);

searchResults$.subscribe(results => updateUI(results));

Without reactive:

let lastQuery = '';
let cancelToken = null;

const onInput = debounce(async (query) => {
    if (query === lastQuery || query.length < 2) return;
    lastQuery = query;
    if (cancelToken) cancelToken.abort();
    cancelToken = new AbortController();
    try {
        const results = await searchApi(query, {signal: cancelToken.signal});
        updateUI(results);
    } catch (err) {
        if (!cancelToken.signal.aborted) updateUI([]);
    }
}, 300);

The reactive version is shorter and more obviously correct. For these specific patterns, reactive earns its keep.

Tools

A pragmatic position

Use reactive for streams. Use async/await for sequential async. Don't conflate them.

Specifically:

The mistake is the all-or-nothing stance. Reactive is a tool; use where it fits.

Further reading