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.
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.
combineLatest, merge, zip) handle this elegantly.await reads top-to-bottom; reactive's chain reads similarly but is more verbose.The 2020s consensus: reactive is overused. For most async code, async/await with proper error handling is simpler.
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.
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.
When a subscriber unsubscribes, the upstream operations cancel. AbortController in browser fetch is the equivalent for plain async; reactive bakes it in.
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.
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.
When producer outpaces consumer, what happens?
buffer — accumulate; risk OOM if producer is too fast.drop — discard new items.drop_oldest — keep new; discard old.error — fail loudly when buffer is full.block (in some implementations) — pause producer until consumer catches up.Reactive frameworks make this explicit. Pick a policy per stream.
| Operator | What it does |
|---|---|
map | Transform each value |
filter | Keep matching values |
flatMap / mergeMap | Transform to a stream; flatten; concurrent |
concatMap | Transform to a stream; flatten; sequential |
switchMap | Transform to a stream; cancel previous on new |
debounceTime | Emit only after silence |
throttleTime | Emit at most once per period |
distinctUntilChanged | Drop consecutive duplicates |
take | Take first N |
takeUntil | Stop on signal |
combineLatest | Combine latest from multiple sources |
zip | Combine pairs |
merge | Interleave |
concat | Sequential |
share | Multicast |
retry | Retry on error |
catchError | Handle errors |
The vocabulary is extensive. Most production reactive code uses 10-15 operators heavily; the rest are edge-case-only.
map. map should be pure; side effects in tap (named explicitly)..toPromise() everywhere. Converting back to promise immediately. Stick to one paradigm.flatMap. Hard to read; refactor to chains.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.
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.
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.