A React atom dissolving into Astro SSR HTML output that streams into an edge caching layer inside a Cloudflare Workers cube.

Zero-JS Wasn't Enough: Edge Caching SSR HTML on Cloudflare Workers

The sequel to the React-islands teardown: why the Lighthouse score kept swinging on a zero-JS site, and the two-day hunt through a hanging Zaraz beacon, a zod-vs-CSP conflict, and a cookie-gated HTML edge cache that ended it.

#astro #cloudflare workers #edge caching #cache api #performance #core web vitals #ttfb #zod #csp #migration

The previous article ended with a confession: same build, two PageSpeed runs two hours apart, desktop Performance 99 and then 90. I blamed first-screen hydration and removed it, all of it, React included. Then I ran PageSpeed four times in ten minutes on the zero-JS build and watched the score land on 96, 100, 98, 96.

The jitter had survived the framework’s funeral. Whatever was moving the score was not running in the browser.

Why a zero-JS first screen still can’t stabilize your Core Web Vitals

Removing React took Total Blocking Time to zero and kept it there. What it could not touch is the other half of every lab run: the time my Worker spends producing the HTML in the first place. The site renders server-side on Cloudflare Workers, and every test paid for a fresh render - the edge-native i18n middleware, KV reads for translations, the full chain - on whatever colo the test happened to hit.

GTmetrix made the same point louder. Page onload at 1.8 seconds, Fully Loaded at 31.6, and the single HIGH-impact audit in the report reading “Root document took 1.2s”. For a site that ships no framework, that report was embarrassing in a brand-new way.

I had also promised myself a small refactor in the last article: Newsletter.astro held too much in one file and needed splitting. I sat down to do that, opened PageSpeed for a baseline, and the baseline turned into a seven-problem findings document. This is the log of those two days.

TL;DR

  • Zero-JS pinned Total Blocking Time at 0 ms; the remaining score jitter was server-render variance, with the root document at up to 1.2s
  • A cookie-gated Cache API middleware now serves anonymous HTML from the edge in ~140 ms
  • Zod v4 left the client bundle: its new Function() JIT trips a no-unsafe-eval CSP and owned most of a 22 KiB chunk
  • A hanging Zaraz audiences beacon held Fully Loaded at 31.6s; one dashboard toggle ended it
  • Lab swings never fully disappear: my medians settled at 100 mobile / 98 desktop, the floor at 91, and that residue is simulation noise

Seven findings from one PageSpeed afternoon

Before fixing anything I spent an afternoon only measuring: four PageSpeed runs minutes apart, two GTmetrix reports from Seattle, and a read through the code each finding pointed at. Everything went into a findings file committed next to the code, with one rule borrowed from the last migration: the document records problems and evidence, never solutions. Diagnosis and surgery in separate sittings keeps you from falling in love with the first fix you think of.

The afternoon produced seven entries:

#FindingEvidence
P1The SSR HTML renders fresh on every request; no edge caching at all”Reduce initial server response time - root document took 1.2s”, the only HIGH audit
P2Scores swing run to run with no deploy in betweenDesktop 96-100, GTmetrix grade B to A, while TBT stays 0 ms
P3Fully Loaded stuck at 31.6sOne analytics beacon hanging open for 29.8s in the waterfall
P4A 22 KiB chunk sits at the deepest point of the critical request chainguards.*.js, mostly zod, max path latency 1,065 ms
P5The hidden post-subscribe step ships and runs its JS on the first screenThree form scripts execute for a section nobody sees
P6A custom select forces layout on every window resize”Forced reflow” insight, 39 ms attributed to one script
P7Static assets carry no cache policy beyond one manifest entryFonts and SVGs re-validated on every visit

Two things stood out in that table. P2 is not really its own problem: when blocking time is zero and execution is 23 ms, the only mover left is how long the server takes, so P1 and P2 are one finding wearing two hats. And the scariest number on the page, the 31.6 seconds, turned out to be the cheapest fix of the whole list.

The fixes below run in the order I shipped them: the quick kills first, the architectural one last.

The 31.6-second Fully Loaded nobody noticed

GTmetrix reports two finish lines: onload, when the page is usable, and Fully Loaded, when the network finally goes quiet. Mine read 1.8 seconds and 31.6 seconds. The gap was a single request named ga-audiences, sent to google.com, sitting open for 29.8 of those seconds with the status “(incomplete)”, with a few canceled collect?t=dc calls to stats.g.doubleclick.net keeping it company.

That request had no business existing. I run analytics through Zaraz exactly so the browser never talks to Google - the architecture I compared against Partytown in an earlier teardown. These beacons were the one exception: Google Ads audience sync, which the GA4 managed component fires client-side when its “Google Analytics Audiences” setting is on.

The fix took one checkbox: Tag Management, the GA4 tool, Advanced, Google Analytics Audiences off. The trade is that Google Ads remarketing lists stop filling from the site. I do not run ads against those lists, so the feature was costing 30 lab seconds and a third-party request per visit while earning nothing.

The CSP collapsed to same-origin

Killing the audience sync had a second payoff. With it gone, I watched the Network panel through a full subscribe flow: every analytics event is a POST to /cdn-cgi/zaraz/t on my own origin, and google-analytics.com never appears. Zaraz does the Google fan-out server-side, so the browser-facing Content-Security-Policy could drop every Google origin:

# before
connect-src 'self' https://*.google-analytics.com https://stats.g.doubleclick.net https://www.google.com

# after
connect-src 'self'

The same pass removed 'unsafe-eval' and data: from script-src. The eval allowance was a fossil - Zaraz dropped its eval dependency in its July 2024 changelog - and a data: URI in script-src is an open door for injected scripts.

A stricter policy is a tripwire, and this one snapped on something I did not expect.

Zod v4’s new Function() JIT walks into a no-unsafe-eval CSP

The next Lighthouse run docked Best Practices to 96 and pointed at the DevTools Issues panel: “Content Security Policy of your site blocks the use of ‘eval’ in JavaScript”. Source: guards.DserkQ__.js, line 1. That chunk was my forms code - and I do not call eval in my forms code.

Zod does. Version 4 JIT-compiles its object parsers through new Function() at module-initialization time, which a CSP without 'unsafe-eval' counts as eval and blocks. The library catches the refusal and falls back to its slower interpreted path, so nothing broke visibly: I submitted a junk email on production and the inline error painted fine. The cost was a logged violation on every page load, a 100 score gone, and zod running deliberately slower for every visitor.

I already knew the chunk was a problem from the findings table. At 21.98 KiB, guards.*.js was the deepest node in the critical request chain - the page loads the form script, the form script loads guards, and guards is mostly zod. Maximum critical path latency: 1,065 ms, with a validation library at the bottom of it.

The decision wrote itself: zod stays on the server, where the Astro Action validates as the source of truth, and the browser gets a mirror that speaks the same protocol. The whole contract zod offers my form controller is one method:

// src/ui/forms/client-schema.ts (the contract, abridged)
export interface SchemaIssue {
  path: Array<string | number>
  message: string // an ERROR_MESSAGE_CODES value, localized later
}

export type SafeParseResult<T> = { success: true; data: T } | { success: false; error: { issues: SchemaIssue[] } }

export interface ClientSchema<T> {
  safeParse(values: unknown): SafeParseResult<T>
}

The enhanceForm controller from the previous article only ever called schema.safeParse() and read error.issues, so it did not change at all - its type signature went from z.ZodType<TValues> to ClientSchema<TValues> and that was the whole migration. Each form now ships a hand-rolled mirror, maybe twenty lines, same field names, same error codes:

const ClientNewsletterSchema: ClientSchema<NewsletterValues> = {
  safeParse(values) {
    const v = (values ?? {}) as Record<string, unknown>
    const nl_contact = str(v.nl_contact).trim()
    if (!isEmail(nl_contact)) {
      return fail([{ path: ['nl_contact'], message: ERROR_MESSAGE_CODES.INVALID_EMAIL }])
    }
    return ok({ nl_contact, source: str(v.source) })
  },
}

The isEmail helper wraps the same regex zod v4 uses for z.email(), so client and server agree byte for byte on what an email is. If a payload ever slips past the mirror, the server-side zod rejects it anyway: the mirror is UX, the action is law.

After the rebuild, the bundler dissolved the chunk entirely - the few non-zod survivors got inlined into the form script, and the critical chain lost its deepest level. Best Practices went back to 100 and stayed there.

The refactor I promised: forms move into their domain

The last article shipped with a confession in the middle: Newsletter.astro was a first pass that held the subscribe markup, the segment reveal, the no-JS branch, and the client script in one component, with a note that splitting it was queued. This was the task that started the whole two days, so it was getting done regardless of what the waterfall said.

The split followed the domain, not the component tree. The site already had src/domain/newsletter/ holding the repositories and services; the form components moved in next to them:

src/domain/newsletter/
  sources.ts  interests.ts  billing.ts   # enums shared with the server action
  segmentation-controller.ts             # step-2 client logic, loaded lazily
  components/
    Newsletter.astro        # orchestrator: form/segment/done + the no-JS result
    SubscribeForm.astro     # email capture, owns its client controller
    SegmentationForm.astro  # step-2 markup, ships no script of its own

Newsletter.astro now owns only the flow: three sibling sections and the logic that reads the native-POST result for the no-JS path. The convention I landed on is that a form is domain logic, and components/layout/ was never the right home for one.

The split also surfaced a win I kept missing while everything lived in one file. The segmentation step is hidden until someone subscribes, yet its controller used to execute on every page view, for every visitor. With the logic in its own module, deferring it is one dynamic import in the subscribe success handler:

onSuccess: async (data) => {
  formSection?.setAttribute('hidden', '')
  segmentSection?.removeAttribute('hidden')

  // Loads ONLY here, after a successful subscribe
  const { initSegmentForms } = await import('@/domain/newsletter/segmentation-controller')
  initSegmentForms()
},

Vite compiles that into a separate async chunk. For the visits that never subscribe, which is almost all of them, the step-2 code is no longer part of the page at all - the network dependency tree lost another branch.

That settled the browser side of the ledger: blocking time at zero, a flat request chain, no eval, nothing hydrating. And the score still would not sit still, because the variable was never in the browser.

Edge caching SSR HTML with the Cloudflare Workers Cache API

Here is what every request to the homepage paid for before this fix: the full middleware chain, translation reads through the i18n engine, and a complete server render - to produce HTML that is byte-identical for every anonymous visitor on that locale. The root document took up to 1.2 seconds on a bad sample. Rendering the same page thousands of times is not an architecture, it is a habit.

Why a Cache-Control header alone does nothing here

My first instinct was the classic one: set Cache-Control on the HTML response and let Cloudflare’s cache handle it. On this setup that header is a no-op, and the reason is routing.

The site runs with run_worker_first enabled for HTML routes - the Worker answers before the asset and cache layers get a look. That choice is deliberate: with assets-first routing, a cold hit on an SSR route can fall through to a wrong asset response instead of invoking the Worker, a failure mode only crawlers and cold regions ever see. I keep static files on exception patterns (!/_astro/* and friends) so they still fly straight from the edge.

The consequence: if the Worker runs first, the only HTML cache that exists is the one the Worker checks itself. That is the Cache API - caches.default, the same per-colo cache my translation layer already uses for granular KV caching.

Two cookies decide what is cacheable

Caching SSR HTML safely means answering one question precisely: what makes my HTML differ between two visitors? I read every component on the page and the answer was exactly two cookies. The newsletter cookie flips the subscribe form into its done state, and consent_status decides whether the consent banner renders.

So the policy wrote itself. A request carrying either cookie bypasses the cache entirely and gets a fresh render. A request carrying neither is anonymous, and anonymous HTML is safe to cache per locale - the locale already lives in the URL path, so there is nothing to Vary on.

Look at who sends cookie-less requests: every first-time visitor, every crawler, and every single PageSpeed or GTmetrix run. The cache serves precisely the audience where TTFB decides the impression.

One landmine sat in my own i18n middleware. It synced the language cookie on every localized response - which meant every response carried Set-Cookie, and a response with Set-Cookie must never be cached. Worse, a cookie-less first visit got the cookie created for it, so even lab runs would have produced uncacheable responses forever.

The fix is a rule worth stating in bold: a cookie-less request must produce a cookie-less response. The middleware now only re-syncs an existing lang cookie that disagrees with the URL, and the cookie itself is born client-side, in the language switcher, at the moment of an explicit choice. Crawlers and test runs never see Set-Cookie again.

The middleware, abridged

The whole thing is one middleware, first in the chain so a cache hit short-circuits everything behind it:

// src/middleware/htmlCacheMiddleware.ts (abridged)
export const htmlCacheMiddleware: MiddlewareHandler = async (context, next) => {
  if (context.request.method !== 'GET' || !isHtmlCacheEnabled()) return next()

  const cache = getEdgeCache() // caches.default; null in astro dev
  if (!cache) return next()

  // Personalized visitor: never read or write the cache
  if (PERSONALIZATION_COOKIES.some((name) => context.cookies.has(name))) {
    const response = await next()
    response.headers.set('X-Edge-Cache', 'BYPASS')
    return response
  }

  // Key = origin + pathname. The locale lives in the path; the query is
  // dropped on purpose so UTM params cannot fragment the cache.
  const url = new URL(context.request.url)
  const cacheKey = new Request(`${url.origin}${url.pathname}`, { method: 'GET' })

  const cached = await cache.match(cacheKey)
  if (cached) {
    const response = new Response(cached.body, cached)
    response.headers.set('Cache-Control', 'private, no-cache') // see next section
    response.headers.set('X-Edge-Cache', 'HIT')
    return response
  }

  const response = await next()

  const contentType = response.headers.get('content-type') ?? ''
  const cacheable = response.status === 200 && contentType.includes('text/html') && !response.headers.has('set-cookie')
  if (!cacheable) {
    response.headers.set('X-Edge-Cache', 'SKIP')
    return response
  }

  // The STORED copy carries the edge TTL; the client response does not
  const stored = new Response(response.clone().body, response)
  stored.headers.set('Cache-Control', `public, s-maxage=${EDGE_TTL_SECONDS}`)
  waitUntil(cache.put(cacheKey, stored))

  response.headers.set('Cache-Control', 'private, no-cache')
  response.headers.set('X-Edge-Cache', 'MISS')
  return response
}

A few decisions in there earn a sentence each. Only 200 + text/html responses are stored, so redirects, 404s, RSS, and the geo-dependent locale redirect on / stay fully dynamic. The write happens through waitUntil, which since August 2025 imports directly from cloudflare:workers - no threading an execution context through call sites just to schedule a background task. And the X-Edge-Cache header costs nothing while making production behavior inspectable from any browser console.

The TTL is 300 seconds. After a deploy, a colo can serve HTML up to five minutes stale, and I accepted that on purpose: the same zone already has a Purge API pipeline for translations, so if five minutes ever starts to hurt, purge-on-deploy is a small extension rather than a new system.

First measurement after shipping: root document 141 ms, with X-Edge-Cache: HIT. The 1.2-second render was still happening - once per colo per five minutes, instead of once per visitor.

The stored copy leaked s-maxage to browsers

The first version of that middleware had a bug I want on the record, because I suspect every first implementation of this pattern has it.

Testing the full flow on production: accept the consent banner, subscribe to the newsletter, get the success toast. The cookies are set, the server is doing everything right - I could fetch the page from the console and see X-Edge-Cache: BYPASS with the correct subscribed-state HTML in the body. Then I navigated to the same page normally and got the anonymous version: subscribe form, consent banner, as if the last two minutes had not happened.

The server was right and the screen was wrong, which leaves exactly one suspect: the browser cache. The Cache API only stores responses that carry caching headers, so my stored copy had Cache-Control: public, s-maxage=300 on it. On a HIT I was returning that stored response nearly as-is - s-maxage is for shared caches and browsers ignore it, but public with a cacheable response is an invitation, and Chrome accepted it. The browser kept a copy of the anonymous HTML and served it for navigations even after the cookies changed who I was.

The fix is the line the previous section already spoiled: every HTML response leaving the middleware gets Cache-Control: private, no-cache - on hits, on misses, and on bypasses too, since personalized HTML changes state in the other direction the moment a user unsubscribes. Only the copy written into caches.default carries s-maxage, and it never travels past the edge.

The browser revalidating every navigation sounds expensive until you remember where the revalidation lands: on a Worker that answers from its own cache in a hundred and forty milliseconds. Cookie-gated HTML and browser caching do not mix, and this architecture spends the browser’s cache budget where it belongs - on the immutable hashed assets.

Two ten-minute fixes: a reflow-free select and immutable assets

Two findings from the table were small enough to close between deploys, and both are worth thirty seconds of your time.

The custom select component read offsetWidth at initialization and again on every window resize, to size its dropdown. Lighthouse attributed 39 ms of forced reflow to that script - layout work for a menu nobody had opened, on a component sitting inside a hidden section, where the measurement returns zero anyway. The fix moved the measurement to the moment the menu opens, when the element is visible and the number is real, and made the resize listener bail unless the menu is currently open. The reflow insight now attributes nothing to my code.

The static assets fix was even less glamorous. My public/_headers had a cache policy for exactly one file, the web manifest. Everything Astro emits under /_astro/ carries a content hash in the filename - the hash is the version - so the policy is one stanza:

/_astro/*
  Cache-Control: public, max-age=31536000, immutable

Icons and images that are not hashed got a week with stale-while-revalidate. Fonts live under /_astro/ and inherit the year. Repeat visitors stopped revalidating files that can never change.

What two days bought, in numbers

Same URL, same tools, one day apart:

MetricBefore (2026-06-10)After (2026-06-11)
PSI Performance, mobile / desktop96-98 / 96-100, swinging run to runmedian 100 / 98 over a day of runs
PSI Best Practices96 (CSP eval violation)100
Root documentup to 1.2s - the only HIGH audit~140 ms
Critical request chain3 levels, 1,065 ms maxflat, 542 ms max
Forms chunk (guards.*.js)21.98 KiB, deepest nodegone
Fully Loaded (GTmetrix)31.6s1-2s
GTmetrix gradeswinging B to A between runsA, 99% / 99%
TBT / CLS0 ms / ~00 ms / 0 (already won last time)

The swings that remain, and why chasing them is a trap

I promised myself I would not end this article pretending the score is now nailed to 100. It is not, and understanding why matters more than the table above.

Individual runs still dip - my observed floor over a day of testing was 91 on desktop, 92 on mobile. When I opened those runs, the insights contradicted themselves: one report claimed “Server responded slowly (observed 6,643 ms)” while the same run showed TTFB at 0 ms and LCP at 0.3 seconds. Another invented a 7.2-second render delay for a heading that painted in 300 milliseconds. Numbers like that are not measurements of my server, they are artifacts of Lighthouse’s throttling simulation having a bad sample, plus the occasional cold colo paying the once-per-TTL render.

So the rule I now hold: optimize until the median is clean and every remaining dip is explainable, then stop. The render path here has nothing left to give - TTFB near zero, blocking time zero, a flat chain - and any further hour spent chasing a 93 would be spent optimizing a random number generator. The verdict that counts long-term is CrUX field data from real visitors, which a young site simply has to wait for.

One more reason the stability itself matters, beyond vanity. A score that swings 99 to 90 between runs reads, to anyone watching a dashboard, like a regression shipped - and on a site that buys traffic, every phantom regression triggers a real investigation with a real cost. A flat lab baseline is what makes the one swing that does mean something visible.

What this architecture costs

Every section above bought something, and this is the ledger of what it spent.

A staleness window. After a deploy or a content edit, any colo can keep serving the previous HTML for up to five minutes. For a blog and a marketing site that is invisible; for anything where minutes matter, you would wire the deploy pipeline to purge the HTML keys, the way my translation layer already purges KV cache entries. The TTL is a dial, but every notch toward freshness is a notch away from the hit rate.

It only accelerates the anonymous. Subscribed visitors with cookies bypass the cache and pay the full render on every page. That is the correct trade for this site, where the audience that decides rankings and first impressions arrives cookie-less. Flip the demographics - a dashboard, a logged-in app, a store where everyone holds a session - and this pattern buys you almost nothing.

The cookie list is a loaded contract. The bypass check knows exactly two personalization cookies. The day a third cookie starts changing the HTML and nobody adds it to that list, anonymous visitors start seeing someone else’s page state, and no test will catch it because tests run cookie-less. This is the sharpest edge in the whole design: I documented it in the middleware header comment, and I still expect future me to cut himself on it.

Two caches to reason about. The edge copy carries s-maxage, the client response forbids storage, and confusing the two produces the stale-state bug from earlier - quiet, user-facing, invisible in lab runs. The X-Edge-Cache header is not decoration; it is the only way to know from outside which path a response took.

And the mirrors are mine now. Dropping zod from the client means I own twenty-line validation mirrors that must stay in agreement with the server schemas - same fields, same error codes. Last time I traded react-hook-form for a controller I own; this time I traded a parser runtime for mirrors I own. I keep making the same trade, dependency out, responsibility in, and I keep concluding it is worth it - but it is never free.

The patterns from these two days - the cookie-gated HTML cache, the zod-free client schemas, forms living in their domain - are running in production on this site right now, and the ones that keep proving themselves go into the EdgeKits Starter Kits.

If you want the next teardown when it ships, the Early Birds list below is the way. The form is the exact component this article refactored, validated by the exact mirrors that replaced zod - and once you subscribe, your requests flip to X-Edge-Cache: BYPASS, making you one of the few people whose pages this article’s cache will never serve.

Leave your email to get launch discount • No spam ever

Frequently Asked Questions

How do I cache server-rendered HTML on Cloudflare Workers with the Cache API?

Add a middleware that runs first in the chain and talks to caches.default directly. On every GET it builds a cache key from origin plus pathname, returns a match if one exists, and otherwise renders, stores a copy with Cache-Control: public, s-maxage=300, and schedules the write through waitUntil so the response is not delayed. Only 200 text/html responses without a Set-Cookie header get stored, which automatically keeps redirects, 404s, and personalized renders out of the cache. On the EdgeKits site this took the root document from up to 1.2 seconds of fresh SSR per request down to about 140 ms served from the edge, and it is the reason the Lighthouse score stopped swinging between runs.

Why does a Lighthouse Performance score fluctuate between runs when nothing was deployed?

Because the lab measures two variable systems: the browser work and the server response. If Total Blocking Time is already 0 ms and script execution is in the low tens of milliseconds, the remaining mover is server-render time, which differs per run depending on which colo the test hits and whether that colo is warm. On my zero-JS Astro site, four PageSpeed runs in ten minutes scored 96, 100, 98, 96 with no deploy in between, and the deltas tracked the root-document time exactly. Edge caching the anonymous HTML removed that variable: over a day of runs the mobile median settled at 100 and desktop at 98. The residual dips that remain come from Lighthouse simulation artifacts, not from the site.

Does edge caching improve Core Web Vitals on a server-rendered Astro site?

It improves TTFB directly, and TTFB sits underneath FCP and LCP, so the whole chain benefits. On a server-rendered Astro site running on Cloudflare Workers, every request normally pays for middleware, translation reads, and a full render. Caching the anonymous HTML in caches.default cut the root document from up to 1.2s to roughly 140 ms on this site, flattened the critical request chain from 1,065 ms to 542 ms, and stabilized lab Core Web Vitals enough that the PageSpeed median holds at 100 on mobile and 98 on desktop. The cache only serves cookie-less visitors, which is exactly the population lab tests and crawlers belong to.

Why does a Cache-Control header alone not cache HTML when run_worker_first is enabled?

With run_worker_first, the Worker answers HTML routes before the static-asset and cache layers see the request, so a Cache-Control header on the response has no shared cache in front of it to instruct. SSR HTML also does not exist as a file in the asset layer, so assets-first routing cannot serve it either. The only HTML cache that exists in this topology is the one the Worker consults itself: the Cache API, caches.default, checked explicitly in middleware before rendering. The header still matters for what you store there, because cache.put respects the s-maxage you write on the stored copy.

How do you cache HTML for anonymous visitors but not for personalized ones?

Cookie gating. List every cookie that changes your rendered HTML, and bypass the cache whenever a request carries any of them. On the EdgeKits site exactly two cookies alter the page: the newsletter cookie flips the subscribe form into its done state, and consent_status controls the consent banner. Requests with either cookie get a fresh render tagged X-Edge-Cache: BYPASS; requests with neither are served from the edge. The matching rule on the response side: a cookie-less request must produce a cookie-less response, because anything carrying Set-Cookie is uncacheable. The danger to document is the list itself - a third personalization cookie added later, but not added to the bypass list, would silently serve wrong page state to anonymous visitors.

Why does zod v4 trigger a CSP unsafe-eval violation in the browser?

Zod v4 JIT-compiles its object parsers through new Function() at module-initialization time, and a Content-Security-Policy without unsafe-eval blocks that call as eval. Zod catches the refusal and silently falls back to a slower interpreted path, so forms keep working while DevTools logs a violation on every page load and Lighthouse docks Best Practices below 100. The options are to allow unsafe-eval, which weakens the CSP for everything, or to take zod out of the client bundle. I chose the second: the server action keeps validating with real zod, and the browser runs a hand-rolled mirror behind a zod-compatible safeParse interface, about 70 lines of contract code total.

What is the difference between s-maxage and max-age in Cache-Control?

max-age applies to any cache including the browser; s-maxage applies only to shared caches like a CDN or the Workers Cache API and is ignored by browsers. The trap is combining s-maxage with public on a response that reaches the browser: the browser ignores the s-maxage value but may still treat the response as storable. That is exactly the bug I shipped - the edge-cached copy carried public, s-maxage=300, a cache HIT returned those headers to the client, and Chrome kept serving the anonymous HTML after the user subscribed. The fix: the stored edge copy keeps s-maxage, while every client-facing HTML response gets Cache-Control: private, no-cache so cookie-dependent state changes appear immediately.

When should you not cache SSR HTML at the edge?

When most of your traffic is personalized. A cookie-gated cache only accelerates anonymous requests; on a dashboard, a logged-in app, or a store where every visitor carries a session, nearly everything bypasses and you keep the operational complexity without the hit rate. It is also the wrong tool when content must be fresh within seconds and you are unwilling to wire purge-on-deploy, since every colo can serve up to a full TTL of stale HTML after a change. The sweet spot is content sites, blogs, and marketing pages, where the visitors who matter most - first-timers, crawlers, and lab tests - arrive cookie-less.

Why does GTmetrix show Fully Loaded at 30+ seconds when onload finishes in under 2 seconds?

Something on the page keeps a network connection open long after the page is usable. Fully Loaded waits for network quiet, so a single hanging request dominates the number. In my case it was a ga-audiences request to google.com held open for 29.8 seconds: Google Ads audience sync, fired client-side by the Zaraz GA4 managed component when its Google Analytics Audiences setting is enabled. Real users never noticed, since onload completed at 1.8s, but the beacon ran on every visit. Disabling the setting in the Zaraz dashboard dropped Fully Loaded from 31.6s to roughly 2s and allowed the Content-Security-Policy to drop every Google origin, since the browser now only talks to the site itself.

We use cookies to analyze site traffic and improve your experience. By clicking "Accept All", you consent to our use of analytical tracking.