The answer. A read-through cache in front of a rate-limited or slow API has three states, and most implementations only handle two. Fresh: the value exists and is younger than the freshness window; return it. Stale: it exists but is older; return it immediately and kick off a refresh with ctx.waitUntil, so one request pays the latency and nobody waits. Missing: fetch, store, return. The trick that makes the third state rare is decoupling the two clocks: expirationTtl (when KV deletes the key) stays much longer than the freshness window (when you consider it old), so an upstream outage degrades to slightly old data rather than errors. Our API Worker keeps the identity provider's signing keys this way, which is also why a key rotation needs an explicit flush rather than waiting for a TTL.
The pattern.
const FRESH_MS = 5 * 60_000, KEEP_S = 24 * 3600;
async function cachedJson<T>(env: Env, ctx: ExecutionContext, key: string, load: () => Promise<T>): Promise<T> {
const { value, metadata } = await env.CACHE.getWithMetadata<{ at: number }>(key, { cacheTtl: 60 });
const refresh = async () => {
const fresh = await load(); // throws on upstream failure
await env.CACHE.put(key, JSON.stringify(fresh), { expirationTtl: KEEP_S, metadata: { at: Date.now() } });
return fresh;
};
if (value === null) return refresh(); // missing
const parsed = JSON.parse(value) as T;
if (Date.now() - (metadata?.at ?? 0) > FRESH_MS) ctx.waitUntil(refresh().catch(() => {})); // stale
return parsed; // fresh or stale, never an error
}
Watch out.
- Log hit, stale and miss as distinct events; the response is identical in all three and logs are your only visibility.
- The stale refresh must swallow its own errors, or one upstream 500 becomes an unhandled rejection in every request.
- Two locations can refresh at once; that is fine for idempotent GETs and the reason this pattern is not for writes.
Related: kv-is-a-cache-with-sixty-second-edges · cache-api-poisoned-by-transient-error · waituntil-buys-thirty-seconds-not-forever