Create your own
Lesson illustration

Sharing and Replaying Observables with `shareReplay`

Hello! Welcome back to our deep dive into RxJS.

In our last lesson, we explored ReplaySubject and its ability to cache and replay a history of emissions. We even had a sneak peek at how it forms the internal mechanism of the shareReplay operator. We saw that without a sharing mechanism, every subscription to a "cold" Observable (like one wrapping an HTTP request) triggers a new, independent execution. This can lead to redundant network calls, wasted computation, and poor performance.

Today, we'll tackle that problem head-on. The learning outcome for this lesson is to convert a cold Observable to a hot, shared Observable using the shareReplay operator to prevent redundant work. We will explore how this operator provides an elegant, declarative solution for caching and multicasting, making your data streams far more efficient.

1. The Problem: The Cost of Cold Observables

By default, most Observables you create are cold. This means the producer of values doesn't start its work until a consumer subscribes. Each new subscription creates a new, independent execution.

Imagine fetching data for your application. If a user profile component and a dashboard component both subscribe to the same getUsers() Observable, you'll make two identical API calls.

To see this problem in action, let's start with a video that clearly explains the difference between cold and hot observables and demonstrates the issue of redundant work.

How to share your RxJS observables for improved performance

This video from Joshua Morony, 'How to share your RxJS observables', provides an excellent foundation. It clearly illustrates what a cold observable is and why simply caching the observable object itself doesn't solve the problem of multiple executions.

Watch from the beginning to 03:46. Focus on the explanation of a 'cold observable' and why the initial attempt to cache it in a member variable fails to prevent multiple data fetches.

As the video shows, we need a way to subscribe to the source once and share that single execution among all subscribers.

2. The Solution: shareReplay

The shareReplay operator is designed for exactly this purpose. It transforms a cold observable into a hot, multicasted observable. Here's how it works:

  1. When the first subscriber arrives, shareReplay subscribes to the source observable.
  2. It multicasts the values from that single source subscription to all of its own subscribers.
  3. It uses an internal ReplaySubject to buffer a specified number of the latest values.
  4. When a new subscriber arrives, it immediately receives the buffered values without causing a new subscription to the source.

Let's see this in practice with a non-HTTP example to emphasize that this applies to any expensive operation.

Cold vs. Hot Observables in Angular with RxJS

The article 'Cold vs. Hot Observables' provides a fantastic example using a 'heavy computation' function. It perfectly demonstrates the performance problem and how shareReplay solves it.

Read the section '6.1 “Heavy Computation” Example with a Cold Observable'. First, observe the code where calculIntensif is called multiple times. Then, see how adding a single line—shareReplay({ bufferSize: 1, refCount: true })—solves the problem entirely, ensuring the expensive function runs only once.

The result is dramatic: a costly operation that was being repeated for every subscriber is now executed only once, with the result shared efficiently.

3. The Critical refCount Parameter: Avoiding Memory Leaks

You might have noticed the configuration object { bufferSize: 1, refCount: true }. While bufferSize is straightforward (it's the same concept as in ReplaySubject), refCount is arguably the most critical and often misunderstood part of shareReplay.

  • Default Behavior (refCount: false): By default, once shareReplay subscribes to its source, it never unsubscribes, even if all its own subscribers go away. The source subscription remains active indefinitely. For a finite stream like a typical HTTP GET request, this isn't a disaster, as the stream completes. But for a long-lived stream (like a WebSocket or a polling mechanism), this creates a permanent, unstoppable subscription—a classic memory leak.

  • Smart Behavior (refCount: true): When refCount is set to true, the operator maintains a reference count of its subscribers.

    • When the number of subscribers goes from 0 to 1, it subscribes to the source.
    • When the number of subscribers drops back to 0, it unsubscribes from the source.

This makes the stream "turn off" when nobody is listening, preventing memory leaks and unnecessary background work.

The following video explains this pitfall and its solution in detail. Given your background, you'll appreciate that it goes a step further and builds a custom shareReplay operator from scratch, revealing the mechanics of reference counting.

ShareReplay in RxJS - Hidden Pitfall You Have To Know (Advanced)

This advanced video from Decoded Frontend, 'ShareReplay in RxJS - Hidden Pitfall You Have To Know', is essential for truly understanding shareReplay. It directly addresses the memory leak problem and demonstrates the power of refCount.

Watch from the beginning to 06:01, and then from 13:33 to 16:36. The first part (00:00 - 06:01) introduces the memory leak pitfall with a polling example and shows how refCount: true fixes it. The second part (13:33 - 16:36) implements the refCount logic in a custom operator, showing you exactly how the subscriber counting and teardown logic works under the hood.

For most use cases involving caching data that can be re-fetched (like API calls), shareReplay({ bufferSize: 1, refCount: true }) is the configuration you'll want.

4. A Real-World Caching Pattern

The most common use case for shareReplay is caching data in a service. By combining shareReplay with a lazy-initialization pattern in a singleton service (standard in React with providers or in frameworks like Angular), you can create a highly efficient, application-wide cache.

Here is a typical implementation pattern.

import { Injectable } from '@angular/core'; // Concept applies to any DI system
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
import { shareReplay } from 'rxjs/operators';

// Assume User type is defined elsewhere
// @Injectable({ providedIn: 'root' }) // This makes it a singleton in Angular
export class UserService {
  private users$?: Observable<User[]>;

  constructor(private http: HttpClient) {}

  getUsers(): Observable<User[]> {
    if (!this.users$) {
      this.users$ = this.http.get<User[]>('/api/users').pipe(
        tap(() => console.log('API call: Fetching users...')), // For demonstration
        shareReplay({ bufferSize: 1, refCount: true })
      );
    }
    return this.users$;
  }
}

How this pattern works:

  1. The users$ property holds the shared observable. It's initially undefined.
  2. The first time getUsers() is called, it sees that users$ is not set. It creates the HTTP observable, pipes it through shareReplay, and assigns the result to users$. The API call is made.
  3. Any subsequent call to getUsers() will find that users$ already exists and will simply return the existing shared observable. New subscribers will get the cached data from shareReplay without triggering a new API call.
  4. Because of refCount: true, if all components using this data unsubscribe (e.g., the user navigates away), the internal subscription is torn down. The next time getUsers() is called, the process will start over, ensuring fresh data.

5. Advanced Pitfall: shareReplay and Higher-Order Operators

Operator placement matters. A common mistake is to use shareReplay inside a higher-order mapping operator like switchMap. This negates its sharing effect because a new inner observable (with its own shareReplay) is created every time the outer observable emits.

The sharing operator must be placed after the higher-order operator to share the final, combined stream.

Cold vs. Hot Observables in Angular with RxJS

Let's return to the 'Cold vs. Hot Observables' article to see a brilliant demonstration of this advanced pitfall.

Read the section '6.2 Evolution request: combining with an external stream'. Pay close attention to the explanation of why adding shareReplay inside the getData$ function is not enough when it's used within a switchMap. The key insight is that the sharing must happen outside the switchMap.

Conclusion

Today we've demystified one of the most important operators for performance optimization in RxJS. By converting cold observables into hot, shared streams, shareReplay provides a robust and declarative way to prevent redundant work.

Key Takeaways:

  • Purpose: shareReplay subscribes to a source observable once and multicasts its emissions to multiple subscribers, preventing redundant executions.
  • Caching: It uses an internal ReplaySubject to cache and replay the last N values to new subscribers.
  • refCount: true is Vital: This configuration is crucial for preventing memory leaks with long-lived streams, as it tears down the source subscription when no subscribers are active.
  • Common Pattern: The combination of lazy initialization and shareReplay({ bufferSize: 1, refCount: true }) in a singleton service is the canonical pattern for caching API responses.
  • Placement Matters: To share the result of a chain involving higher-order operators like switchMap, place shareReplay after them.

Next Up:

We've now seen how to create multicasted streams using both Subjects (directly) and operators like shareReplay. This begs the question: when should you use one over the other? In our next lesson, we will analyze the differences between Subjects and shareReplay for creating shared, multicasted streams, giving you a clear mental model for choosing the right tool for the job.

Can't find a good explanation? Sign up and we'll make it for you

Sign up