Edge-Side Rendering with Cloudflare Workers
Origin server rendering fixes what a crawler sees, but not how fast it sees it: a single region hands a distant Googlebot a slow first byte on every fetch. Running the render inside an edge runtime such as Cloudflare Workers keeps the populated HTML and delivers it from the point of presence nearest the requester. This guide is part of edge rendering and CDN SEO and shows how to move a client-side app’s render into a Worker without turning into a bot-only branch.
Problem Statement
A React or Vue SPA that ships an empty shell is at the mercy of the second-wave render; moving to SSR removes that risk but a single origin adds latency that a global crawler pays on every URL. You want one artifact that both a browser and a crawler receive as complete HTML, produced close to the requester. The Worker is that artifact: it renders your app to a string, streams it back, and lets the browser hydrate the same markup.
The key architectural rule is that the edge render and the client hydration must consume the same data and produce the same tree. If they diverge you reintroduce the hydration mismatch that wipes content. The request path below is the shape you are building.
Step-by-step fix
-
Render your app to a stream inside the Worker. Use the framework’s streaming server renderer so the first bytes flush before the whole tree is built. The
fetchhandler below renders a React tree to a readable stream and returns it as HTML.// src/worker.ts — Cloudflare Worker (module syntax) import { renderToReadableStream } from "react-dom/server"; import { App } from "./App"; import { loadRouteData } from "./data"; export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { const url = new URL(request.url); const data = await loadRouteData(url.pathname, env); const stream = await renderToReadableStream( <App url={url.pathname} data={data} />, { bootstrapModules: ["/assets/client.js"], onError(error) { console.error("Edge render error:", error); }, }, ); return new Response(stream, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=0, s-maxage=60", }, }); }, } satisfies ExportedHandler<Env>; -
Serialise the data the render used so hydration matches. Inline the exact
dataobject the server rendered with into the HTML, and read it on the client instead of refetching. This guarantees the client’s first render produces the same tree.// Inside the document shell the Worker emits const serialised = JSON.stringify(data).replace(/</g, "\\u003c"); const bootstrap = `<script>window.__DATA__=${serialised}</script>`; // Client entry reads it before hydrating: // hydrateRoot(document.getElementById("root")!, <App data={window.__DATA__} />); -
Serve the same document to bots and users. Do not branch on the user agent. One code path renders for everyone, which is what keeps this out of cloaking territory and distinct from bot-only dynamic rendering.
-
Cache the rendered output at the edge. Because a full render can approach the Worker CPU limit, store the response so most requests return a stored copy. The CDN caching guide covers stale-while-revalidate; the minimal version uses the runtime cache:
const cache = caches.default; const cacheKey = new Request(url.toString(), request); let response = await cache.match(cacheKey); if (!response) { response = await render(request, env, ctx); // the fetch body above ctx.waitUntil(cache.put(cacheKey, response.clone())); } return response; -
Keep the CPU budget in check. Split heavy synchronous work, avoid rendering enormous lists on the server, and rely on streaming so the runtime can flush and reclaim time. If a route cannot render within budget, cache it aggressively so re-renders are rare.
The cache lookup that protects that budget is a simple branch: a hit returns the stored HTML untouched, and only a miss pays for a render.
Validation
Confirm the edge render is what a crawler actually receives, not just what a browser assembles:
-
curlfrom outside your network.curl -sSL https://example.com/product/42 | grep -o '<h1>[^<]*</h1>'must print the real heading. If it does, the content is in the first-wave HTML. -
Check the first byte is low and global.
curl -s -o /dev/null -w 'TTFB %{time_starttransfer}s\n' https://example.com/product/42from two regions should both be well under 200ms. -
Disable JavaScript in DevTools and reload. The full page must still render; a blank result means the Worker returned a shell, not the rendered tree.
-
GSC URL Inspection → View Crawled Page. The rendered HTML should match the served document with no hydration warnings in the console tab, per the audit workflow.
-
Assert parity in CI. A headless check comparing server HTML text nodes to the hydrated DOM catches divergence before deploy:
console.assert( serverText.trim() === hydratedText.trim(), "Edge SSR and hydration diverged — fix before shipping", );
Common Pitfalls
- Branching on user agent. Rendering a different tree for bots recreates the cloaking risk edge SSR is supposed to remove. Serve one document to all clients.
- Refetching on the client instead of reading inlined data. If the client refetches and the data has shifted, hydration mismatches and content can flash away. Read the serialised payload first.
- Ignoring the CPU limit. A synchronous render of a huge tree can be terminated mid-response. Stream, trim, and cache so the runtime rarely renders from scratch.
- Setting
s-maxagewithout a purge path. A long shared cache with no invalidation serves outdated content to crawlers. Pair any cache with the purging strategy in the caching guide. - Forgetting
charset. Omittingcharset=utf-8on the content type can garble non-ASCII text in the crawled snapshot.
The first pitfall is worth seeing side by side: branching on the user agent forks the response into two documents that drift apart, while one render path never can.
Frequently Asked Questions
Will Googlebot see the HTML rendered by a Cloudflare Worker?
Yes. A Worker returns a normal HTTP response with fully populated HTML in the body, so Googlebot indexes it in the first wave exactly as it would origin SSR — no JavaScript execution required. Because the Worker runs in the point of presence nearest the crawler, the Time to First Byte is also lower than a single-region origin.
How is edge rendering different from dynamic rendering for bots?
Dynamic rendering serves a prerendered snapshot only to bots and the raw app to users, which risks divergence and cloaking. Edge rendering serves the same server-rendered HTML to everyone and then hydrates it in the browser, so users and crawlers receive identical markup and there is no bot-detection branch to drift out of sync.
What are the CPU limits for rendering inside a Worker?
Workers cap CPU time per request, so a full framework render of a large tree can exceed the budget. Keep the render fast by streaming the response, limiting synchronous work, and caching the rendered output at the edge so most requests return a stored response instead of re-rendering on every hit.
Related
- Edge Rendering & CDN SEO — the section overview and how this fits the other edge approaches.
- Caching SSR HTML at the CDN without serving stale pages — make most Worker requests return a cached response.
- Streaming SSR and the TTFB vs LCP tradeoff — why the render streams instead of buffering.
- How to fix React hydration mismatch SEO warnings — keep the edge render and client tree identical.
← Back to Edge Rendering & CDN SEO