Skip to content

services cache

Eugene Lazutkin edited this page Jul 10, 2026 · 7 revisions

Cache

io.cache is an application-governed response cache for GET requests, attached and on by default. It is not an HTTP cache: it does not read Cache-Control or max-age. TTLs come from your call, the app decides what to store, and the app decides when to sweep.

import io from 'double-meh';

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

On by default, opting out

GETs are cached by default; cache: false opts a request out. Use cache: {ttl} (milliseconds) to override the default TTL, or cache: true to force caching for a request the default predicate excludes. Hard gates apply regardless: only GET is ever cached (cache: true on a POST does nothing), and stream: true or bust requests are never stored.

await io.get('https://example.com/c'); // cached with the default TTL
await io.get('https://example.com/c', null, {cache: {ttl: 30_000}});
await io.get('https://example.com/c', null, {cache: false}); // straight to the network

io.cache.theDefault — a boolean or a predicate (options) => boolean — decides which requests are cached when they don't say. The shipped default is options => !options.transport: every GET on the default transport.

io.cache.theDefault = options => options.url.startsWith('https://api.example.com/');

App-governed TTL

io.cache.defaultTtl is 5 minutes. Each entry stores an absolute expiresAt; a fresh entry is served directly, an expired one is refetched. Pass ttl: Infinity to keep an entry until you evict it, or ttl: 0 to force a refetch every time.

io.cache.defaultTtl = 60_000;
await io.get('https://example.com/t', null, {cache: {ttl: 0}}); // always refetched

304 revalidation

When a stale entry carries an ETag or Last-Modified, the next read attaches If-None-Match / If-Modified-Since. A 304 Not Modified reuses the stored body, merges the 304's headers into the stored entry (all but content-length), and refreshes the entry's expiresAt — no re-download.

await io.get('https://example.com/r', null, {cache: {ttl: 0}});
// stale entry with ETag "v1" → conditional request → 304 → cached body reused
const body = await io.get('https://example.com/r', null, {cache: {ttl: 0}});

Only ok (2xx) responses are ever stored; error responses pass straight through.

Representations and Vary

Cache identity is representation-aware on both sides of the wire:

  • Request side — the effective Accept extends the cache key (see keys & URLs), so a JSON read and a CSV read of the same URL are two entries that coexist; neither overwrites the other.
  • Response side — when a response names selecting headers via Vary (say Vary: Accept-Language), the entry snapshots the request's values for them. A later read whose values differ is a miss and refetches (one variant per key: the new response replaces the old). Vary: * marks the response uncacheable — it is never stored.
await io.get('/report'); // JSON entry
await io.get('/report', null, {accept: 'text/csv'}); // CSV entry — both cached
await io.get('/doc', null, {headers: {'accept-language': 'de'}});
// if the response says Vary: Accept-Language, an 'en' read refetches instead of serving German

Invalidation with remove

io.cache.remove(pattern) evicts by:

  • an exact URL string,
  • a trailing-* prefix string,
  • a RegExp tested against the stored key,
  • a function (key) => boolean.
await io.cache.remove('https://example.com/users/42'); // exact
await io.cache.remove('https://example.com/users/*'); // prefix
await io.cache.remove(/\/users\//); // RegExp on the key
await io.cache.remove(key => key.includes('draft')); // predicate

An exact URL owns all of its accept variants — removing it evicts the JSON and the CSV entry alike (io.update's invalidation relies on this).

With the invalidation channel installed, the string forms also propagate to other tabs (and a controlling service worker's shared tier); RegExp and predicate removals stay local.

Clearing and sweeping

io.cache.clear() drops everything. io.cache.sweep() deletes only expired entries — it is app-called, nothing runs it for you, so call it on your own schedule (e.g. an interval or idle callback).

await io.cache.clear();
await io.cache.sweep(); // purge entries past their expiresAt

Swappable storage

The backend lives at io.cache.storage and defaults to an in-memory Map. Any object implementing get / set / delete / clear / keys (sync or async) can replace it. Persistent backends ship with the library — filesystem and SQLite for CLIs, the Cache API for browsers — see Storage backends.

import {fsStorage} from 'double-meh/storage/fs.js';

io.cache.storage = fsStorage({name: 'my-app'}); // survives runs, shared across processes

Pre-populating with save

io.cache.save(target, response, ttl?) stores a response directly. io.adopt uses it under the hood, so an adopted GET is durable past its in-flight window and later reads hit without a network call.

The adopted envelope resolves without waiting for the storage write — a slow persistent backend never delays adopters. When ordering matters (a test asserting the very next GET hits the cache), io.cache.idle() awaits all outstanding writes.

await io.adopt('https://example.com/cf', json({from: 'prefetch'}));
await io.cache.idle(); // storage write landed
const data = await io.get('https://example.com/cf'); // no network

Attaching

The main entry attaches the cache for you. io.cache.detach() removes the service; io.cache.attach() reinstalls it. Both maintain io.cache.isActive and return io for chaining.

io.cache.detach();
io.cache.attach();

See also

Clone this wiki locally