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.
How It Works
Section titled “How It Works”When client.get(url) is called:
- The URL and parameters are hashed into a deterministic key
- The cache store is checked for a matching entry
- Fresh entries are returned immediately without a network request
- Stale entries trigger a conditional request (
If-None-Match/If-Modified-Since) for efficient revalidation - New responses are wrapped in a metadata envelope and cached with a TTL derived from the response headers
Freshness
Section titled “Freshness”The client determines cache freshness following RFC 9111 priority:
Cache-Control: max-age=N— freshness lifetime is N secondsExpiresheader minusDateheader- Heuristic: 10% of (
DateminusLast-Modified) - No freshness info — falls back to
defaultCacheTTL
Server: Cache-Control: max-age=300→ Entry is fresh for 5 minutes, then staleConfiguring TTL
Section titled “Configuring TTL”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);TTL Semantics
Section titled “TTL Semantics”All built-in store backends follow the same TTL rules:
| Value | Behavior |
|---|---|
ttlSeconds > 0 | Expires after N seconds |
ttlSeconds === 0 | Never expires (permanent) |
ttlSeconds < 0 | Immediately expired |
Conditional Requests (304 Revalidation)
Section titled “Conditional Requests (304 Revalidation)”When a cached entry becomes stale, the client sends conditional headers to the origin server:
If-None-Match— sent when the cached response had anETagIf-Modified-Since— sent when the cached response had aLast-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 minutesStale-While-Revalidate
Section titled “Stale-While-Revalidate”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 requiredThis 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.
Stale-If-Error
Section titled “Stale-If-Error”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 normallyOnly server errors (5xx) and network failures trigger the fallback. Client errors (4xx) are always propagated.
Vary Header
Section titled “Vary Header”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: Acceptconst json = await client.get('https://api.example.com/data', { headers: { accept: 'application/json' },});
// Same Accept — cache hitconst json2 = await client.get('https://api.example.com/data', { headers: { accept: 'application/json' },});
// Different Accept — cache miss, re-fetchesconst 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.
Overrides
Section titled “Overrides”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 }, },);| Override | Effect |
|---|---|
ignoreNoStore | Caches responses that have Cache-Control: no-store |
ignoreNoCache | Returns cached values without revalidation even when no-cache is set |
minimumTTL | Floor on the header-derived store TTL (seconds) |
maximumTTL | Cap on the header-derived store TTL (seconds) |
Private Cache Semantics
Section titled “Private Cache Semantics”HttpClient operates as a private cache (a client library, not a shared proxy), so:
Cache-Control: privateresponses are cacheable (we are the private cache)s-maxageis parsed but ignored for freshness calculationsproxy-revalidateis parsed but ignored
Store Options
Section titled “Store Options”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.
import { SQLiteCacheStore } from '@http-client-toolkit/store-sqlite';
const cache = new SQLiteCacheStore({ database: './cache.db', // File path or Database instance cleanupIntervalMs: 60_000, // Set to 0 to disable maxEntrySizeBytes: 5_242_880, // 5 MiB per entry});SQLite stores persist to disk, so cached data survives process restarts. Multiple stores can share a single database file by passing a better-sqlite3 Database instance.
import { DynamoDBCacheStore } from '@http-client-toolkit/store-dynamodb';
const cache = new DynamoDBCacheStore({ client: dynamoClient, tableName: 'http-client-toolkit', maxEntrySizeBytes: 390 * 1024, // 390 KB (DynamoDB 400 KB limit minus overhead)});DynamoDB uses native TTL for automatic item expiration — no cleanup timers needed. Note that DynamoDB TTL deletion can be delayed up to 48 hours, but stores filter expired items on read.
Memory Management
Section titled “Memory Management”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 downcache.destroy();DynamoDB stores don’t use timers, so no explicit cleanup is needed.