share and ShareReplay are 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 the refCount flag, 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. count1$ subscribes, so count$ starts running.
  2. count$ emits three times, then count1$ completes thanks to the take(3). With zero subscribers left, count$ finishes and the inner Subject resets.
  3. After 5500ms, count2$ subscribes. It attaches to count$, which restarts from the beginning.
  4. 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:

  1. count1$ triggers count$ on subscription.
  2. After three emissions, count1$ completes via take(3). Because refCount is true and subscriber count hits zero, count$ also completes.
  3. At 5500ms, count2$ subscribes again, and count$ restarts from zero.
  4. 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:

  1. count1$ subscribes and the source begins.
  2. Three emissions later, count1$ completes due to take(3). However, count$ doesn’t finish—its subscriber count never dropped to zero, so it keeps going, emitting another value each second.
  3. At 5500ms, count2$ subscribes and immediately picks up the last value, which is 4.
  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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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

  1. count1$ subscribes, so count$ starts, and count1$ immediately gets the initial value 0.
  2. count1$ doesn’t complete, so subscriber count doesn’t hit zero, and count$ stays active.
  3. After 1s, count2$ subscribes, but because the inner connector is a Subject, the last value isn’t replayed—count2$ gets nothing initially.
  4. Clicking INCREMENT pushes value 1 through, and both count1$ and count2$ receive it.
  5. FINALIZE unsubscribes both subscribers (via the asyncPipe) and finalizes them. Since share tears down the source when no subscribers remain, count$ finalizes as well.

ShareReplay with refCount: true

Replacing share with shareReplay({bufferSize: 1, refCount: true}):

  1. Behavior is identical to above.
  2. Same as before.
  3. After 1s, count2$ subscribes, but this time it receives the last emitted value (0), because shareReplay relies on a ReplaySubject. 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.

  1. On FINALIZE, count1$ and count2$ unsubscribe properly via the asyncPipe. But count$ doesn’t finalize: shareReplay keeps the source alive, so count$ 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);
  }
}

Enter fullscreen mode Exit fullscreen mode

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

  1. request1$ initiates a subscription to http$, which fires off an HTTP call. Upon receiving a response, the call finishes, leading to the completion of both http$ and request1$.
  2. After a 1s delay, request2$ subscribes to http$, anticipating the cached result. But, because share hinges on a Subject, 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.

  1. The first step mirrors the previous one.
  2. A second later, request2$ subscribes to http$. This time, however, shareReplay relies on a ReplaySubject as its connector, which allows it to hold onto the most recent value from http$. 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.