Angular Caching Strategies with httpResource: Stale-While-Revalidate, Invalidation [2026]

Link copied
Angular Caching Strategies with httpResource: Stale-While-Revalidate, Invalidation [2026]

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:

  1. Real-time data — stock prices, chat messages, anything where stale is wrong. Use WebSockets or SSE (lesson 6.6).
  2. User-specific session state — cart contents, draft form data. Use signals + persistence; don't cache HTTP.
  3. 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.

YouShould I use TanStack Query Angular or roll my own cache?
YouFor a small app with maybe 10-20 cacheable resources, roll your own — the patterns in this lesson are 30-50 lines and you understand every cache miss. For an app with many resources, complex invalidation chains, optimistic updates, and a need for devtools to inspect the cache, TanStack Query Angular saves months of work. The mental cost of learning it is real (it has its own query-key + mutation-key model) but pays back as the app grows.

Lesson 6.6 picks up streaming HTTP — server-sent events.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *