Angular Caching Strategies with httpResource: Stale-While-Revalidate, Invalidation [2026]
Most apps fetch the same data many times — /api/user/me on every page, /api/categories for every dropdown. Without caching, each fetch is a wasted round-trip. With naive caching, stale data persists forever. The sweet spot — stale-while-revalidate — shows the cached value INSTANTLY and fetches fresh in the background. This lesson walks the patterns: per-key caches, TTL, invalidation, and stale-while-revalidate built on httpResource and a simple cache service.
This is lesson 6.5 of the Angular Tutorial. After this lesson you can build app-level caching without pulling in TanStack Query or NgRx Entity.
The simplest cache — shareReplay #
For a single observable shared across consumers:
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { shareReplay } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class CategoryService {
private http = inject(HttpClient);
// Single HTTP call, replayed to every subscriber, cached forever
categories$ = this.http.get<Category[]>('/api/categories').pipe(
shareReplay(1),
);
}
First subscriber triggers the fetch; every subsequent subscriber gets the cached value. Works fine for app-startup-loaded reference data (currencies, locales, feature flags).
Limitation: no TTL, no invalidation, no per-key caching. Fine for static reference data; insufficient for anything that changes.
Per-key cache (memoization) #
For loadById(id) patterns:
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
private cache = new Map<string, Promise<User>>();
load(id: string): Promise<User> {
if (!this.cache.has(id)) {
this.cache.set(id, firstValueFrom(this.http.get<User>(`/api/users/${id}`)));
}
return this.cache.get(id)!;
}
invalidate(id: string) { this.cache.delete(id); }
invalidateAll() { this.cache.clear(); }
}
First call fetches; second returns the same Promise. Works for read-only data. For mutations, call invalidate(id) after the update returns to force the next read to refetch.
TTL-based cache (time-to-live) #
Add expiry:
class TtlCache<T> {
private entries = new Map<string, { value: T; expires: number }>();
constructor(private ttlMs: number = 60_000) {}
get(key: string): T | undefined {
const e = this.entries.get(key);
if (!e) return undefined;
if (Date.now() > e.expires) { this.entries.delete(key); return undefined; }
return e.value;
}
set(key: string, value: T) {
this.entries.set(key, { value, expires: Date.now() + this.ttlMs });
}
invalidate(key?: string) {
if (key) this.entries.delete(key);
else this.entries.clear();
}
}
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
private cache = new TtlCache<User>(5 * 60 * 1000); // 5 min
async load(id: string): Promise<User> {
const cached = this.cache.get(id);
if (cached) return cached;
const user = await firstValueFrom(this.http.get<User>(`/api/users/${id}`));
this.cache.set(id, user);
return user;
}
}
Fresh fetches happen at most once per TTL window per key. Good for data that's safe-stale for a few minutes — categories, tags, slowly-changing reference data.
Stale-while-revalidate (SWR) pattern #
The ergonomic ideal: return the cached value INSTANTLY, fetch fresh in the background, swap when the fresh response arrives. Users see no loading state on cached pages; fresh data still arrives.
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
private cache = new Map<string, WritableSignal<User | undefined>>();
load(id: string): Signal<User | undefined> {
if (!this.cache.has(id)) {
const sig = signal<User | undefined>(undefined);
this.cache.set(id, sig);
this.refetch(id);
} else {
// Already cached — kick off background revalidate without clearing the signal
this.refetch(id);
}
return this.cache.get(id)!.asReadonly();
}
invalidate(id: string) {
this.cache.delete(id);
}
private async refetch(id: string) {
try {
const fresh = await firstValueFrom(this.http.get<User>(`/api/users/${id}`));
this.cache.get(id)?.set(fresh);
} catch { /* leave stale value */ }
}
}
Usage:
user = this.users.load(this.userId());
First call: signal is undefined, render skeleton, fresh data appears.
Second call (same id): signal already has the user → instant render → background fetch → swap if changed.
For most read-heavy apps, SWR is the right default.
httpResource + cache layer #
HttpResource doesn't cache responses by default. Layer a cache between component and HttpClient:
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
private cache = new Map<string, User>();
loadObservable(id: string) {
if (this.cache.has(id)) {
// Return synchronous-emitting observable for cache hit
return of(this.cache.get(id)!);
}
return this.http.get<User>(`/api/users/${id}`).pipe(
tap(user => this.cache.set(id, user)),
);
}
}
// Component
user = httpResource<User>(() => ({
url: `/api/users/${this.userId()}`,
// For cache to work, your service should be called instead of bare HttpClient
}));
For more control, write a custom resource() instead of httpResource() that consults the cache before fetching:
user = resource({
params: () => ({ id: this.userId() }),
loader: ({ params }) => this.userService.load(params.id),
});
The cache service handles caching; the resource handles signal-shaped consumption + cancellation.
Browser HTTP cache #
Don't forget the browser already caches GET responses if your server sends appropriate headers:
Cache-Control: public, max-age=300
ETag: "abc123"
With withFetch(), HttpClient uses the Fetch API which respects browser caching natively. For data that doesn't change often, server-side cache headers are the simplest cache — zero app-side code.
For authenticated APIs, use Cache-Control: private, max-age=60 so CDNs don't cache user-specific data.
Cache invalidation patterns #
The hardest part of caching. Two patterns:
After mutation (most common) #
async updateUser(id: string, patch: Partial<User>) {
await firstValueFrom(this.http.patch<User>(`/api/users/${id}`, patch));
this.cache.delete(id); // bust the cache
// Next read will re-fetch fresh
}
For a list-detail pattern, invalidate both the detail key AND the list:
async updateUser(id: string, patch: Partial<User>) {
await firstValueFrom(this.http.patch(`/api/users/${id}`, patch));
this.cache.delete(id); // detail
this.cache.delete('list'); // list cached separately
}
Tag-based invalidation (advanced) #
For cross-cutting invalidation ("a user changed → invalidate all 'org-X' queries"), tag entries:
class TaggedCache {
private entries = new Map<string, { value: any; tags: Set<string> }>();
set(key: string, value: any, tags: string[]) {
this.entries.set(key, { value, tags: new Set(tags) });
}
invalidateTag(tag: string) {
for (const [k, v] of this.entries) {
if (v.tags.has(tag)) this.entries.delete(k);
}
}
}
Now invalidateTag('user-42') invalidates every cached query that touched user 42. TanStack Query's invalidateQueries({ queryKey: ['user', 42] }) works on this principle.
For most apps, simple per-key invalidation after mutation is sufficient. Tag-based pays off only at scale.
When NOT to cache #
Three cases where caching is harmful:
- Real-time data — stock prices, chat messages, anything where stale is wrong. Use WebSockets or SSE (lesson 6.6).
- User-specific session state — cart contents, draft form data. Use signals + persistence; don't cache HTTP.
- Sensitive data — authentication tokens, payment info. Don't cache in memory longer than needed; clear on logout.
When to reach for a real library #
For simple caching: write it yourself (above patterns are <50 lines).
For production-grade caching with deduplication, automatic background refetching, query keys, optimistic updates, and a devtools panel: use TanStack Query Angular (@tanstack/angular-query). The library is the closest thing to a community standard in 2026.
The rule: if you find yourself writing a custom cache invalidation system with tags and dependencies, TanStack Query is what you're reinventing.
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| Cache never updates | Forgot to invalidate after mutation | Add cache.delete(id) to every PUT/PATCH/DELETE handler |
| Multiple components fetch the same data | Each component has its own cache | Inject a shared service; cache lives in the service |
| Memory grows unboundedly | No eviction strategy | Add TTL OR cap the Map size to N entries (LRU) |
| Cached value persists after logout | No clear-on-logout | Subscribe to auth.logout$ and cache.invalidateAll() |
| SSR gets cached browser value | The cache is initialized fresh per request on server | Only use in-memory caches client-side; use TransferState (lesson 9.6) for SSR data passing |
What's next #
Lesson 6.6 closes Module 6 with server-sent events and streaming HTTP — for the data flows that caching can't fix. Module 7 then picks up RxJS in modern Angular.
Try it yourself #
A stale-while-revalidate user cache:
import { Injectable, signal, inject, Signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class UserCache {
private http = inject(HttpClient);
private cache = new Map<string, ReturnType<typeof signal<any>>>();
get(id: string): Signal<{ name: string } | undefined> {
if (!this.cache.has(id)) {
this.cache.set(id, signal(undefined));
}
// Always revalidate in the background
firstValueFrom(this.http.get<{ name: string }>(`https://jsonplaceholder.typicode.com/users/${id}`))
.then(u => this.cache.get(id)!.set(u))
.catch(() => {});
return this.cache.get(id)!.asReadonly();
}
}
First call: returns undefined signal, fetches in background. Second call (same id): returns the cached signal — instant — AND triggers a background revalidate. Components don't see loading flicker on cache hits.
Lesson 6.6 picks up streaming HTTP — server-sent events.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


