RxJS Observables — shareReplay Without refCount Leak
Thousands of retained DOM nodes from shareReplay without refCount in Angular services.
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- Observable is a lazy function that accepts an observer and returns a teardown
- Cold creates fresh execution per subscriber; hot shares one execution
- Higher-order operators (switchMap, mergeMap, concatMap, exhaustMap) manage overlapping inner subscriptions
- shareReplay({ bufferSize: 1, refCount: true }) solves duplicate HTTP calls without leaking
- Memory leaks happen when subscriptions aren't torn down — use takeUntil or takeUntilDestroyed
Imagine you subscribe to a newspaper. You don't get every paper ever printed — you only get new ones from the day you subscribed. That's an Observable: a source that delivers values over time, only to whoever is actively listening. A Promise is like ordering one pizza — it arrives once and it's done. An Observable is like a pizza conveyor belt at a restaurant — it keeps sending slices as long as you're sitting at the table, and the moment you leave (unsubscribe), the slices stop coming to you.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Every modern JavaScript app — whether it's an Angular dashboard, a React data-fetching layer, or a Node.js event pipeline — eventually runs into the same problem: asynchronous data that arrives in bursts, needs to be transformed, combined with other streams, and cancelled gracefully. setTimeout and Promises handle one-shot async well, but they fall apart the moment you need to debounce a search box, retry a failing API call with exponential backoff, or merge a WebSocket stream with an HTTP response. RxJS was built exactly for that world.
RxJS (Reactive Extensions for JavaScript) brings the Observer pattern, the Iterator pattern, and functional programming together into one composable API. At its core, an Observable is a lazy, cancellable, composable data pipeline. Unlike a Promise, it can emit multiple values over time, it doesn't start executing until something subscribes to it, and it can be torn down mid-flight — which is the key to avoiding memory leaks in dynamic UIs.
By the end of this article you'll understand how Observables work under the hood, why cold vs hot matters in production, how the most important operators actually compose, how multicasting prevents redundant network calls, and exactly which mistakes ship bugs to production. You'll also walk away with interview-ready answers that go beyond surface-level definitions.
Why shareReplay Without refCount Leaks Memory
An RxJS Observable is a lazy push-based collection — it doesn't emit until subscribed, and each subscriber gets its own execution unless the Observable is made multicast. shareReplay is a multicast operator that caches the last N emissions and replays them to new subscribers. The core mechanic: it wraps a Subject, subscribes once to the source, and replays the buffer to late subscribers. Without refCount: true, the underlying Subject stays subscribed even after all subscribers unsubscribe. This means the source Observable never completes or errors, and the subscription to the source persists indefinitely. In practice, this creates a permanent reference chain: the source holds resources (timers, HTTP connections, WebSocket listeners) that never release. The key property: shareReplay defaults to refCount: false, meaning the operator keeps the source alive until the Observable itself is garbage collected — which may never happen if the Observable is referenced globally or in a long-lived service. Use shareReplay when you need to replay past values to late subscribers, but always pass refCount: true unless you explicitly need the source to stay alive (e.g., a shared WebSocket that should reconnect). In real systems, forgetting refCount: true is the #1 cause of silent memory leaks in Angular services and long-lived RxJS streams.
How Observables Work Internally — Not Just What They Are
Most tutorials treat Observable as a black box. Let's crack it open. At its simplest, an Observable is a function that accepts an Observer (an object with next, error, and complete callbacks) and returns a teardown function. That's the entire contract. When you call subscribe(), RxJS invokes that producer function and wires up the observer. Nothing happens before that call — that's what 'lazy' means.
This is fundamentally different from a Promise, which starts its executor synchronously the moment you call new Promise(). An Observable defers all work until subscription time, which means you can pass an Observable around, compose it with operators, and store it in a variable without triggering any side effects. That referential transparency is what makes Observables safe to compose.
The teardown function returned by the producer (or set via subscriber.add()) is called when you unsubscribe, or when the Observable completes or errors. This is the foundation of RxJS's memory-safety story — every resource (timers, event listeners, WebSocket connections) must be cleaned up in that teardown. If your custom Observable doesn't return a teardown, you've created a leak.
subscribe() is called.Cold vs Hot Observables — The Distinction That Ships Bugs
This is the single most misunderstood concept in RxJS and the root cause of both duplicate API calls and missed WebSocket messages. Understanding it deeply separates senior RxJS engineers from everyone else.
A cold Observable creates its producer fresh for each subscriber. Each subscriber gets the complete sequence from the beginning, with its own independent execution context. The interval example above is cold — two subscribers would each get their own timer. fromFetch(), ajax(), and interval() are cold by default.
A hot Observable shares a single producer among all subscribers. Subscribers only receive values emitted after they subscribe — like a live concert stream. fromEvent() (wrapping a DOM event) is hot because there's one event listener on the element, not one per subscriber.
The danger zone is HTTP requests: if you build a search-as-you-type feature using a cold ajax() Observable and render it in two places, each render triggers a separate HTTP request. The fix is multicasting — turning a cold Observable hot so all subscribers share one execution. shareReplay(1) is the production workaround most Angular devs reach for, but it has its own subtleties around refCounting and memory.
Operator Internals and Composition — map, switchMap, mergeMap, exhaustMap Compared
Operators are pure functions that take an Observable and return a new Observable. They don't mutate the source — each operator wraps the previous one in a new layer, forming a pipeline. Under the hood, pipe() is just function composition: pipe(opA, opB, opC) is equivalent to opC(opB(opA(source))).
The higher-order mapping operators — switchMap, mergeMap, concatMap, exhaustMap — are where most production bugs live. They all accept a function that maps each emitted value to an inner Observable. The difference is what they do with overlapping inner subscriptions.
switchMap cancels the previous inner Observable when a new outer value arrives. This is perfect for autocomplete — you only care about the response for the latest keystroke. mergeMap subscribes to every inner Observable concurrently, which is useful for parallel requests but can overwhelm a server. concatMap queues them, processing one at a time in order. exhaustMap ignores new outer values while an inner Observable is still active — ideal for a login button that shouldn't fire twice.
Choosing the wrong one causes race conditions (mergeMap for search), dropped requests (exhaustMap for pagination), or stalled queues (concatMap when order doesn't matter but throughput does).
Production Patterns — Error Handling, Retry and Memory Management
Error handling in RxJS is a trap for the unprepared. When an Observable errors, it terminates — no more values, no recovery. That means if you have a WebSocket stream and it throws, your UI goes silent. The answer is catchError, which intercepts an error and must return a new Observable (including EMPTY to silently swallow it, or throwError to re-throw).
retryWhen and its modern replacement retry({ delay, count }) let you implement exponential backoff — critical for flaky API endpoints. But retry resubscribes to the entire source Observable, which for cold Observables means a fresh HTTP call — exactly what you want. For hot sources, retry can cause confusing behaviour because the source doesn't reset.
For memory management in SPAs, the takeUntilDestroyed() operator (Angular 16+) or the classic takeUntil(destroy$) pattern ensures subscriptions are torn down when a component unmounts. In React with RxJS, cleaning up in useEffect's return function is the equivalent. Forgetting this in a long-lived app with many navigations leads to dozens of stale subscriptions running in the background, causing ghost updates to unmounted components and measurable memory growth you'll only catch in a production heap snapshot.
Multicasting Internals — Subject, BehaviorSubject, ReplaySubject, AsyncSubject
When you need to share a single execution among multiple subscribers, you need multicasting. The core mechanism is Subject — a type that is both an Observable and an Observer. You push values into it via next(), and all subscribed observers receive them.
BehaviorSubject extends Subject: it remembers the last emitted value and replays it to new subscribers immediately. This makes it perfect for 'current user' state — when a component initializes, it gets the current user without waiting for a new emission.
ReplaySubject replays a configurable number of past emissions (or all). Use it for caching transient data like search results that you want to show for a few seconds after navigation.
AsyncSubject replays only the last value after the source completes. It's rarely used but perfect for loading a resource that you know will eventually complete.
The key to multicasting correctly is understanding that the Subject is the 'bridge' from cold to hot. You can create a Subject, subscribe your source Observable to it, and then expose the Subject as the hot observable. Operators like share, shareReplay, and publish do this internally. shareReplay is the most common production choice because it also caches the last value for late subscribers.
- Subject: no memory, only live listeners — like a live radio show
- BehaviorSubject: remembers the last value — like a whiteboard that shows the current state
- ReplaySubject: remembers a configurable history — like a DVR that replays the last N minutes
- AsyncSubject: waits until the end to share the final value — like a race result announced after the match
The Data Pipeline: Why Your RxJS Code Smells Like Callback Hell
You don't subscribe inside subscribe. I've seen it. A junior wires a user input event, then in the callback manually calls another observable with a nested subscribe. That's not reactive programming — that's callback hell with extra steps.
RxJS data pipelines exist for one reason: to declare transformations declaratively, not imperatively. You take a source observable, pipe it through operators that describe what happens to each value, and then subscribe once at the end. That's it. No nesting. No intermediate subscriptions. No manual cleanup.
The function composes operators lazily. Each operator returns a new observable that wraps the previous one. When data flows through, each operator transforms or filters it before passing it downstream. This means your subscription handler only sees the final result, not the intermediate noise.pipe()
If your observable pipeline has more than one subscription inside it, you've already lost. Refactor into a single pipeline with switchMap, map, or filter.
What Is Reactive Programming? (And Why Your Event Handlers Are Lying to You)
Reactive programming isn't about Observables or RxJS. It's a shift in how you model change over time. Traditional imperative code says: "Do this, then do that, then check this." Reactive programming says: "Here's a stream of events. Here's how to transform it. React as it flows."
You've already done reactive programming without knowing it. Click listeners? That's a stream of click events. Promise chains? That's a stream that emits once. The problem is you're treating each event source as a special case with its own API — addEventListener, then, callbacks. Reactive programming gives you a single abstraction for all of them: the Observable.
The advantage isn't just consistency. It's composition. With imperative code, combining two event sources requires nested callbacks or state variables. With reactive programming, you use combineLatest, merge, or forkJoin. You describe what you want, not how to wire it up. This means less code, fewer bugs, and no state synchronization errors.
Reactive programming also gives you backpressure control, cancellation, and error propagation. Promises fail silently when you forget a catch. Observables let you handle errors exactly where they happen — in the pipeline or at subscription.
Why combineLatest With Dynamic Streams Silently Breaks
combineLatest emits a new value whenever any source emits, but only after every source has emitted at least once. When streams are added dynamically (e.g., via array.push into an observable list), the operator doesn't re-evaluate the initial state. New sources may emit later, leaving the combined result in a stale partial state. This causes UI flickers, missing data, or silent failures if downstream operators assume completeness. The fix is to seed each new stream with a startWith value or use forkJoin if you only care about the initial emission. Never assume combineLatest lazily adapts to dynamic source changes—it only tracks what it was given at subscription time. Test your multicasting setup: if a BehaviorSubject feeding combineLatest fires before all sources are ready, your pipeline emits incomplete payloads.
Why Subscription.add() Hides Memory Leaks Better Than takeUntil
takeUntil is the standard pattern for unsubscribing when a notifier emits. But it only works if the notifier completes or emits at the right moment. If the notifier itself never fires (e.g., a Subject that never .next()), the subscription stays active forever. subscription.add(childSub) groups subscriptions into a parent container, letting you manage all teardown logic in one place. More importantly, it auto-unsubscribes children if the parent unsubscribes—no reliance on external notifiers. This prevents orphan subscriptions when components are destroyed but the notifier is still alive. Use add for composite subscriptions (e.g., multiple HTTP requests) and takeUntil only when you control the notifier's lifecycle. Never mix both without a finalize operator to force cleanup.
subscription.add() guarantees teardown even if the notifier stalls.subscription.add() over takeUntil when you cannot guarantee the notifier will fire.Why testrx.js Solves Real-World Observable Debugging
When your Angular app’s HTTP stream silently fails or a switchMap drops requests, logging to console won’t reveal the race condition. testrx.js is a lightweight sandbox (npm install testrx) that lets you simulate time with marble diagrams in Node.js without a browser. Unlike full test runners, testrx.js focuses on observables directly: you write marble strings like -a-b-c| to define emissions, then pipe real operators to see exactly when errors or completions fire. The why before how: most devs debug by adding .pipe(tap()) but miss timing bugs because browser DevTools can’t replay async sequences. testrx.js gives you deterministic reproduction. Use it to isolate memory leaks from shareReplay or verify that your retry logic actually waits the correct interval. Always test the observable, not the UI.
Why Educative Courses Fill the Production-Reactive Gap
Documentation explains operators; production teaches failure patterns. Educative’s interactive tutorials (like "RxJS Mastery" or "Reactive Patterns Angular") go beyond API docs by having you refactor buggy real-world code — memory leaks from shareReplay, race conditions in combineLatest, or improper unsubscription in React useEffect. The why: most developers learn observables via small examples that never stress-test garbage collection or dynamic stream counts. Educative’s live coding environment lets you run and break code immediately, seeing the memory tab in your browser spike. They also cover web application concerns: handling WebSocket reconnection with retryWhen, debouncing search inputs with switchMap, and canceling stale HTTP requests via AbortController integrated with takeUntil. Use their "RxJS in Production" path to reinforce the patterns from this article — especially why shareReplay without refCount leaks resources across route changes.
Memory Leak from shareReplay Without refCount in Angular Service
- Always use refCount: true with shareReplay in long-lived environments
- Test with simulated navigation cycles and check heap snapshots
- Treat shareReplay as an optimization, not a free lunch — understand when it keeps state alive
Add `tap(console.log)` in observable pipeline to trace emissionsUse rxjs-spy: `import { spy } from 'rxjs-spy'; window.rxjsSpy = spy.create();`takeUntil(destroy$) where destroy$ emits in destroy hook| File | Command / Code | Purpose |
|---|---|---|
| ObservableInternals.js | const intervalObservable = new Observable((subscriber) => { | How Observables Work Internally |
| ColdVsHotMulticast.js | const coldTimer$ = new Observable((subscriber) => { | Cold vs Hot Observables |
| HigherOrderOperators.js | switchMap, | Operator Internals and Composition |
| ProductionErrorHandling.js | catchError, | Production Patterns |
| MulticastingSubjects.js | const subject$ = new Subject(); | Multicasting Internals |
| SearchPipeline.js | searchInput.valueChanges.subscribe((query) => { | The Data Pipeline |
| ReactiveVsImperative.js | let email = ''; | What Is Reactive Programming? (And Why Your Event Handlers A |
| DynamicCombineLatest.js | const source1 = new Subject(); | Why combineLatest With Dynamic Streams Silently Breaks |
| SubscriptionAdd.js | const neverFires = new Subject(); | Why Subscription.add() Hides Memory Leaks Better Than takeUn |
| testrx.js | const {cold} = require('testrx'); | Why testrx.js Solves Real-World Observable Debugging |
| educative-pattern.js | const {Subject, interval} = require('rxjs'); | Why Educative Courses Fill the Production-Reactive Gap |
Key takeaways
subscribe() is called. Returning a teardown function from the producer is what separates memory-safe from leaky custom Observables.Interview Questions on This Topic
What is the difference between a cold and a hot Observable, and how does shareReplay convert one to the other? Can you describe a real scenario where mixing them up caused a bug?
ajax() in a method called from two components. Each component triggered a separate HTTP call, causing double billing on a paid API. The fix was to store the observable as a property with shareReplay(1) so both components share one execution.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
That's Advanced JS. Mark it forged?
8 min read · try the examples if you haven't