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 defaultCacheTTL
Server: Cache-Control: max-age=300
→ Entry is fresh for 5 minutes, then stale

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

const client = new HttpClient(
{ cache: new InMemoryCacheStore() },
{ defaultCacheTTL: 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 cacheOverrides option lets you selectively bypass certain cache directives:

const client = new HttpClient(
{ cache: new InMemoryCacheStore() },
{
cacheOverrides: {
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)

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.