Cache upstream JSON in KV with a TTL; serve stale when upstream fails

Store the payload with expirationTtl longer than its freshness window and fetchedAt in metadata: fresh serves, stale serves and refreshes, outage serves stale.

KV/R2/D1

· Chapter

49

·

3

min read

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