Caching and resilience

A shared cache with stale-while-revalidate, keyed by the request. Plus circuit breakers to stop hammering an endpoint that is already failing.

@mmstack/resourcenpm

Two components that ask for the same URL shouldn't hit the network twice, and a list you saw a second ago shouldn't blank out while it reloads. The cache handles both. It stores responses keyed by the request, serves them instantly while they are fresh, and revalidates in the background once they go stale. Circuit breakers cover the other failure mode: an endpoint that is down, where retrying just adds load.

Caching is off until you opt in. That is deliberate, so a resource does exactly what you asked and nothing implicit. Turning it on is two steps.

#Turning it on

Register createCacheInterceptor() in the HTTP client, then opt a resource in with the cache option. Pass cache: true for the defaults, or an object to tune the key and durations.

import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { createCacheInterceptor, queryResource } from '@mmstack/resource';

// 1. register the interceptor
provideHttpClient(withInterceptors([createCacheInterceptor()]));

// 2. opt a resource in
readonly posts = queryResource<Post[]>(() => ({ url: '/api/posts' }), {
  cache: true, // or { staleTime: 60_000, ttl: 5 * 60_000 }
});

The interceptor only touches GET by default. Pass an array to widen it, for example createCacheInterceptor(['GET', 'HEAD']). For a persistent, cross-tab, or tuned cache, add provideQueryCache(), covered further down.

#Stale-while-revalidate

A cache entry has two clocks, and the gap between them is where the good behavior lives. staleTime is how long the entry is considered fresh: reads inside it return cached data and skip the network entirely. ttl is how long the entry lives at all; after it, the entry is evicted.

Between the two, the value is stale-but-valid. A read gets the cached value immediately, and the resource kicks off a background refetch. The consumer sees the old value first, then the fresh one when it lands. No spinner, no empty state, and the data still ends up current.

import { provideQueryCache } from '@mmstack/resource';

// global defaults
provideQueryCache({
  staleTime: 60_000, // fresh for a minute
  ttl: 5 * 60_000, // evicted after five
});

// or per-resource
queryResource<Post[]>(() => ({ url: '/api/posts' }), {
  cache: { staleTime: 60_000, ttl: 5 * 60_000 },
});

HTTP Cache-Control, ETag, and Last-Modified are honored by default, so a server saying stale-while-revalidate=300 extends the stale window on its own. To ignore server directives and use only your staleTime and ttl, pass cache: { ignoreCacheControl: true }.

#Cache keys

The default key comes from hashRequest(): method, URL, and response type, plus sorted params and a stable hash of the body. It does not include headers. That is usually right, but it bites in one case, when the same URL returns different data per header.

Two logged-in users hitting /api/me would share a cache entry and see each other's data. Opt the distinguishing header into the key with varyHeaders and each user gets their own entry.

queryResource<Profile>(() => ({ url: '/api/me', headers }), {
  cache: {
    varyHeaders: ['Authorization'], // one cache entry per user
  },
});

Header values are one-way digested into the key, never stored raw, because keys are persisted to IndexedDB and broadcast across tabs and must not leak secrets. Even so, call injectQueryCache().clear() on logout: the previous user's entries become unreachable under the new key but linger until their TTL. For full control over the key (ignoring a param, a custom shape) pass a hash function, which takes precedence over varyHeaders.

queryResource<Post>(() => ({ url }), {
  cache: {
    hash: (req) => `posts:${new URL(req.url, location.origin).pathname}`,
  },
});

#Recipe: scope the cache to a user or tenant

In a multi-tenant or multi-user app, one cache shared across identities is a data-leak waiting to happen, and a persisted cache makes it outlive the session. The fix is to fold the current identity into every key. Build your key from the exported hashRequest and prepend the Keycloak sub (and a tenant id if you have one). This is the pattern the library nudges you toward instead of putting Authorization in varyHeaders, since a rotating token would churn the cache on every refresh while a stable sub does not.

import { hashRequest } from '@mmstack/resource';

@Injectable({ providedIn: 'root' })
export class Api {
  private readonly auth = inject(AuthStore); // exposes sub() and tenant()

  // prepend the identity so each user/tenant gets its own entries
  private readonly key = (req: HttpResourceRequest) =>
    `${this.auth.sub()}:${this.auth.tenant()}:${hashRequest(req)}`;

  readonly profile = queryResource<Profile>(() => ({ url: '/api/me' }), {
    cache: { hash: this.key },
  });
}

Prepending with ordinary characters is safe: the key still carries its URL structure, so invalidateUrlPrefix keeps working. When the identity changes, drop the old entries. On logout clear() everything; on a tenant switch, invalidate just the previous prefix so the incoming tenant starts clean without nuking anything already loaded for it.

const cache = injectQueryCache();

// logout: nothing from the old session should survive
cache.clear();

// tenant switch: drop only the previous tenant's entries
cache.invalidateWhere((k) => k.startsWith(`${sub}:${oldTenant}:`));

#Persistence and cross-tab sync

provideQueryCache() replaces the default in-memory cache with one you can tune and persist. Set persist: true and the cache mirrors entries to IndexedDB, so still-fresh data comes back after a reload as if the page never went away. Bump version to invalidate the whole persisted store when your response shapes change.

provideQueryCache({
  staleTime: 60_000,
  ttl: 5 * 60_000,
  cleanup: { maxSize: 100 }, // LRU eviction cap (default 200)
  persist: true, // mirror to IndexedDB
  version: 1, // bump to invalidate persisted entries
  syncTabs: true, // broadcast across tabs
});

syncTabs: true broadcasts invalidations and updates over BroadcastChannel: tab A writes a fresh response and tab B sees it without a second network call. Both features are SSR-safe, since the IndexedDB store and the channel are only created in the browser.

#Invalidating by hand

Most invalidation should be the declarative invalidates option on a mutation. For the cases it can't express (a guard reading the cache, an arbitrary predicate), reach for injectQueryCache().

const cache = injectQueryCache();

cache.invalidateUrlPrefix('/api/posts'); // any method, under this URL prefix
cache.invalidatePrefix('tenant-a:'); // raw key string from its start
cache.invalidate(key); // a single entry by exact key
cache.invalidateWhere((key) => key.includes('userId=42')); // arbitrary predicate
cache.clear(); // everything: memory, persisted, other tabs (e.g. on logout)

invalidateUrlPrefix() is the common move: it recovers the URL field from the key structurally, so it matches any HTTP method. invalidatePrefix() is the lower-level cousin, matching the raw key string from its start (useful when your custom hash prepends a stable namespace and you want to drop just that segment). invalidate(key) drops a single entry by its exact key, invalidateWhere() takes an arbitrary predicate, and clear() drops everything (memory, persisted rows, other tabs), which is what you want on logout. Treat keys as opaque; don't hand-build them.

#Two more per-resource knobs

Alongside staleTime, ttl, hash, varyHeaders, persist, and ignoreCacheControl, the cache object carries two situational flags.

OptionWhat it does
skipTabSync Don't broadcast this resource's writes to other tabs even when syncTabs is on globally. Lets you opt into cross-tab sync app-wide and opt one resource out. Pair with persist: true for "persist but don't sync".
bustBrowserCache Append a unique query parameter so the browser's own HTTP cache is bypassed and the request always reaches the server. The parameter is stripped before the cache key is computed, so it doesn't fragment your entries.

#Circuit breakers

When an endpoint is failing, retrying is worse than waiting: you add load to a service that is already struggling. A circuit breaker trips after a threshold of failures and short-circuits new requests until a timeout passes, then lets one probe through to test the waters.

Three states:

StateMeaning
CLOSEDNormal. Requests go through.
OPEN Threshold hit. New requests are short-circuited and the resource's disabled() is true.
HALF_OPEN After the timeout, one probe is allowed. Success closes the breaker; failure reopens it.
import { createCircuitBreaker, queryResource } from '@mmstack/resource';

const cb = createCircuitBreaker({
  threshold: 5, // open after 5 failures
  timeout: 30_000, // probe after 30s
});

queryResource(() => ({ url: '/api/data' }), { circuitBreaker: cb });

Share one breaker across every resource hitting the same service and a single trip protects all of them. Pass circuitBreaker: true (or an options object) instead for a breaker private to that one resource.

#Errors that won't fix themselves

Some failures aren't transient. A 401 with a bad token or a 403 from a permission boundary won't succeed on a probe, so retrying is pointless. shouldFailForever opens the breaker permanently for those: no timeout, no probe. The resource stays disabled until you explicitly recover.

import { HttpErrorResponse } from '@angular/common/http';

const cb = createCircuitBreaker({
  shouldFailForever: (err) =>
    err instanceof HttpErrorResponse && [401, 403].includes(err.status),
});

// elsewhere, after re-auth:
authService.onRefresh(() => cb.hardReset());

hardReset() is the recovery handle. Call it after the user re-authenticates and the breaker clears its failure count, drops the permanent-open flag, and goes back to CLOSED.

#Recipe: polling with a safety valve

Poll a job's status every five seconds, retry a few times on a blip, and if failures pile up, let the circuit breaker pause the polling for a minute instead of pounding a dead endpoint. The three options are independent, so they compose without stepping on each other.

queryResource(() => ({ url: '/api/job-status' }), {
  refresh: 5_000, // poll every 5s
  retry: { max: 3, backoff: 2_000 }, // 3 retries, backoff from 2s
  circuitBreaker: { threshold: 5, timeout: 60_000 }, // pause after 5 failures
});