Skip to content

shareReplay#

Imagine you have an Observable that does some expensive work when someone subscribes (like making an HTTP request). If multiple parts of your application subscribe to this same Observable independently, the expensive work will happen multiple times (multiple identical HTTP requests!).

shareReplay solves this by:

  1. Sharing a Single Subscription: It ensures that only one subscription is ever made to the original source Observable, no matter how many downstream subscribers there are.
  2. Multicasting Results: It takes the values emitted by that single source subscription and broadcasts them to all downstream subscribers.
  3. Replaying Buffered Values: It keeps a buffer of the most recent values (you specify how many) and immediately sends those buffered values to any new subscriber that joins later.

At a glance

  • Signature: shareReplay({ bufferSize, refCount }) (or shareReplay(bufferSize), which sets refCount: false)
  • Use when: many consumers need the same result and late subscribers should get the cached value: config, user profile, lookup data
  • Avoid when: values must not be replayed (live-only events) or the cache must be refreshable without extra plumbing
  • Top gotcha: the bare shareReplay(1) form never disconnects from a non-completing source, a classic memory leak

Analogy#

Think of watching a live stream on the internet that also has DVR/replay capabilities.

  • The Original Broadcast (Source Observable): The actual live event happening once.
  • The Streaming Service (shareReplay): It takes the single live broadcast.
  • Viewers (Subscribers): People tuning in to watch.
  • The first viewer causes the streaming service to connect to the original broadcast.
  • All viewers watch the same broadcast via the streaming service (multicasting).
  • Someone tuning in late can immediately see the last few minutes (replaying the buffer) before catching up to the live feed.
  • The streaming service only needs one connection to the original broadcast source, regardless of how many viewers there are.

Key Configuration#

shareReplay is typically configured with an object: shareReplay({ bufferSize: 1, refCount: true })

  • bufferSize: How many of the latest emissions to buffer and replay to new subscribers.
  • bufferSize: 1 is very common, especially for HTTP requests where you just want the single result cached and shared.
  • refCount: (Reference Counting) This is crucial!
  • refCount: true: The operator keeps track of how many active subscribers there are. It subscribes to the source Observable only when the first subscriber arrives. It unsubscribes from the source Observable when the last subscriber unsubscribes. This is usually what you want for things like HTTP requests to avoid keeping connections or resources active unnecessarily. If a new subscriber arrives later, it will re-subscribe to the source.
  • refCount: false: The subscription to the source Observable, once established by the first subscriber, stays active forever (or until the source completes/errors), even if all subscribers leave. Use this only if you intend for the source to keep running in the background regardless of subscribers.

Completion changes the rules

refCount only matters while the source is still live. Once the source completes (like a finished HTTP request), the buffered value is replayed to every future subscriber without resubscribing, regardless of refCount. After an error, shareReplay resets instead, so the next subscriber retriggers the source.

Minimal Example#

import { defer, of, shareReplay } from "rxjs";

let calls = 0;
const data$ = defer(() => {
  calls++;
  console.log(`source executed: ${calls}`);
  return of("result");
}).pipe(shareReplay({ bufferSize: 1, refCount: true }));

data$.subscribe((v) => console.log(`A: ${v}`)); // source executed: 1, A: result
data$.subscribe((v) => console.log(`B: ${v}`)); // B: result (replayed, source NOT re-executed)

Real-World Example: Efficiently Fetching Shared Configuration Data#

Imagine multiple components in your application need access to some configuration data fetched from an API endpoint (/api/config). Without shareReplay, each component subscribing to the fetch operation would trigger a separate HTTP GET request.

Code Snippets:

1. Configuration Service (config.service.ts)

import { Injectable, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable, shareReplay, tap, timer, switchMap } from "rxjs";

export interface AppConfig {
  apiUrl: string;
  featureFlags: {
    newDashboard: boolean;
    betaTesting: boolean;
  };
  theme: string;
}

@Injectable({
  providedIn: "root",
})
export class ConfigService {
  private http = inject(HttpClient);
  private configUrl = "/api/app-config"; // Your API endpoint

  // --- Shared Config Observable ---
  // This is the Observable that components will subscribe to.
  readonly config$: Observable<AppConfig>;

  constructor() {
    // Make the HTTP request ONLY ONCE and share the result.
    this.config$ = this.http.get<AppConfig>(this.configUrl).pipe(
      tap(() =>
        console.log(
          "%c Fetching application config from API... ",
          "background: #ffcc00; color: black;",
        ),
      ),
      // --- Key Operator ---
      shareReplay({
        bufferSize: 1, // Cache and replay the single config object
        refCount: true, // Unsubscribe from HTTP when no components are listening
      }),
      // --------------------
    );

    // Example of a source that emits periodically - shareReplay works here too
    // this.config$ = timer(0, 5000).pipe( // Emit every 5 seconds
    //   switchMap(() => this.http.get<AppConfig>(this.configUrl)),
    //   tap(() => console.log('%c Fetching application config from API... ', 'background: #ffcc00; color: black;')),
    //   shareReplay({ bufferSize: 1, refCount: true })
    // );
  }

  // You might still have methods for specific actions, but data access is via config$
}

2. Component A - Consumes Config

import {
  Component,
  inject,
  signal,
  OnInit,
  DestroyRef,
  ChangeDetectionStrategy,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ConfigService, AppConfig } from "./config.service"; // Adjust path

@Component({
  selector: "app-comp-a",
  template: `
    <div class="component-box">
      <h4>Component A</h4>
      @if (config()) {
        <p>API URL: {{ config()?.apiUrl }}</p>
        <p>Theme: {{ config()?.theme }}</p>
      } @else {
        <p>Loading config...</p>
      }
    </div>
  `,
  styles: [
    ".component-box { border: 1px solid blue; padding: 10px; margin: 10px; }",
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CompAComponent implements OnInit {
  private configService = inject(ConfigService);
  private destroyRef = inject(DestroyRef);

  config = signal<AppConfig | null>(null);

  ngOnInit(): void {
    console.log("CompA: Subscribing to config$");
    this.configService.config$
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe((cfg) => {
        console.log("CompA: Received config", cfg);
        this.config.set(cfg);
      });
  }
}

3. Component B - Consumes Config

import {
  Component,
  inject,
  signal,
  OnInit,
  DestroyRef,
  ChangeDetectionStrategy,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ConfigService, AppConfig } from "./config.service"; // Adjust path

@Component({
  selector: "app-comp-b",
  template: `
    <div class="component-box" style="border-color: green;">
      <h4>Component B</h4>
      @if (config()) {
        <p>
          New Dashboard Feature:
          {{ config()?.featureFlags?.newDashboard ? "ENABLED" : "DISABLED" }}
        </p>
        <p>
          Beta Testing: {{ config()?.featureFlags?.betaTesting ? "ON" : "OFF" }}
        </p>
      } @else {
        <p>Loading config...</p>
      }
    </div>
  `,
  styles: [
    ".component-box { border: 1px solid blue; padding: 10px; margin: 10px; }",
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CompBComponent implements OnInit {
  private configService = inject(ConfigService);
  private destroyRef = inject(DestroyRef);

  config = signal<AppConfig | null>(null);

  ngOnInit(): void {
    console.log("CompB: Subscribing to config$");
    // Simulate CompB loading slightly later
    setTimeout(() => {
      this.configService.config$
        .pipe(takeUntilDestroyed(this.destroyRef))
        .subscribe((cfg) => {
          console.log("CompB: Received config", cfg);
          this.config.set(cfg);
        });
    }, 50); // Simulate slight delay
  }
}

4. App Component

import { Component } from "@angular/core";
import { CompAComponent } from "./comp-a.component"; // Adjust path
import { CompBComponent } from "./comp-b.component"; // Adjust path

@Component({
  selector: "app-root",
  imports: [CompAComponent, CompBComponent], // Import components
  template: `
    <h1>RxJS shareReplay Demo</h1>
    <app-comp-a></app-comp-a>
    <app-comp-b></app-comp-b>
  `,
})
export class AppComponent {}

Explanation:

  1. The ConfigService defines config$. Inside the constructor, it chains http.get(...) with tap() (for logging the fetch attempt) and then shareReplay({ bufferSize: 1, refCount: true }).
  2. When CompAComponent initializes (ngOnInit), it subscribes to configService.config$. This is the first subscription.
  3. Because it's the first subscription and refCount is true, shareReplay subscribes to its source (the http.get). The HTTP request is made. You'll see the "Fetching application config from API..." log message once.
  4. When the HTTP request completes, shareReplay receives the AppConfig data. It buffers this single value (bufferSize: 1) and sends it to CompAComponent.
  5. A moment later, CompBComponent initializes and subscribes to the same configService.config$.
  6. Because shareReplay already has an active source subscription and a buffered value, it does not trigger a new HTTP request. Instead, it immediately replays the buffered AppConfig value to CompBComponent. You will not see the "Fetching..." log message a second time.
  7. Both components now have the same configuration data, fetched with only a single API call.
  8. If both CompA and CompB were destroyed while the request was still in flight, shareReplay (because refCount: true) would unsubscribe from the source and abort the HTTP call; a later subscriber would trigger a fresh fetch. But once the source has completed, the cached value is replayed to any future subscriber without a new request, regardless of refCount. Refreshing the config requires rebuilding the stream (see Common Mistakes).

Common Mistakes#

Using the bare shareReplay(1) on a non-completing source. That form means refCount: false: the operator stays subscribed to the source forever, even with zero subscribers. On sources like intervals, Subjects, or WebSocket streams this leaks work and memory. Pass { bufferSize: 1, refCount: true } unless you deliberately want a permanent connection.

Assuming refCount: true re-fetches after completion. It does not: a completed source is cached permanently for the lifetime of the stream. To support refresh, rebuild the pipeline behind a trigger, for example refresh$.pipe(startWith(void 0), switchMap(() => this.http.get(...)), shareReplay(...)).

Ignoring error behavior. Errors are not cached: shareReplay resets after an error, so each new subscriber re-triggers the failing request. Without retry/catchError upstream, a broken endpoint gets hammered once per subscriber.

Interview Q&A#

What exactly does refCount control in shareReplay?

Whether the operator unsubscribes from a live source when its own subscriber count reaches zero. refCount: true disconnects (and a later subscriber resubscribes the source); refCount: false keeps the source running forever. After the source completes, refCount is irrelevant: the buffer is replayed to all future subscribers without resubscription.

How would you cache an HTTP response so multiple components share one request?

Put the request in a service field piped through shareReplay({ bufferSize: 1, refCount: true }) and let every consumer subscribe to that one Observable. The first subscriber fires the request; later subscribers replay the cached response. Mention the refresh limitation and the trigger + switchMap pattern for invalidation to stand out.

Why is shareReplay a common source of memory leaks?

Two reasons: the default refCount: false keeps the source subscription alive forever on non-completing sources, and the replay buffer itself holds references to emitted objects. Both are invisible until memory profiling, which is why interviewers ask about the configuration object.

  • share for live multicasting without a cache
  • ReplaySubject, the primitive that powers the replay buffer
  • Cold Observables for why each HttpClient subscription normally re-executes

Summary#

shareReplay is essential for optimizing applications by preventing redundant work and ensuring multiple parts of your UI react to the same shared data stream efficiently.