●MCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructure●EXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioning●ADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applications●QUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the window●PRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days out●FIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attribution●MCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructure●EXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioning●ADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applications●QUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the window●PRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days out●FIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attribution
Fixing the Code Doesn't Evict a Broken Page From the Edge Cache
I shipped a fix and the broken page kept serving. The problem was not the code but the edge cache, which had stored a broken HTML response because the store decision looked only at the status code. Here is the integrity guard I now run before every cache write, and the thresholds I had to tune in production.
One morning I pushed a fix for a layout break on an article page. The build passed. The deploy succeeded. On my machine the page rendered correctly.
On a different device it was still broken.
A private window showed the same thing. Deploying again changed nothing. I reread the code several times looking for the mistake I must have made, and found nothing, because there was nothing to find. At that point I still had not suspected the cache. The response was returning 200.
The cache was the problem. A broken HTML response had been stored as a healthy one, and it was being served to everyone. My corrected code was sitting behind it, waiting for a request that never reached it.
The blind spot: broken pages that return 200
When you write a cache store decision, the natural thing to check is the status code. That is what I had done. Store 2xx, skip 4xx and 5xx. It is simple, and most of the time it is correct.
The trouble is that an application can be broken and still return 200.
In the Next.js App Router, an exception during rendering hands off to an error boundary (error.tsx or global-error.tsx), which renders a fallback UI. From HTTP's point of view that is a successful response: status 200. The same thing happens when only the article body fails to load and the surrounding shell renders fine. The page exists. There is nothing in it.
A status code tells you that a response was produced. It says nothing about whether that response is worth reading.
Response state
HTTP status
Status-based decision
What the reader gets
Healthy page
200
Store
Fine
Error boundary fallback
200
Store
Broken
Shell rendered, body empty
200
Store
Broken
Truncated HTML stream
200
Store
Broken
Server error
500
Skip
Broken, but refetched
Rows two through four are the hole I fell into. Once stored, those responses stay until the TTL expires or someone purges them. The underlying fault may have lasted a few hundred milliseconds; the outage lasts hours.
What makes this hard to reason about is that the incident is not ongoing. It is frozen. Your logs are clean. You cannot reproduce it. And readers still see a broken page.
The trigger is almost always a momentary failure
It is worth explaining where the broken HTML came from.
The four sites I run are Next.js applications deployed on Cloudflare Workers. Article bodies are stored as separate HTML files and read through the static asset binding rather than bundled into the Worker, which is what keeps the bundle under the size limit. I wrote about that arrangement separately in the 62 MiB limit and content split architecture.
That design adds a step: fetching the body is a read that can fail. In the seconds right after a deploy, or occasionally for no visible reason, that read came back empty. It was rare. Not something I saw daily.
Rarity turned out to be no protection at all. A cache converts a rare failure into a permanent state. If one request in ten thousand fails and that one gets stored, the other nine thousand nine hundred and ninety-nine see the failure.
This asymmetry is easy to miss when you are reasoning about probability. I missed it.
✦
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 will be able to decide what belongs in your cache based on whether the body is complete, not on whether the status code happens to be 200
✦You will be able to stop a one-in-ten-thousand render failure from becoming a page that every visitor sees, using a single guard at the write path
✦You will be able to tell within minutes whether a fix that appears not to work is a code problem or a cache problem
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.
The fix is unglamorous. Before writing to the cache, check whether the HTML is a finished page. Status code remains a precondition, but it is no longer the decision.
I settled on three conditions.
1. No error boundary marker
Put a machine-detectable attribute on the error boundary component. As an attribute, it has no visual effect.
// app/[locale]/error.tsx'use client'export default function Error({ reset }: { error: Error; reset: () => void }) { return ( <div data-error-boundary="1" className="mx-auto max-w-2xl px-6 py-24"> <h1 className="text-xl font-semibold">This page could not be displayed</h1> <p className="mt-4 text-sm opacity-80">Please try again in a moment.</p> <button onClick={reset} className="mt-6 underline"> Reload </button> </div> )}
Readers never see data-error-boundary, but the cache layer always can. Add the same attribute to global-error.tsx. You can redesign the error screen freely afterwards without breaking detection.
2. The HTML reached its end
A truncated stream will not contain </html>. Checking for the terminator catches a surprising number of severed responses on its own.
3. The body container is not empty
Verify that the element holding the article body actually has content in it. This is the condition that catches a correctly structured page with nothing inside.
Those three fold into one function.
// cache-worker.jsconst ERROR_MARKER = 'data-error-boundary'const ARTICLE_CONTAINER = /<div[^>]+id="article-content"[^>]*>([\s\S]*?)<\/div>/// Decide whether this HTML is safe to storefunction isCacheableHtml(html, { requireArticle }) { if (!html || html.length < 512) return false // implausibly short if (html.includes(ERROR_MARKER)) return false // error boundary output if (!html.includes('</html>')) return false // truncated if (requireArticle) { const m = html.match(ARTICLE_CONTAINER) if (!m) return false // strip tags and see whether real text remains const text = m[1].replace(/<[^>]*>/g, '').trim() if (text.length < 200) return false } return true}
requireArticle is a parameter because applying the body check to listings, tag pages, and the home page produces false positives. Those pages have no article-content element. Different page types get different requirements.
The write path always goes through it.
async function handleRequest(request, env, ctx) { const cache = caches.default const cached = await cache.match(request) if (cached) return cached const response = await fetch(request) const contentType = response.headers.get('content-type') || '' // Non-HTML keeps the old decision if (!contentType.includes('text/html')) { if (response.ok) ctx.waitUntil(cache.put(request, response.clone())) return response } // For HTML, read the body before deciding const body = await response.clone().text() const url = new URL(request.url) const requireArticle = /\/articles\/[^/]+\/[^/]+$/.test(url.pathname) if (response.ok && isCacheableHtml(body, { requireArticle })) { ctx.waitUntil(cache.put(request, response.clone())) } return response}
Note that a rejected response is still returned to the reader. Showing one person a broken page and showing everyone a broken page indefinitely are different failures. If you block the response instead, a momentary glitch becomes an availability problem. What you want to stop is not the delivery. It is the freezing.
Say no-store on 5xx explicitly
There was a second gap. My error responses carried no cache directives at all.
My own cache layer never stores 5xx. But in front of it sit browser caches and whatever intermediaries a reader happens to be behind. If you do not state your intent in a header, those layers are free to make their own decision. I spent a while puzzling over reports where a single device kept showing an error screen long after the fix had shipped.
// wherever you construct the error responsereturn new Response(errorHtml, { status: 500, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store, must-revalidate', },})
One line. Without it you have no idea how many copies of your worst moment are sitting on other people's machines. Error responses should be short-lived, and you have to say so yourself.
Treat the cause once, but only once
The cache guard contains the damage. It does not stop broken HTML from being produced.
So I added exactly one retry to the asset read.
async function readStaticAsset(env: Env, path: string): Promise<string | null> { for (let attempt = 0; attempt < 2; attempt++) { try { const res = await env.ASSETS.fetch(new URL(path, 'https://assets.local')) if (res.ok) { const text = await res.text() if (text.length > 0) return text } } catch { // fall through to the next attempt } if (attempt === 0) await new Promise((r) => setTimeout(r, 50)) } return null}
Two attempts, not five. Retrying only helps against transient failures. Against a permanent one — a missing file, a wrong path — additional attempts add latency and change nothing. One 50ms pause, and if that does not do it, fail honestly and let the guard do its job.
Returning null rather than an empty string is deliberate. An empty string renders as an article with no body, which is precisely the HTML you did not want to cache. Represent failure in the type and let the caller escalate it.
The thresholds were tuned in production
Right after deploying the guard I noticed something I had not planned for. Refusing to store responses raises origin load. Make the check too strict and healthy pages go back to the origin on every request.
The number that needed tuning was the body-length threshold.
Threshold
What happened
Verdict
1,000 chars after tag removal
Short posts and premium previews were rejected; hit rate dropped visibly
Pages where only the body fetch failed slipped through
Too loose
Premium articles show a portion of the body and hide the rest, so the visible preview on a shorter post yields less text than you would guess. Pick a general-purpose number without knowing your own page structure and this is where it bites.
Rather than guessing, sample your actual output and look at the distribution. Pulling a handful of live pages through the same extraction is enough.
# fetch representative pages and measure what the guard would seefor path in /articles/claude-code/some-slug /articles/claude-ai/other-slug /articles; do len=$(curl -s "https://example.com${path}" \ | sed -n 's/.*id="article-content"[^>]*>\(.*\)<\/div>.*/\1/p' \ | sed 's/<[^>]*>//g' | tr -d '[:space:]' | wc -c) echo "${path} -> ${len}"done
The 200 I chose after looking at real numbers has held up. The 1,000 I chose beforehand was pure invention.
Triage when a fix appears not to work
Here is the order I now follow when a deploy seems to have no effect.
1. Fetch around the cache
Adding a query parameter is usually enough to make a cache treat the request as a different URL.
curl -s "https://example.com/articles/claude-code/some-slug?cb=$(date +%s)" | head -c 400
If the correct HTML comes back, your code is fine and the cache is the suspect. If it is still broken, the problem is upstream in your application. One command cuts the search space in half.
2. Read the cache headers
Check cf-cache-status, or whatever marker you set yourself.
A HIT means you are looking at stored content, and age tells you roughly when it was stored. If that lines up with the window when things were broken, you have your answer.
3. Invalidate
I include a deploy version constant in the cache key, so changing that value clears everything at once. It is more reliable than purging individual URLs and there is no procedure to remember.
I change it only when I need to, not on every deploy. Bumping it every time leaves the cache permanently cold, which defeats the purpose of having one.
What I delegated, and what I decided myself
Claude Code wrote most of this: the predicate, the retry helper, the header changes. The patterns were clear and my conditions were already settled, so the implementation took very little time.
Two things I would not hand over.
The first is whether a response you refuse to store should still be returned to the reader. That is a trade between availability and correctness, and the right answer depends on what kind of site you run. I decided that one person seeing a broken page is acceptable while everyone seeing it indefinitely is not. Ask for an implementation without settling that first and you tend to get the cautious version, where the response is blocked and a transient glitch becomes downtime.
The second is the threshold. Two hundred characters came from looking at my own output. Nothing about a premium preview being short is visible from outside my codebase.
As the writing gets faster, the share of my time spent deciding what counts as correct has gone up. There is a risk in that speed too: the implementation can be finished before the decision has been made. Lately I write the conditions down on paper before I ask for anything.
One thing to check today
If you take one thing from this, make it this one.
Look at what your cache layer uses to decide whether to store a response. If the answer is the status code alone, add a body integrity check next to it. Put a marker on your error boundary and refuse to store any HTML containing it. Those two small changes stop most of what I described here.
A cache exists to deliver the right thing quickly. The same mechanism delivers the wrong thing just as quickly. Deciding what is allowed onto that path is the part that stays with you.
Thank you for reading. I hope it saves you a confusing morning.
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.