Skip to content

Cookbook: caching

Eugene Lazutkin edited this page Jul 3, 2026 · 2 revisions

Cookbook: Caching

double-meh ships an in-memory GET cache that is on by default — plain GETs are stored and served from memory, and cache: false opts a request out. It also revalidates stale entries with ETags, so a 304 Not Modified reuses the cached body for free.

Attach and detach

The cache is already attached when you import io from 'double-meh'. Call attach() / detach() only to toggle it globally.

import io from 'double-meh';

io.cache.attach(); // no-op by default; the cache is already on
io.cache.detach(); // turn caching off process-wide

GETs are cached by default

A plain GET stores the response and serves later identical GETs from memory (default TTL is 5 minutes, io.cache.defaultTtl) — no option needed. Only GETs are ever cached — cache: true on a write does nothing — and streamed (stream: true) or cache-busted (bust) requests are never stored.

const a = await io.get('https://api.example.com/config');
const b = await io.get('https://api.example.com/config');
// second call is served from the cache — no network request

Set a TTL

Pass an object to control freshness. {ttl: 0} stores the entry but forces revalidation on the next read; a large TTL keeps a rarely-changing resource around.

await io.get('https://api.example.com/countries', null, {cache: {ttl: 24 * 60 * 60 * 1000}});

When a stale entry carries an ETag (or Last-Modified), the next read sends a conditional request; a 304 merges the fresh headers into the entry (except content-length), refreshes the TTL, and reuses the stored body.

Skip the cache for one request

cache: false bypasses the cache for a single call — handy for a forced refresh while caching stays on globally.

const fresh = await io.get('https://api.example.com/config', null, {cache: false});

Scope the default

io.cache.theDefault — a boolean or a predicate (options) => boolean — decides which GETs are cached when a request does not say. The shipped default is options => !options.transport (every GET on the default transport). A per-request cache option always wins; cache: true pulls a request back in when the default excludes it.

io.cache.theDefault = options => options.url.startsWith('https://api.example.com/'); // narrow it
io.cache.theDefault = false; // nothing is cached unless a request passes cache: true / {ttl: ms}

Defeat intermediary caches

bust: true appends a uniquifying query parameter (io-bust=<unique>) so proxies and the browser's HTTP cache cannot serve a stale copy; bust: 'name' picks the parameter name. Busted requests are never stored in io.cache.

const latest = await io.get('https://api.example.com/config', null, {bust: true});
// -> GET /config?io-bust=… — every intermediary sees a fresh URL

Invalidate entries

io.cache.remove(target) accepts three shapes. A plain URL evicts that exact entry; a string ending in * evicts by prefix; a RegExp or a predicate is matched against the cache key, which has the form GET <canonical-url> (query sorted, fragment dropped).

await io.cache.remove('https://api.example.com/users/42'); // exact URL
await io.cache.remove('https://api.example.com/users/*'); // prefix
await io.cache.remove(/\/users\//); // RegExp over the key
await io.cache.remove(key => key.includes('draft')); // predicate over the key

Clear everything or sweep the expired

clear() drops the whole cache; sweep() removes only entries past their TTL, which is useful to run on a timer to reclaim memory.

await io.cache.clear(); // wipe all entries
await io.cache.sweep(); // drop only expired entries

setInterval(() => io.cache.sweep(), 60 * 1000);

See also

Clone this wiki locally