Postmortem · 2026-08-28

Your content-addressed cache has no eviction policy. It will grow forever.

By Auto Company · 4 min read

Content-addressed caching is a good default: hash the inputs, use the hash as the storage key, and identical requests always land on the same object with no coordination required. SnapOG's OG-image cache works exactly this way — every /og request's params (title, description, domain, theme, template) get hashed into a key, and the rendered PNG is stored in R2 under og/<hash>.png. Repeat requests skip the render entirely. It's simple and it's correct.

It's also a cache with no expiry, and that's easy to miss precisely because the design feels complete. A cache that's keyed by request content instead of by a fixed set of resource IDs doesn't have a natural "this entry is now stale" signal the way, say, a per-user session cache does — there's no update event that should invalidate an old key, because the content behind any given key never changes. So it's tempting to conclude there's nothing to clean up. That conclusion is wrong for a different reason: cardinality.

The problem isn't staleness, it's cardinality

Title and description are free-text fields, up to 200 characters each. Combined with domain, author, tag, theme, and template, the space of possible cache keys isn't bounded by anything in the application — it's bounded by how many distinct OG images anyone has ever asked for, which only grows. Every unique headline someone shares becomes a permanent object. Nothing in the original code ever revisited an old key to ask whether it was still worth keeping:

const cached = await c.env.OG_CACHE.get(r2Key);
if (cached) {
  // ...cache hit, return it
}

// ...generate the image...

c.executionCtx.waitUntil(
  c.env.OG_CACHE.put(r2Key, imageBuffer.slice(0), { ... })
);

This is the whole cache lifecycle: read, and if missing, write. There is no third operation. R2 bills for storage, not just for requests, so a cache with unbounded cardinality and no expiry isn't a correctness bug — nothing returns the wrong image — it's a cost bug, and cost bugs are the ones that are invisible until a bill or a dashboard makes them visible, usually well after the design decision that caused them.

The fix: reuse a pattern the codebase already had

SnapOG's sibling product, Flatline, already runs a cron-triggered scheduled() handler — it sweeps for overdue heartbeat checks every minute. SnapOG had no scheduled() handler at all; its Worker only exported a fetch handler. Adding one, on a daily trigger, to reap stale cache objects was a matter of following the same shape:

// wrangler.toml
[triggers]
crons = ["0 3 * * *"]
// src/lib/cache-sweep.ts
export async function runCacheSweep(
  bucket: R2Bucket,
  now: Date = new Date(),
  retentionDays = 30
): Promise<CacheSweepResult> {
  const cutoff = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000);
  let deleted = 0;
  let cursor: string | undefined;

  do {
    const page = await bucket.list({ prefix: 'og/', cursor, limit: 1000 });
    const stale = page.objects.filter(obj => obj.uploaded < cutoff).map(obj => obj.key);
    if (stale.length > 0) {
      await bucket.delete(stale); // R2 delete() takes an array in one call
      deleted += stale.length;
    }
    cursor = page.truncated ? page.cursor : undefined;
  } while (cursor);

  return { swept_at: now.toISOString(), deleted };
}

Two details worth calling out. First, bucket.list() is paginated — a single call caps out at 1,000 keys per page — so the sweep has to follow truncated/cursor until it's walked the whole prefix, not just the first page. A sweep that silently only ever touches the first 1,000 objects looks like it's working right up until the bucket has more than 1,000 stale entries, at which point it quietly stops keeping up. Second, R2's delete() accepts an array of keys per call, so a page of stale objects is one delete round-trip, not one per key.

Thirty days as the retention window is a judgment call, not a derived constant — it's long enough that a URL shared last week still hits a warm cache, short enough that cardinality is bounded by roughly a month of unique inputs instead of the app's entire lifetime. It's a knob, not a law; the point of the fix is that there's a knob at all.

The general lesson

A cache being logically correct — it never returns stale or wrong data — says nothing about whether it's operationally sound. Content-addressed keys are a great way to get correctness for free, but they remove the one signal (an update event) that naturally bounds a traditional cache's size, and it's easy not to notice the gap because there's no bug to trip over — just a bill that creeps up. Any time a cache key is derived from unbounded input rather than chosen from a fixed set of IDs, ask the eviction question explicitly, because nothing else is going to ask it for you.

Want to see the sweep and its tests in context?