← All posts

Zero-JS was not enough: edge-caching SSR HTML on Cloudflare Workers

How the author stabilized Core Web Vitals after dropping React: Cache API, cookie-gated HTML, removing Zod from the browser, and refactoring Astro forms.

Contents

In brief

The author removed React and hydration, reached Total Blocking Time = 0 ms, yet PageSpeed still swung between 90 and 100. The culprit was server-side: every request rendered fresh HTML on Cloudflare Workers. The fix — middleware using the Cache API, personalization gated on two cookies, plus a series of small wins around forms and CSP.

What happened

The story opens with a confession: a zero-JS build eliminated main-thread blocking, but lab PageSpeed runs still jittered. Four measurements in ten minutes returned 96, 100, 98, 96 — with no deploy. The variable was not in the browser but in HTML generation time at the edge.

GTmetrix showed a root document up to 1.2 s with no framework at all — surprisingly painful for a site that looks static. The author logged seven findings in a dedicated document (diagnosis separate from fixes) and closed them by priority: quick wins first, architecture last.

Among the findings: a 29.8 s Google Ads Audiences beacon via Zaraz (disabled with one checkbox), Zod v4 using new Function() in the client bundle (breaking CSP without 'unsafe-eval'), a hidden subscribe step that still shipped JS on first paint, and a select forcing reflow on every resize.

The main architectural move — htmlCacheMiddleware: anonymous GET requests without personalization cookies are cached in caches.default for 300 s; responses with newsletter/consent cookies always bypass cache. Key = origin + pathname (locale in path, query stripped). After shipping, root document with X-Edge-Cache: HIT landed around ~140 ms instead of 1.2 s.

Why it matters

Many teams optimize only the client: drop JS, split bundles, tune CLS. But with SSR on Workers, TTFB and render variance hit LCP and lab score stability directly. A Cache-Control header on HTML is often useless when run_worker_first is on: the Worker answers before CDN cache layers, so the only HTML cache is the one the Worker checks itself.

The article is a useful edge-native case study: how not to poison cache with Set-Cookie (cookie-less request → cookie-less response), how to split edge TTL (s-maxage only on stored copies) from browser policy (private, no-cache on all HTML responses), and how to replace heavy client validation with a ~20-line mirror while keeping server Zod as source of truth.

In practice

  1. Measure variance — several PageSpeed/GTmetrix runs back-to-back; if TBT = 0 but Performance swings, inspect TTFB and root document time.
  2. Cache anonymous HTML in the Workercache.match before the middleware chain; personalize only via an explicit cookie list.
  3. Never mix Set-Cookie and edge cache — sync lang cookie only when it already exists and disagrees with the URL.
  4. Audit third parties — audience sync can hold Fully Loaded for tens of seconds with zero product value.
  5. Zod on server, light mirror in browser — one safeParse interface, same error codes; server stays authoritative.
  6. Lazy-load hidden stepsdynamic import the segmentation controller only after a successful subscribe.
Finding Effect after fix
No edge HTML cache Root ~140 ms (HIT)
GA Audiences beacon Fully Loaded down from 31.6 s
Zod in client Best Practices 100, no eval in CSP
Newsletter domain refactor Less JS on first screen

Takeaway

Zero-JS is necessary but not sufficient for stable Core Web Vitals on SSR at the edge. Cookie-gated Cache API, strict CSP, and form discipline brought medians around ~100 mobile / 98 desktop — with the understanding that lab noise never fully disappears. If your site runs on Workers with i18n and personalization, walk through the article’s checklist before the next “magic” framework deploy.