An R2 cache for OpenNext keeps growing because each deploy writes into a fresh folder named after that release, and nothing in the adapter ever deletes a previous folder. To clean it safely you must learn which release currently serves traffic, spare it together with a few recent neighbours, and run the deletion as a dry run before the real thing. On one Cloudflare account that removed about 41 GB.

Method

It began with a plain question from the owner: how much R2 space do we use? We counted 29 buckets and about 47 GB. Nearly all of it was the page cache of eight Next.js sites running on OpenNext, with five staging Workers beside them.

We then opened the adapter code that ships in release 1.20.1. Every cache entry is stored under a key made of a root prefix, a release identifier, a hash of the entry and a suffix for its kind. The adapter exposes read, write and delete for a single key, and that is all. The OpenNext caching page explains the prefix setting and does not mention what happens to earlier releases.

Finding what is live. Every rendered page embeds the release identifier in its script payload under the short key "b". We downloaded two pages per site and added a random query string, so a cache could not hand us an older copy, and both pages had to agree. We never trusted the build file on a developer machine: on one project, the id stored locally had never reached production at all.

What stays. The live folder, the two most recent of the rest, and every folder touched during the last 7 days. If no live id can be read, the three most recent folders stay, plus the same week. The week is our own choice; no vendor states it. A custom prefix setting moves the whole cache to another root folder, so list that root, not the default one.

Why not simply set an expiry. R2 can drop objects by age, applied about a day later (lifecycle docs). But age says nothing about whether a release is still serving. Such a rule would also remove the oldest entries inside the running release. A site that queries its database whenever a page is rendered then pays for every one of those re-renders. Deleting by folder avoids that.

Results

The biggest bucket held 149 build folders, 118 300 objects and 21.44 GB. The running release was only 5 016 objects and 0.46 GB, about 2 %. Summed over 13 cache buckets, the orphans came to 185 634 objects, 40.87 GB.

The dry run came first. The actual deletion then removed exactly the number the dry run had predicted. In every spared folder a control object kept its checksum and size, and each site still answered 200. One listing call failed with R2 error 10058, a transient plea to slow down; that run refused to continue and deleted nothing, and the next attempt went through.

Traps in the adapter and in the tooling

  • A deployment id replaces the folder name. When one is set in the Next config, the folder carries that value instead of the page's release id. If it is a constant, every deploy lands in the same folder, so folder-level cleanup has nothing to do.
  • A folder called no-build-id. The identifier was missing when the cache was written. Cleaning around such a folder is guessing, so you stop and fix the cause.
  • KV has the same layout. There, deletes and listings are billed beyond one million a month on the paid plan (KV pricing), and capped at 1 000 a day on the free one.
  • A CLI that shows part of the list. wrangler r2 bucket list printed 20 of 29 buckets; the REST API returned all 29.

What it is worth in money

Deleting objects in R2 is free, and each stored gigabyte costs 1.5 cents a month beyond the first 10 GB (R2 pricing). Our account held about 47 GB, so roughly 37 GB was over the free amount: about 55 cents a month. After the cleanup the total is near 6 GB, under the free 10 GB. That is small, and we will not call it a saving worth a headline. It does grow with every deploy until something clears it.

Turning it into a skill

We turned these rules into a checklist for a model and tested it on 29 pasted setups: 17 needing a cleanup, 3 with nothing to delete and 9 where the right move was to refuse. The skill is opennext-r2-cache-cleanup. Sonnet rose from 23 correct to 28, and Haiku from 27 to 28.

Sonnet alone did well on the general ideas: it located the live release from page HTML, spared the earlier folder that is still serving after a rollback, and the half-filled folder of a deploy underway, and declined to delete the running release. It failed on things that exist only in the adapter code. With a deployment id it declared the live release unknown, it carried on deleting next to a no-build-id folder, and it cleaned a bucket whose listing never showed the live id.

One plain caveat on copying. A model must write folder names back out, and a mistyped name either misses its folder or leaves a folder it meant to keep unprotected. Sonnet got three names wrong without the skill (two dropped letters and one invisible zero-width character) and one with it. We scored each as a wrong answer.

What we did not measure

  • One account. Eight sites with a single owner; other deployments may lay things out differently.
  • One version. Everything about keys comes from release 1.20.1 of the adapter, and a later one may change the layout.
  • Our own setups. We wrote all 29 test cases ourselves and ran each model once per case, so a single result could move on another run.
  • The window. Seven days and two spare folders are our judgement; we did not try other values.
  • The bill. The 55 cents is arithmetic on the published rate and our measured total, not an invoice we read.

Read this post as Markdown: /blog/opennext-r2-cache-old-builds-cleanup.md · Atom feed.