Angular: HTTP Caching with RxJS shareReplay
Source: Dev.to
Caching HTTP Calls in Angular
Repeated HTTP calls in an Angular application quickly become a performance problem: slower page loads, unnecessary backend load, and wasted network traffic.
The obvious solution is caching, but in Angular caching is not just about storing data – it’s about managing observables correctly.
If done wrong, you’ll get duplicate requests, stale data, or permanently cached errors.
If done right, you eliminate redundant calls entirely.
1. Naïve Cache Using a Service Field
@Injectable({
providedIn: 'root',
})
export class AppService {
private vehiclesDataCache: VehiclesApiResponse | null = null;
constructor(private http: HttpClient) {}
public getVehiclesData(): Observable {
if (this.vehiclesDataCache) {
return of(this.vehiclesDataCache);
}
const observable = this.http.get(
'https://starwars-databank-server.vercel.app/api/v1/vehicles',
);
// Store the response for later calls
observable.subscribe((response) => {
this.vehiclesDataCache = response;
});
return observable;
}
}
Problems with this approach
- If
getVehiclesData()is called multiple times before the first request finishes, each call creates a new HTTP request. - The manual subscription is never unsubscribed → potential memory leaks.
- The code mixes data‑fetching logic with caching logic, making it harder to maintain.
2. Using shareReplay – The RxJS Way
shareReplay caches the latest emitted value from an observable so that multiple subscribers share the same source without triggering additional backend calls.
@Injectable({
providedIn: 'root',
})
export class AppService {
private vehiclesDataShareReplay$: Observable | null = null;
constructor(private http: HttpClient) {}
public getVehicleShareReplay(): Observable {
if (!this.vehiclesDataShareReplay$) {
this.vehiclesDataShareReplay$ = this.http
.get('https://starwars-databank-server.vercel.app/api/v1/vehicles')
.pipe(shareReplay(1)); // cache the last emission
}
return this.vehiclesDataShareReplay$;
}
}
The subscription to the HttpClient remains active only until the request completes.
This pattern is the most common way to cache HTTP requests in Angular services.
2.1. Pitfall – Errors Are Cached Too
shareReplay also replays errors. If the request fails, every future subscriber receives the same error and no new request is ever made.
3. Handling Errors – retry + catchError
A typical solution is to retry the request and reset the cached observable when an error occurs:
@Injectable({
providedIn: 'root',
})
export class AppService {
private vehiclesDataShareReplay$: Observable | null = null;
constructor(private http: HttpClient) {}
public getVehicleShareReplay(): Observable {
if (!this.vehiclesDataShareReplay$) {
this.vehiclesDataShareReplay$ = this.http
.get('https://starwars-databank-server.vercel.app/api/v1/vehicles')
.pipe(
retry(2), // try up to 3 times total
catchError((error) => {
// Reset cache so the next subscriber can retry
this.vehiclesDataShareReplay$ = null;
return throwError(() => error);
}),
shareReplay(1),
);
}
return this.vehiclesDataShareReplay$;
}
}
retry(2)→ three attempts in total.catchErrorclears the cached observable before re‑throwing the error, allowing a fresh request on the next subscription.
4. Cache‑With‑Refresh Pattern
shareReplay(1) works great for static data, but the cached value can become stale.
A common pattern is to combine a BehaviorSubject (as a refresh trigger) with switchMap and shareReplay.
@Injectable({
providedIn: 'root',
})
export class AppService {
private vehiclesDataBehavior$: Observable | null = null;
private refreshTrigger$ = new BehaviorSubject(undefined); // initial trigger
constructor(private http: HttpClient) {}
/** Observable that always emits the latest successful response */
public getVehicleBehavior(): Observable {
// Create the observable only once
if (!this.vehiclesDataBehavior$) {
this.vehiclesDataBehavior$ = this.refreshTrigger$.pipe(
// Every time the trigger emits, perform a new HTTP request
switchMap(() =>
this.http
.get('https://starwars-databank-server.vercel.app/api/v1/vehicles')
.pipe(
retry(2),
catchError((error) => {
console.error('Error fetching vehicles:', error);
// Swallow the error for this emission; keep the previous cached value
return EMPTY;
}),
),
),
// Cache the most recent successful response and replay it to all subscribers
shareReplay(1),
);
}
return this.vehiclesDataBehavior$;
}
/** Call this method to force a refresh */
public refreshVehiclesBehavior(): void {
this.refreshTrigger$.next(undefined);
}
}
How it works
- Initial creation – The observable is built once and stored in
vehiclesDataBehavior$. - Refresh trigger –
refreshTrigger$emits wheneverrefreshVehiclesBehavior()is called. switchMap– Cancels any in‑flight request and starts a new one on each trigger.- Error handling –
retry(2)attempts the request up to three times; on final failure we log the error and returnEMPTYso the previous cached value stays alive. shareReplay(1)– Keeps the latest successful response and replays it to any current or future subscriber.
5. Summary
| Technique | When to use | Pros | Cons |
|---|---|---|---|
| Simple field cache | Very small, one‑time data | Easy to understand | No sharing of in‑flight requests, manual subscription → leaks |
shareReplay(1) | Static reference data (enums, config) | Minimal code, automatic sharing | Caches errors; data can become stale |
shareReplay + retry/catchError | Same as above, but you need resilience | Errors don’t permanently block future calls | Slightly more boilerplate |
BehaviorSubject + switchMap + shareReplay | Data that may need periodic refresh | Allows explicit refresh while still caching | More moving parts; requires a trigger |
By leveraging RxJS operators correctly, you can keep your Angular app fast, reduce backend load, and avoid the hidden pitfalls of naïve caching.
Error Handling in the Cached Observable Pattern
When an error occurs we cannot simply reset the cached observable and re‑throw the error like this:
catchError((error) => {
this.vehiclesDataShareReplay$ = null;
return throwError(() => error);
})
Why throwError Is Problematic
throwError()inside aswitchMapcompletes the entire stream.- Once completed, the stream is dead forever – any future
next()calls on theBehaviorSubjecthave no effect because there is no active subscription. - Setting
vehiclesDataBehavior$ = nulldoes not revive the stream; the pipeline has already terminated.
Using EMPTY Instead
When an error occurs, EMPTY completes the inner observable without emitting a value. Because the outer stream stays alive and continues listening to the BehaviorSubject, we get:
- Subscribers are NOT notified of the error – they retain the last successful cached value.
- The refresh mechanism stays active – users can trigger another fetch with
refreshVehiclesBehavior().
Trade‑off
- Users see stale data instead of an error message.
- If you need to surface errors, use a different approach (e.g.,
Subjectto model both success and failure states).
shareReplay Configuration
Understanding how different shareReplay configurations affect subscriptions and memory usage is essential to avoid leaks.
| Configuration | Buffer | Subscription Behaviour | Typical Use‑Case |
|---|---|---|---|
shareReplay() (no args) | Caches all emitted values (infinite buffer) | Stays active until the source completes, even with zero subscribers | Finite streams where you need the full emission history |
shareReplay(1) | Caches the last emitted value | Remains active until the source completes | Global singleton data fetched once and reused across the app (most common for HTTP requests) |
shareReplay({ bufferSize: 1, refCount: true }) | Caches the last emitted value | refCount: automatically unsubscribes from the source when subscriber count drops to 0 | Long‑lived or infinite streams (WebSockets, timers) or when you want cached data to reset when users leave a feature |
When refCount Matters
- Cleanup: Useful for streams that should stop when nobody is listening (e.g., WebSocket connections).
- Component‑level data: Not always needed; a simple
shareReplay(1)often suffices.
When not to Use shareReplay
- User‑specific data – risk of leaking one user’s cached data to another.
- Real‑time data – stock prices, live scores, etc., need fresh data on every request.
- Large payloads – caching big responses inflates memory usage.
- Security‑sensitive data – ensure proper cache invalidation on logout.
Key Take‑aways for Caching in Angular
- Purpose – Caching isn’t just about performance; it also controls side effects, avoids duplicate work, and makes state management predictable.
shareReplay(1)– Ideal for global, static data that rarely changes.- Error handling – Must be implemented; otherwise failures become permanently cached.
- Stale data – Requires an explicit refresh strategy (e.g., a
BehaviorSubjectcombined withswitchMap). refCount– Important for long‑lived or infinite streams and for aligning cache lifetime with component lifecycles.- No one‑size‑fits‑all – Choose the configuration based on data longevity, consumer scope, and refresh requirements.
When used deliberately, shareReplay can eliminate redundant HTTP calls, simplify state management, and significantly improve application performance—without introducing hidden bugs or memory leaks.
Example Project
A working example is available in my 📁 GitHub repository. Feel free to explore the code for a concrete implementation of the patterns discussed above.