shareReplay: Caching HTTP Requests and Killing Duplicate Calls in Angular

shareReplay: Caching HTTP Requests and Killing Duplicate Calls in Angular

Who hasn't seen a component fire 3 identical HTTP requests on page load, just because 3 different services subscribed to the same Observable? Each one asked for the same data: each got its own copy of the response, and the backend answered the same question three times.

shareReplay fixes this. But pasting the operator without understanding why it works is gambling, not programming.

Cold and hot observables

A cold Observable only starts producing values once someone subscribes, and every new subscribe() fires its own execution from scratch. For example, this.http.get(url) is cold: three subscriptions turn into three separate HTTP calls, even if they all fire at the exact same instant asking for the same URL.

A hot Observable is already producing (or shares a single production) regardless of who's listening. And whoever subscribes later just joins the party that's already happening, without triggering anything new.

Diagram comparing a cold Observable, where each consumer fires its own HTTP call, with a hot Observable using shareReplay, where all three consumers share the same HTTP call

shareReplay takes a cold Observable and turns it into a hot one: the first subscription triggers the real execution, and everyone who subscribes after that (even a few milliseconds later) gets the cached value instead of firing a fresh execution.

shareReplay({ bufferSize: 1, refCount: true })

Here's what that looks like, in a service with two ways to fetch the same data:

const URL = 'https://jsonplaceholder.typicode.com/todos/1';

@Injectable({ providedIn: 'root' })
export class DataService {
  callsWithoutCache = 0;
  callsWithCache = 0;

  private readonly cachedData$ = this.http.get(URL).pipe(
    tap(() => this.callsWithCache++),
    shareReplay({ bufferSize: 1, refCount: true }),
  );

  constructor(private readonly http: HttpClient) {}

  fetchWithoutCache(): Observable<unknown> {
    return this.http.get(URL).pipe(tap(() => this.callsWithoutCache++));
  }

  fetchWithCache(): Observable<unknown> {
    return this.cachedData$;
  }
}

The detail that makes this actually work is that cachedData$ is created once, as a class field. If that .pipe(shareReplay(...)) lived inside a method instead, every call would build a brand new instance of the operator, and the cache would never actually be shared with anyone. In other words, each caller would get its own private shareReplay, which is the same as having no cache at all.

bufferSize: 1 means "keep only the most recent value for whoever shows up later." That's the right call for a current-state cache (the newest response is what matters). By contrast, bufferSize: Infinity would keep the entire history of emissions: that's useful for event replay, but overkill for caching a request.

I simulated this with 3 "consumers" subscribing to each version, all at once:

Running app showing two sections side by side: without shareReplay firing 3 HTTP calls, with shareReplay firing only 1

The result is 3 calls on one side, 1 on the other, exactly the hook from the start of this post.

A real gotcha: refCount doesn't reset the cache on its own

I wrote this service's original comment thinking I already knew the answer, and RxJS's actual behavior corrected me. My assumption was: with refCount: true, once the last subscriber leaves, the cache "resets," and the next subscription fires a fresh HTTP call. So I tested it by clicking "fetch again" twice in a row on the cached card, well after the first response had already landed and everyone had unsubscribed. The result: the HTTP call counter stayed frozen at 1, forever.

The reason is that, under the hood, shareReplay always runs with resetOnComplete: false, no matter what refCount is set to. That means once the source completes on its own (an HTTP call completes the moment the response arrives), the value sitting in the buffer never gets discarded: it stays in memory for the entire lifetime of the service. But refCount doesn't control that. It only controls whether the subscription to a live source tears down once nobody's listening anymore, which only actually matters for a source that never completes by itself.

In other words: shareReplay on an HttpClient.get() caches forever (until the service itself gets destroyed), with no automatic expiry at all. If you need a cache that expires and refetches later, shareReplay alone doesn't get you there: you need another piece on top of it (a timer tearing the cache down, for instance).

That piece doesn't have to stay theoretical. The simplest way to build it: keep the pipe in a field that can turn null, and let a timer throw that field away after a TTL (short for "time to live," how long the cached value stays valid before it expires).

export const CACHE_TTL_MS = 30_000;

@Injectable({ providedIn: 'root' })
export class ExpiringCacheService {
  calls = 0;

  private cached$: Observable<unknown> | null = null;

  constructor(private readonly http: HttpClient) {}

  fetch(): Observable<unknown> {
    if (!this.cached$) {
      this.cached$ = this.http.get(URL).pipe(
        tap(() => this.calls++),
        shareReplay({ bufferSize: 1, refCount: true }),
      );
      timer(CACHE_TTL_MS).subscribe(() => (this.cached$ = null));
    }

    return this.cached$;
  }
}

The timer doesn't cancel anything already running, it just discards the reference stored in cached$. On the next call, cached$ is null, so the if rebuilds .pipe(shareReplay(...)) from scratch, with its own fresh instance of the operator, and that's what fires a genuinely new HTTP call. In other words, it's the exact same mechanism from the start of this post (building the pipe inside a method instead of a fixed field), just triggered on purpose this time.

I tested this with fakeAsync and HttpTestingController, not just by reading the code and assuming it works:

it('shares one HTTP call within the TTL, then fires a fresh one after it expires', fakeAsync(() => {
  service.fetch().subscribe();
  httpMock.expectOne(URL).flush({});
  expect(service.calls).toBe(1);

  service.fetch().subscribe();
  httpMock.expectNone(URL);
  expect(service.calls).toBe(1);

  tick(CACHE_TTL_MS);

  service.fetch().subscribe();
  httpMock.expectOne(URL).flush({});
  expect(service.calls).toBe(2);
}));

One call, the cache holds for 30 seconds, and only after that does the next call go fetch fresh data.

Where refCount actually matters: leaking a live source

The real difference between refCount: true and refCount: false only shows up with a source that never completes on its own, like a WebSocket or a polling read. I simulated that with interval():

@Injectable({ providedIn: 'root' })
export class LiveSourceService {
  ticksWithRefCount = 0;
  ticksWithoutRefCount = 0;

  readonly withRefCount$ = interval(1000).pipe(
    tap(() => this.ticksWithRefCount++),
    shareReplay({ bufferSize: 1, refCount: true }),
  );

  readonly withoutRefCount$ = interval(1000).pipe(
    tap(() => this.ticksWithoutRefCount++),
    shareReplay({ bufferSize: 1, refCount: false }),
  );
}

Diagram comparing refCount true, where the source tears down and the tick counter freezes the instant the last subscriber unsubscribes, with refCount false, where the source keeps running and the counter climbs forever

I wrote a fakeAsync test to prove this with actual numbers, instead of just describing it:

it('keeps ticking in the background after unsubscribing (refCount: false)', fakeAsync(() => {
  const sub = service.withoutRefCount$.subscribe();
  tick(3000);
  expect(service.ticksWithoutRefCount).toBe(3);

  sub.unsubscribe();
  tick(3000);
  expect(service.ticksWithoutRefCount).toBe(6); // kept climbing: this is the leak

  discardPeriodicTasks();
}));

With refCount: true, the equivalent test freezes at 3 right after unsubscribe(), because the source genuinely tears down once the last subscriber leaves. With refCount: false, the interval() keeps running in the background forever, even though nobody's listening, and it burns memory and CPU for nothing. And in a real app that's a WebSocket connection that never closes, or a poll that never stops, all because a component that passed through once forgot to set refCount: true.

And it doesn't matter what the component does on its own side. Even with an ngOnDestroy that calls unsubscribe() (which is exactly what this post's LeakComponent does), that only disconnects that one component: the shared source keeps running underneath, because refCount: false never tears it down, no matter how many subscribers are left.

The leak lives in how shareReplay is configured, not in whoever subscribes to it afterward.

The repo

All the code from this post (the fetch flavors, the expiring cache, the leak demo, and the tests proving those behaviors) is at formigadev-sharereplay-example, with a Portuguese/English toggle built into the app itself. And I picked Angular 15 on purpose: shareReplay is plain RxJS, so it doesn't depend on anything from a newer version.

Wrapping up

shareReplay solves a real problem (duplicate calls), but the refCount every tutorial mentions in passing only makes a difference when the source doesn't complete on its own. For a plain HTTP call, the cache sticks around forever either way: bufferSize and where the operator lives (once, as a field) matter more than the refCount debate most write-ups on this topic focus on. And testing before writing kept this post from repeating a mistake I'd made myself skimming the docs.

Henrique Guedes FormigaHenrique Guedes FormigaSênior Angular Developer