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.

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:

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:
shareReplayon anHttpClient.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,shareReplayalone doesn't get you there: you need another piece on top of it (atimertearing 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 }),
);
}

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
shareReplayis 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.
