shareandShareReplayare two RxJS operators that often get mixed up. The go-to solution for multicasting a resource-intensive observable or caching a value for reuse across multiple points has its own nuances, and it’s easy to lose sight of how they differ. What exactly is therefCountflag, why does it matter, and how can you take advantage of its behavior?Below, we’ll unpack these concepts with a couple of practical examples, so you can confidently pick the right tool next time.
Example 1
I posted a question on Twitter about this topic, but it didn’t get any replies. That silence tells you just how unclear these operators are even among experienced developers.
The exercise looks like this:
@Component({
selector: 'app-count',
standalone: true,
imports: [NgIf, AsyncPipe],
template: `
<ng-container *ngIf="flag"> {{ count1$ | async }} </ng-container>
<ng-container *ngIf="!flag"> {{ count2$ | async }} </ng-container>
`,
})
export class CountComponent implements OnInit {
flag = true;
readonly count$ = interval(1000).pipe(
take(7),
shareReplay({ bufferSize: 1, refCount: false }) // 👈 line: 15
);
readonly count1$ = this.count$.pipe(
take(3),
map((c) => `count1: ${c}`)
);
readonly count2$ = this.count$.pipe(
take(3),
map((c) => `count2: ${c}`)
);
ngOnInit(): void {
setTimeout(() => {
this.flag = false;
}, 5500);
}
}
Note: The example is in Angular, but the operator behavior is identical outside of that framework.
When flag is true, count1$ subscribes, emits three times, and completes. After 5500ms, flag flips to false, count2$ subscribes, and it also completes after three values. Both observables are connected to count$.
The task is to predict what appears on screen after 10 seconds, depending on which operator appears on line 15.
Share
Let’s start with share. This operator multicasts each value from the source observable, so the source isn’t re-executed for every subscriber.
Additionally, when the subscriber count hits zero, the source observable gets unsubscribed.
Inside, a Subjectacts as the bridge between the source and the subscribers. That means late subscribers won’t see any of the previously emitted values.
Reach for share when you don't need earlier data and only care about future emissions.
Solution
Going back to the exercise:
count1$subscribes, socount$starts running.count$emits three times, thencount1$completes thanks to thetake(3). With zero subscribers left,count$finishes and the inner Subject resets.- After 5500ms,
count2$subscribes. It attaches tocount$, which restarts from the beginning. - The
take(3)limits the result, so the final output is 3.
ShareReplay with refCount: true
share and shareReplay function nearly the same—shareReplay is built on top of share. The key difference is the connector: shareReplay uses a ReplaySubject instead of a Subject. That matters for late subscribers, who get access to prior emissions.
The other distinction is the refCount flag. With refCount=true, the source unsubscribes when the subscriber count drops to 0. (The share operator’s refCount is always true.)
Here, with refCount: true, the source gets unsubscribed as soon as the subscriber count hits zero.
Solution
Back to the example:
count1$triggerscount$on subscription.- After three emissions,
count1$completes viatake(3). BecauserefCountis true and subscriber count hits zero,count$also completes. - At 5500ms,
count2$subscribes again, andcount$restarts from zero. - With
take(3), the answer is 3. So far, both operators behave identically. We’ll introduce more scenarios below to reveal their differences.
ShareReplay with refCount: false
Setting refCount to false keeps the source observable alive even when no subscribers remain.
That’s risky because if the source never completes, you can end up with memory leaks.
But it’s also useful when you want to avoid re-executing the source for a new subscriber, like with an HTTP request.
Solution
Let’s revisit the example:
count1$subscribes and the source begins.- Three emissions later,
count1$completes due totake(3). However,count$doesn’t finish—its subscriber count never dropped to zero, so it keeps going, emitting another value each second. - At 5500ms,
count2$subscribes and immediately picks up the last value, which is 4. - With
take(3), the final result is 6.
Note: Both observables complete here, so there are no leak-related problems in this scenario.
Example 2
Let’s pick another case to highlight the real distinction between share and shareReplay, especially when the source never completes, like a BehaviorSubject.
@Component({
selector: 'app-root',
standalone: true,
imports: [NgIf, AsyncPipe],
template: `
<ng-container *ngIf="!flagFinalize">
<ng-container> {{ count1$ | async }} </ng-container>
<ng-container *ngIf="flag"> {{ count2$ | async }} </ng-container>
</ng-container>
<button (click)="flagFinalize = !flagFinalize">FINALIZE</button>
<button (click)="subject.next(subject.value + 1)">INCREMENT</button>
`,
})
export class AppComponent implements OnInit {
flag = false;
flagFinalize = false;
subject = new BehaviorSubject(0);
readonly count$ = this.subject.pipe(
tap({
next: (t) => console.log('I get next value of count', t),
complete: () => console.log('complete count'),
finalize: () => console.log('finalize count'),
}),
share() // 👈
);
readonly count1$ = this.count$.pipe(
tap({
next: (t) => console.log('I get next value of count1', t),
complete: () => console.log('complete count1'),
finalize: () => console.log('finalize count1'),
}),
map((c) => `count1: ${c}`)
);
readonly count2$ = this.count$.pipe(
tap({
next: (t) => console.log('I get next value count2', t),
complete: () => console.log('complete count2'),
finalize: () => console.log('finalize count2'),
}),
map((c) => `count2: ${c}`)
);
ngOnInit(): void {
setTimeout(() => {
this.flag = true;
}, 1000);
}
}
This time we’re using a BehaviorSubject and there’s an INCREMENT button to push new values into the subject.
There’s also a tap operator to log the next, complete, and finalize events.
The FINALIZE button unsubscribes from both count1$ and count2$.
Scenario
On load, count1$ subscribes to count$, and after one second, count2$ subscribes to count$ as well.
Then we click INCREMENT once, and finally FINALIZE.
Before you read on, try predicting what happens with each operator. Compare your expectations to the solutions shortly.
Share
count1$subscribes, socount$starts, andcount1$immediately gets the initial value 0.count1$doesn’t complete, so subscriber count doesn’t hit zero, andcount$stays active.- After 1s,
count2$subscribes, but because the inner connector is aSubject, the last value isn’t replayed—count2$gets nothing initially. - Clicking INCREMENT pushes value 1 through, and both
count1$andcount2$receive it. - FINALIZE unsubscribes both subscribers (via the
asyncPipe) and finalizes them. Sincesharetears down the source when no subscribers remain,count$finalizes as well.
ShareReplay with refCount: true
Replacing share with shareReplay({bufferSize: 1, refCount: true}):
- Behavior is identical to above.
- Same as before.
- After 1s,
count2$subscribes, but this time it receives the last emitted value (0), becauseshareReplayrelies on aReplaySubject. That’s the core difference between the two operators.
Steps 4 and 5 behave the same as before, since refCount is true.
ShareReplay with refCount: false
Steps 1 through 4 are identical to the previous setup; the difference appears only at step 5 when we unsubscribe.
- On FINALIZE,
count1$andcount2$unsubscribe properly via theasyncPipe. Butcount$doesn’t finalize:shareReplaykeeps the source alive, socount$runs indefinitely, which risks a memory leak.
Important note: The leak isn’t certain—if you lose the reference to the running observable, for example when a component is destroyed, a new count$ is created the next time that component loads. Still, that can add up over time.
In this example, toggling the flag means count1$ and count2$ resubscribe to the already-running observable. A shareReplay with refCount: false is handy when you don’t want to re-trigger something expensive like an HTTP call. Placing that shared observable in a global service and injecting it anywhere gets you an instance that lives for the whole app. New subscribers reuse that same instance instead of forcing a new execution.
Note: The shorter form replaySubject(1) is actually shorthand for replaySubject({bufferSize: 1, refCount: false}), so watch out for that. Usually, you’ll want refCount: true to steer clear of memory leak surprises.
Example 3
For the final demonstration, we turn to a source observable that finishes on its own, much like an HTTP request. To keep things straightforward, the of operator is our tool of choice.
@Component({
selector: 'app-root',
standalone: true,
imports: [NgIf, AsyncPipe],
template: `
<ng-container> {{ request1$ | async }} </ng-container>
<ng-container *ngIf="flag"> {{ request2$ | async }} </ng-container>
`,
})
export class AppComponent implements OnInit {
flag = false;
readonly http$ = of('trigger http request').pipe(
tap({
next: (t) => console.log('http response', t),
complete: () => console.log('complete http'),
finalize: () => console.log('finalize http'),
}),
share() // 👈
);
readonly request1$ = this.http$.pipe(
tap({
next: (t) => console.log('request1 response', t),
complete: () => console.log('complete request1'),
finalize: () => console.log('finalize request1'),
}),
map((c) => `request1: ${c}`)
);
readonly request2$ = this.http$.pipe(
tap({
next: (t) => console.log('request2 response', t),
complete: () => console.log('complete request2'),
finalize: () => console.log('finalize request2'),
}),
map((c) => `request2: ${c}`)
);
ngOnInit(): void {
setTimeout(() => {
this.flag = true;
}, 1000);
}
}
Setting the Stage
Upon component load, a first HTTP request is initiated through the http$ observable. A second later, we aim to retrieve the outcome of that same request. The plan involves caching the result with either the share or shareReplay operator.
This is the same exercise as before; I recommend pondering the answer before diving into the explanations provided.
Share
request1$initiates a subscription tohttp$, which fires off an HTTP call. Upon receiving a response, the call finishes, leading to the completion of bothhttp$andrequest1$.- After a 1s delay,
request2$subscribes tohttp$, anticipating the cached result. But, becausesharehinges on aSubject, no data is retained. As a consequence,http$gets re-subscribed, producing a fresh HTTP call.
ShareReplay
Here, the refCount parameter doesn't alter the outcome, given that the source observable (http$) completes independently of how many subscribers it has.
- The first step mirrors the previous one.
- A second later,
request2$subscribes tohttp$. This time, however,shareReplayrelies on aReplaySubjectas its connector, which allows it to hold onto the most recent value fromhttp$. Consequently,http$doesn't have to be re-executed;request2$obtains the stored value without prompting another HTTP request.
Note: Exercise caution when employing shareReplay within a global service in conjunction with an HTTP call. Any new subscriber will get the cached value, and the HTTP request will never be triggered again. Consequently, your data will remain stale forever.
Conclusion
In essence, shareReplay proves valuable when the goal is to cache and replay the latest value of an observable, particularly in instances such as HTTP calls, to prevent redundant re-executions and enhance efficiency. Yet, be mindful: this is advantageous within a component's context, usually not at the application-wide level.
When applying shareReplay to observables that don't self-complete, the refCount option warrants careful thought.
share is the right pick when multicasting a long-lived observable and there's no need to revisit previously emitted values.
As demonstrated, a thorough grasp of the internal mechanics of these two operators can significantly boost your app's performance.
I trust that the distinctions between share and shareReplay are now clearer, along with the significance of the refCount flag. Armed with this insight, you can employ them appropriately and truly grasp what occurs under the hood.
Feel free to connect with me on Twitter or Github. Don't hesitate to reach out if you have any inquiries.
