Skip to content

Caching

HTTP Client Toolkit provides response caching through the CacheStore interface. The client respects standard HTTP cache headers (Cache-Control, ETag, Last-Modified, Expires) per RFC 9111 to make smart caching decisions — honoring server-specified freshness, performing lightweight revalidation, and supporting resilience patterns like stale-while-revalidate.

When client.get(url) is called:

  1. The URL and parameters are hashed into a deterministic key
  2. The cache store is checked for a matching entry
  3. Fresh entries are returned immediately without a network request
  4. Stale entries trigger a conditional request (If-None-Match / If-Modified-Since) for efficient revalidation
  5. New responses are wrapped in a metadata envelope and cached with a TTL derived from the response headers

The client determines cache freshness following RFC 9111 priority:

  1. Cache-Control: max-age=N — freshness lifetime is N seconds
  2. Expires header minus Date header
  3. Heuristic: 10% of (Date minus Last-Modified)
  4. No freshness info — falls back to cache.ttl
Server: Cache-Control: max-age=300
→ Entry is fresh for 5 minutes, then stale

Set the cache TTL (in seconds) when creating the client. This is used when response headers don’t specify freshness:

const client = new HttpClient({
name: 'my-api',
cache: { store: new InMemoryCacheStore(), ttl: 300 }, // 5 minutes
});

All built-in store backends follow the same TTL rules:

ValueBehavior
ttlSeconds > 0Expires after N seconds
ttlSeconds === 0Never expires (permanent)
ttlSeconds < 0Immediately expired

When a cached entry becomes stale, the client sends conditional headers to the origin server:

  • If-None-Match — sent when the cached response had an ETag
  • If-Modified-Since — sent when the cached response had a Last-Modified

If the server responds with 304 Not Modified, the cached value is refreshed with updated metadata — no body is transferred.

Client → Server: GET /data If-None-Match: "abc123"
Server → Client: 304 Not Modified Cache-Control: max-age=300
→ Cached value reused, freshness reset to 5 minutes

When a response includes stale-while-revalidate, the client serves the stale cached value immediately and refreshes the cache in the background:

// Server sends: Cache-Control: max-age=60, stale-while-revalidate=120
// After 60s: entry is stale
// Between 60s–180s: stale value served instantly, background fetch updates cache
// After 180s: full re-fetch required

This gives users instant responses while keeping the cache fresh. Background revalidation failures are silently ignored — the stale entry remains until it falls out of the window.

In tests, use client.flushRevalidations() to wait for all background revalidations to complete before making assertions.

When a response includes stale-if-error, the client serves a stale cached value if the origin server returns a 5xx error or the network fails:

// Server sends: Cache-Control: max-age=60, stale-if-error=300
// After 60s: entry is stale
// If origin returns 500/503 or network fails within 300s of going stale:
// → stale value returned instead of throwing
// After 360s: stale-if-error window expires, errors propagate normally

Only server errors (5xx) and network failures trigger the fallback. Client errors (4xx) are always propagated.

The client respects the Vary response header for cache matching. When a server sends Vary: Accept, the client captures the request’s Accept value and stores it alongside the cache entry. On subsequent lookups, the cached entry is only used if the current request’s header values match the stored ones — otherwise the cache is treated as a miss and a fresh request is made.

// First request — cached with Vary: Accept
const json = await client.get('https://api.example.com/data', {
headers: { accept: 'application/json' },
});
// Same Accept — cache hit
const json2 = await client.get('https://api.example.com/data', {
headers: { accept: 'application/json' },
});
// Different Accept — cache miss, re-fetches
const xml = await client.get('https://api.example.com/data', {
headers: { accept: 'application/xml' },
});

Vary: * means the response varies on everything — it always triggers a fresh fetch.

Since this is a private client cache, only one entry is stored per URL. A Vary mismatch overwrites the previous entry with the new response.

The cache.overrides option lets you selectively bypass certain cache directives:

const client = new HttpClient({
name: 'my-api',
cache: {
store: new InMemoryCacheStore(),
overrides: {
ignoreNoStore: true, // Cache even when no-store is set
ignoreNoCache: true, // Skip revalidation even when no-cache is set
minimumTTL: 60, // Floor: cache for at least 60 seconds
maximumTTL: 3600, // Cap: cache for at most 1 hour
},
},
});
OverrideEffect
ignoreNoStoreCaches responses that have Cache-Control: no-store
ignoreNoCacheReturns cached values without revalidation even when no-cache is set
minimumTTLFloor on the header-derived store TTL (seconds)
maximumTTLCap on the header-derived store TTL (seconds)

Both cache.ttl and cache.overrides can be set per request, overriding the constructor defaults. This is useful when different endpoints have different caching needs.

// Override TTL for a specific request
await client.get(url, { cache: { ttl: 120 } });
// Override cache behavior for a specific request
await client.get(url, {
cache: { overrides: { ignoreNoStore: true, minimumTTL: 60 } },
});
// Combine both
await client.get(url, {
cache: { ttl: 300, overrides: { maximumTTL: 600 } },
});

Per-request cache.overrides are shallow-merged with the constructor-level overrides — specified fields override, unspecified fields fall back to the constructor defaults:

const client = new HttpClient({
name: 'my-api',
cache: {
store: new InMemoryCacheStore(),
ttl: 300,
overrides: { minimumTTL: 60, maximumTTL: 3600 },
},
});
// minimumTTL overridden to 120, maximumTTL stays 3600
await client.get(url, {
cache: { overrides: { minimumTTL: 120 } },
});

Tag cache entries with arbitrary labels, then invalidate all entries sharing a tag after a mutation. This is useful for CRUD APIs where creating or updating a resource should bust related list and detail caches.

// Tag responses during GET requests
const user = await client.get('/api/users/123', {
cache: { tags: ['users', 'user:123'] },
});
const users = await client.get('/api/users', {
cache: { tags: ['users'] },
});
// After a mutation, invalidate related caches
await client.invalidateByTag('user:123'); // busts /users/123 cache
await client.invalidateByTags(['users']); // busts both /users and /users/123 caches

Both methods return the number of cache entries that were invalidated.

Tags are scoped per client — they follow the same cacheScope prefix as cache keys, so multiple clients sharing a store won’t collide. When globalScope: true is set, tags are stored unscoped.

Calling get() with tags on an already-cached URL replaces the old tag associations when the cache entry is refreshed (e.g. after a stale-while-revalidate background fetch).

Passing an empty tags array (or omitting tags) uses regular set() — no tag overhead.

HttpClient operates as a private cache (a client library, not a shared proxy), so:

  • Cache-Control: private responses are cacheable (we are the private cache)
  • s-maxage is parsed but ignored for freshness calculations
  • proxy-revalidate is parsed but ignored

Each cache store backend has its own configuration for eviction and limits.

import { InMemoryCacheStore } from '@http-client-toolkit/store-memory';
const cache = new InMemoryCacheStore({
maxItems: 1000, // Max cached entries
maxMemoryBytes: 50_000_000, // 50 MB memory limit
cleanupIntervalMs: 60_000, // Expired entry cleanup interval
evictionRatio: 0.1, // Evict 10% when limits exceeded
});

The in-memory store uses LRU (Least Recently Used) eviction with dual limits — item count and memory usage. Expired entries are removed lazily on get and during scheduled cleanup.

Call cache.destroy() when done to clear the cleanup timer.

In-memory and SQLite stores use background timers for cleanup. These timers call unref() so they don’t keep the Node.js process alive, but you should still call destroy() or close() when done:

const cache = new InMemoryCacheStore();
// ... use the cache ...
// Clean up when shutting down
cache.destroy();

DynamoDB stores don’t use timers, so no explicit cleanup is needed.