●2.1.302 — Fork subagents are now on by default in claude -p, the Agent SDK and the desktop app. Turn them off with CLAUDE_CODE_FORK_SUBAGENT=0 (10/16)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 38 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.302 — Fork subagents are now on by default in claude -p, the Agent SDK and the desktop app. Turn them off with CLAUDE_CODE_FORK_SUBAGENT=0 (10/16)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 38 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
I Recounted the Bytes: Why Keeping Article Bodies Out of My Worker Bundle Still Holds Up at About 900 Articles
On my own Next.js site running on Cloudflare Workers, I recounted the bytes of a bundle that carries article bodies versus one with metadata only. Here are the headroom, a breakdown I had not expected, and a script that keeps counting.
One morning I tried to say, out loud, how many bytes src/generated/articles.json is on my own site, and I could not.
I'm an indie developer, and I run a site built on Next.js and deployed to Cloudflare Workers. Articles are written in MDX, converted to HTML before the build, and served as static assets. Only metadata such as the title and description goes into the JSON. I had a feeling that this split was the safe one, but I could not back that feeling with a number.
This post is the recount, done on the files in my repository. The short version: with bodies inside the JSON it would be about 28.5 MiB, and with metadata only it is about 0.93 MiB. What surprised me, though, was not the headroom but what the metadata itself is made of.
Checking the limit first
Cloudflare's documentation says a Worker may be up to 64 MiB uncompressed. There is no compressed-size limit, and the gzip figure Wrangler prints is for reference only. For static assets, a single file may be up to 25 MiB, and a version may hold 20,000 files on the free plan or 100,000 on the paid plan.
You will still find older advice quoting "3 MB / 10 MB compressed." That was the number in my head too, until I opened the current page. Limits change, so I check the value before I start counting.
The count: bundle the same articles two ways
The question is simple: how many bytes if bodies are in the JSON, and how many if they are not? You can approximate it without running the build, just by reading front matter and body from the MDX files.
#!/usr/bin/env python3"""Count the headroom when article bodies are inside JSON versus metadata only.Usage: python3 bundle_guard.py <repo> [new articles per month]"""import json, os, re, sysLIMIT = 64 * 1024 * 1024 # Workers uncompressed bundle limit (per the docs)META_ALERT = 2 * 1024 * 1024 # my own line: revisit the design above thisdef read_articles(root, locale): base = os.path.join(root, "content", "articles", locale) for cat in sorted(os.listdir(base)): d = os.path.join(base, cat) if not os.path.isdir(d): continue for name in sorted(os.listdir(d)): if not name.endswith(".mdx"): continue text = open(os.path.join(d, name), encoding="utf-8").read() m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.S) if not m: # report broken front matter instead of silently skipping print("skip:", locale, cat, name, file=sys.stderr) continue yield cat, name[:-4], m.group(1), m.group(2)def field(fm, key): m = re.search(rf'^{key}:\s*"?(.*?)"?\s*$', fm, re.M) return m.group(1) if m else ""def main(root, per_month=40): meta_total = full_total = pairs = 0 for loc in ("ja", "en"): meta, full = [], [] for cat, slug, fm, body in read_articles(root, loc): row = {k: field(fm, k) for k in ("title", "level", "date", "description")} row.update(slug=slug, category=cat) meta.append(row) full.append({**row, "content": body}) sep = (",", ":") meta_total += len(json.dumps(meta, separators=sep, ensure_ascii=False).encode()) full_total += len(json.dumps(full, separators=sep, ensure_ascii=False).encode()) pairs = max(pairs, len(meta)) per_pair_full, per_pair_meta = full_total / pairs, meta_total / pairs print(f"articles per language: {pairs}") print(f"metadata only: {meta_total/1024/1024:.2f} MiB / with bodies: {full_total/1024/1024:.2f} MiB") print(f"headroom with bodies: ~{(LIMIT-full_total)/per_pair_full:.0f} pairs = {(LIMIT-full_total)/per_pair_full/per_month:.1f} months") print(f"headroom, metadata only: ~{(LIMIT-meta_total)/per_pair_meta:.0f} pairs") return 1 if meta_total > META_ALERT else 0if __name__ == "__main__": sys.exit(main(sys.argv[1], float(sys.argv[2]) if len(sys.argv) > 2 else 40))
Run against my repository (917 articles per language, Japanese and English), the output looked like this. The labels are English here, but the numbers are what the script printed.
articles per language: 917
metadata only: 0.93 MiB / with bodies: 28.54 MiB
headroom with bodies: ~1140 pairs = 28.5 months
headroom, metadata only: ~62334 pairs
A "pair" is one Japanese article plus its English twin. At my recent pace of roughly 40 articles a month, even the body-in-JSON layout would last over two years on paper.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can estimate, from files already on your disk, how fast a JSON that holds your content grows toward the limit in your own Next.js site
✦You can catch the size-related failure where the build passes and only the deploy fails, months before you reach the limit
✦You can count which fields make your metadata JSON heavy and decide what to trim first, with numbers behind the order
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
One caveat: the "with bodies" row uses the Markdown source. What you would actually bundle is compiled HTML, and syntax highlighting makes that larger. I have not installed the build dependencies in this environment, so I have no measured HTML figure. Treat 28.5 MiB as a floor, and assume the real room is smaller.
There is a second trap. The 64 MiB also has to hold Next.js itself, React, and every other dependency. Deploys could start failing well before the JSON alone reaches the limit.
What I did not expect: descriptions are heavier than they look
The limit math was close to what I guessed. The surprise was inside the metadata. For the 917 Japanese entries, I counted bytes per field.
import json, re, osdef fm_of(path): t = open(path, encoding="utf-8").read() return re.match(r"^---\n(.*?)\n---\n", t, re.S).group(1)def val(fm, key): m = re.search(rf'^{key}:\s*"?(.*?)"?\s*$', fm, re.M) return m.group(1) if m else ""rows = []base = "content/articles/ja"for cat in sorted(os.listdir(base)): for f in sorted(os.listdir(f"{base}/{cat}")): fm = fm_of(f"{base}/{cat}/{f}") hl = re.search(r"^highlights:\n((?:\s+- .*\n?)+)", fm + "\n", re.M) rows.append({"title": val(fm, "title"), "description": val(fm, "description"), "highlights": hl.group(1) if hl else None})mini = len(json.dumps(rows, separators=(",", ":"), ensure_ascii=False).encode())pretty = len(json.dumps(rows, indent=2, ensure_ascii=False).encode())print("pretty", pretty, "minified", mini, "saved_pct", round(100 * (pretty - mini) / pretty, 1))print("with_highlights", sum(1 for r in rows if r["highlights"]))for k in ("title", "description", "highlights"): s = sum(len(json.dumps(r[k], ensure_ascii=False).encode()) for r in rows) print(k, s, f"{100 * s / mini:.1f}%")
Descriptions make up about 45% of these three fields, averaging 314 bytes against 117 for titles.
Highlights account for about 33%, and 424 of the 917 entries carry them. Bullets meant for member-only article cards were shipping inside the listing JSON for every entry.
Dropping indent: 2 saves only about 3.3%. I had expected much more.
This breakdown omits a few fields that the real articles.json includes, such as tags and update dates, so read it as a direction rather than an exact ledger.
What the current code does
Bodies are dropped at the end of the pre-build conversion script.
The compiled HTML is written under public/content/ and read at runtime through the ASSETS binding, with one retry so an empty body is never returned on a transient failure.
for (let attempt = 0; attempt < 2; attempt++) { try { const { env } = getCloudflareContext(); const assets = (env as any).ASSETS; if (!assets?.fetch) return ""; const res = await assets.fetch(new URL(path, "https://assets.local")); if (res.ok) return await res.text(); } catch { // fall through to retry } if (attempt === 0) await new Promise((r) => setTimeout(r, 150));}return "";
While counting I also noticed that highlights is written twice, once with a spread and once as a plain key. The later one wins, so the output is unchanged, but it is confusing to read. I'll tidy it once this post is out.
Pitfalls
File count is a separate budget. Moving bodies to static assets means one file per article. I have 1,834 MDX files plus 70 blog posts, far from the 20,000 free-plan limit, but images would change that picture.
57 articles are over 40 KB. The largest is about 64 KB in Japanese. That is nowhere near the 25 MiB per-file limit, yet it is large enough to hurt if mixed into a listing JSON.
Do not take comfort in gzip numbers. The bodies-in-JSON layout compresses to roughly 10 MB across both languages, but the limit is judged uncompressed. Reading Wrangler's gzip line as "plenty of room" is risky under the current documentation.
Deciding the order of cleanup
With the numbers in hand, I plan to work in this order, biggest contributor first.
Cap the description length. For a listing, the first 120 characters or so should be enough. The full text stays in the article page's own metadata.
Take highlights out of the listing JSON. Load only the few member-only cards that need them from a separate file.
Leave indentation for last. It saves about 3%, which is not worth giving up readability.
I start with 1 and 2, because neither changes how the site looks.
Handing this to Claude Code
Put bundle_guard.py in the repository and add one line to CLAUDE.md, for example: "Before any change that adds content to a JSON file, run bundle_guard.py; if metadata exceeds 2 MiB, stop and report." Because the script returns an exit code, it also drops straight into a hook or a pre-build check.
The line I drew
Bodies stay outside, metadata stays lean. It is cheapest to hold that line while the limit is still far away.
The next step is a small one. Pick one JSON file in your own site that bundles content, and count its bytes. Once the number is written down, the next time something feels heavy you can talk in differences rather than impressions. I plan to start with descriptions and highlights.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.