How I built edge-side includes on Cloudflare Workers
During Highsnobiety's WordPress-to-React migration, I built a streaming Cloudflare Worker that replaced selected regions of WordPress pages with server-rendered React fragments. It retained WordPress markup whenever the new path failed.
- Published
- Reading time
- 23 min read
In June 2018, I placed a small parser between Highsnobiety’s readers and its WordPress origin. It allowed a single page to contain working WordPress HTML alongside newer server-rendered React, replacing one region at a time without asking the browser to assemble the result.
The parser ran in a Cloudflare Worker. WordPress still produced the page, while a React service produced fragments such as the site’s chrome. The Worker fetched both, replaced marked regions while the response was streaming, and sent one composed HTML document to the reader. If a fragment was slow, unavailable, or returned an error, the old WordPress markup remained in its place.
That fallback was the feature. Fast edge composition was useful; making a two-year migration safe was the reason it existed.
This article accompanies my short Cloudflare Edge Side Includes talk. The slides show the request as a sequence. Here, I will show the protocol, the complete initial implementation, the parser mechanics, the failure behavior, and the production use that justified it. The larger story is how we rewrote Highsnobiety without taking it offline.
These were edge-side includes, not an ESI product
“Edge Side Includes” usually refers to the ESI markup language associated with reverse proxies and content delivery networks. Cloudflare Workers did not provide an ESI engine that we enabled. We built the minimum composition protocol Highsnobiety needed on top of the Fetch and Streams APIs.
The distinction matters. We owned the syntax, discovery header, timeout policy, cache behavior, and fallback semantics. Calling the result edge-side includes describes where the composition happened and what it accomplished; it does not claim compliance with the full ESI specification.
The protocol had two channels.
First, the WordPress response advertised fragments in an HTTP header:
X-Fragments: header header-7e1af762 https://frontend.example/fragments/render/header, footer footer-f810a521 https://frontend.example/fragments/render/footer
Each entry named a fragment, gave the occurrence a stable key, and supplied a rendering URL. The header allowed the Worker to start every fragment request as soon as the origin response headers arrived. It did not need to scan the entire body before doing useful work.
Second, WordPress marked replacement regions in its HTML:
<!-- fragment(footer):footer-f810a521 -->
<footer class="legacy-footer">Working WordPress footer</footer>
<!-- endfragment(footer):footer-f810a521 -->
The content between the markers was not a loading spinner. It was the complete old implementation. If the React fragment succeeded, the Worker replaced that block. If anything failed, the Worker wrote the block unchanged.
The first version used a simpler <!-- fragment:key ... --> syntax. The production system later introduced paired, named markers because explicit boundaries were easier to validate and could represent multiple instances of one fragment. The full source below is the first version that accompanied the original talk. The other examples use the clearer protocol that followed.
One request started several pieces of work
A request took the following path:
- The reader requested an ordinary Highsnobiety URL.
- The Worker fetched WordPress and added an opt-in request header.
- WordPress returned a normal HTML response with
X-Fragmentsdeclarations. - The Worker started all declared fragment fetches immediately and in parallel.
- The Worker opened a
TransformStreamand parsed complete lines from the WordPress response body. - Ordinary WordPress bytes flowed through as soon as they were available.
- At a fragment marker, the Worker awaited only that fragment’s already-running promise.
- Successful fragment bytes were piped into the output. A timeout, exception, or non-success status selected the WordPress fallback bytes.
- Parsing resumed, and a single HTML response continued to the browser.
This avoided the slowest possible implementation: fetching the complete WordPress response, discovering the fragments, requesting them one after another, building a new string, and only then sending the first byte. Header discovery created an early fork. Streaming preserved the time to first byte, while concurrent fragment requests hid much of their latency behind origin rendering and network transfer.
It was still an ordered composition. If the parser reached the header marker and that fragment had not resolved, the output paused there. Later WordPress bytes could not pass an unresolved earlier position without changing the document order. The parallel prefetch reduced that wait; it did not abolish causality.
Full source of the first Worker
Below is the complete fragment-composition Worker from the first production commit. The product-specific deployment files and sample fragment server are not part of the listing; this is the entire edge program that fetched, parsed, raced, piped, and fell back.
/* Define regular expressions at top to have them precompiled.
*/
const htmlContentType = new RegExp('text\/html', 'i')
const fragmentStart = new RegExp('<!-- fragment:(\\w+)( -->)?')
const commentEnd = new RegExp('-->')
addEventListener('fetch', event => {
event.respondWith(main(event.request))
})
/* The main entry function
*/
async function main(request) {
const response = await fetch(request)
const fragments = prefetchFragments(response.headers)
return transformResponse(response, fragments)
}
/* Build a dictionary of promises that we can evaluate later.
* These fetch or timeout.
*
* The overall timeout is shared by each promise. The cumulative amount of time, that
* all fetch-requests can spend is 10 seconds.
*
* Each fetch request defined in the headers gets a fair share.
* We let the promises race and later fail gracefully when the fetch does not return in time.
*
* This is an important circuit-breaker mechanism, to not fail the main request.
*/
function prefetchFragments(headers) {
const header = headers.get('X-Fragments')
if (header === null) return {}
const fragments = {}
const values = header.split(',')
const safeTimeout = 10000 / values.length
values.forEach((entry) => {
const [key, url] = entry.trim().split(' ')
const request = new Request(url)
const timeout = new Promise((resolve, reject) => {
const wait = setTimeout(() => {
clearTimeout(wait)
reject()
}, safeTimeout)
})
fragments[key] = Promise.race([
fetch(request),
timeout
])
})
return fragments
}
/*
* Here we decide whether we are going to stream & parse the response body,
* or just return the response as is, since the request is not eligble for fragments.
*
* Only Content-Type: text/html responses with one or more fragments are going to be evaluated.
*/
function transformResponse(response, fragments) {
const contentType = response.headers.get('Content-Type')
if (
contentType
&& htmlContentType.test(contentType)
&& Object.keys(fragments).length > 0
) {
const { readable, writable } = new TransformStream()
transformBody(response.body, writable, fragments)
return new Response(readable, response)
} else {
return response
}
}
/*
* This function transforms the origin response body.
*
* It assumes the response to be utf-8 encoded
*/
async function transformBody(body, writable, fragments) {
const encoding = new TextDecoder('utf-8')
const reader = body.getReader()
const writer = writable.getWriter()
// initialise the parser state
let state = {writer: writer, fragments: fragments}
let fun = parse
let lastLine = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
const buffer = encoding.decode(value, {stream: !done})
const lines = (lastLine + buffer).split("\n")
/* This loop is highly optimized
* Basically it is a parse-tree keeping state between each line.
*
* But most important, is to not include the last line.
* The response chunks, might be cut-off just in the middle of a line, and thus not representing
* a full line that can be reasoned about.
*
* Therefore we keep the last line, and concatenate it with the next lines.
*/
let i = 0;
const length = lines.length - 1;
for (; i < length; i++) {
const line = lines[i]
const resp = await fun(state, line)
let [nextFun, newState] = resp
fun = nextFun
state = newState
}
lastLine = lines[length] || ""
}
endParse(state)
await writer.close()
}
/*
* This is the main parser function.
* The state machine goes like this:
*
* parse
* -> ON fragmentMatch with fallback
* > parseFragmentFallback
*
* -> ON fragmentMatch without fallback
* > parse
*
* parseFragmentFallback
* -> ON closing comment
* > parse
*/
async function parse(state, line) {
const fragmentMatch = line.match(fragmentStart)
if (fragmentMatch) {
const [match, key, fragmentEnd] = fragmentMatch
const fragmentPromise = state.fragments[key]
if (fragmentEnd && fragmentPromise) {
await writeFragment(fragmentPromise, state.writer, line + "\n")
return [parse, state]
} else if (fragmentPromise) {
state.fragmentPromise = state.fragments[key]
state.fallbackBuffer = ""
write(state.writer, line.replace(fragmentStart, ""))
return [parseFragmentFallback, state]
}
}
write(state.writer, line + "\n")
return [parse, state]
}
/*
* This is a sub-state, that is looking for a closing comment --> to terminate the fallback.
* It will keep buffering the response to build the fallback buffer.
*
* When it finds a `-->` on a line, it will attempt to write the fragment.
*/
async function parseFragmentFallback(state, line) {
if (commentEnd.test(line)) {
await writeFragment(state.fragmentPromise, state.writer, state.fallbackBuffer)
state.fragmentPromise = null
state.fallbackBuffer = null
write(state.writer, line.replace(commentEnd, "\n"))
return [parse, state]
} else {
state.fallbackBuffer = state.fallbackBuffer + line + "\n"
return [parseFragmentFallback, state]
}
}
/*
* This is called after traversing all lines.
* If we have accumulated fallback buffer until here,
* we might want to dump the response, because someone forgot to add an closing '-->' comment tag.
*/
async function endParse(state) {
if (state.fallbackBuffer !== null) {
write(state.writer, state.fallbackBuffer)
}
}
/*
* This function handles a fragment.
* In order for a fragment to render, it must fetch in time and respond with a success state.
*
* The function will attempt to resolve the promise and pipe any successful response directly
* to the main response. Blocking until the fragment response is consumed.
*
* If the fragment does not respond in time (a timeout happened), we attempt to render a fallback.
*
* If the fragment response is not succesful, we attempt to render a fallback.
*/
async function writeFragment(fragmentPromise, writer, fallbackResponse) {
try {
const fragmentResponse = await fragmentPromise
if (fragmentResponse.ok) {
await pipe(fragmentResponse.body.getReader(), writer)
} else {
write(writer, fallbackResponse)
}
} catch(e) {
write(writer, fallbackResponse)
}
}
/*
* Helper function to pipe one stream into the other.
*/
async function pipe(reader, writer) {
while (true) {
const { done, value } = await reader.read()
if (done) break
await writer.write(value)
}
}
/*
* Helper function to write an utf-8 string to a stream.
*/
async function write(writer, str) {
const bytes = new TextEncoder('utf-8').encode(str)
await writer.write(bytes)
}
A small implementation did not imply trivial behavior. It coordinated two or more origins, parsed arbitrary network chunks, preserved byte order, enforced a latency budget, and retained the old output as a recovery path.
Network chunks are not parser tokens
The subtle part begins in transformBody.
A ReadableStream yields chunks whenever bytes happen to be available. Chunk boundaries know nothing about lines, comments, UTF-8 characters, or HTML elements. One chunk can end halfway through <!-- frag, and the next can begin with ment:footer. Treating each chunk as a parseable string would occasionally miss markers or corrupt text under real network conditions.
The Worker decoded the response incrementally, prepended the unfinished tail from the previous read, split on each newline, and retained the final partial line. Only complete lines entered the state machine. That is why the origin templates placed protocol markers on deliberate line boundaries.
The parser had two states, each represented by a function.
parse copied normal lines. At a one-line marker, it selected a fragment immediately. At an opening marker with fallback content, it switched to parseFragmentFallback and accumulated the old HTML. The fallback parser returned to parse at its closing marker. Returning the next function together with the new state made the transitions explicit without introducing a parser framework.
The later paired markers improved this contract. A complete opening and closing marker carried the same fragment name and ID, making malformed boundaries and repeated fragments easier to reason about. The production tests covered markers split across stream reads, complete and incomplete fragments, several fragments in one response, ordinary content before and after them, and fallback behavior.
The timeout was a circuit breaker, not an optimization
prefetchFragments stored promises rather than responses. Starting all fetches populated a lookup table immediately; parsing resolved each entry only when it reached that position.
Every promise raced its fetch against a timeout. The initial implementation divided a ten-second budget by the fragment count. Later production code used a shorter fixed timeout and logged non-success responses. Both policies enforced the same invariant: a new fragment service could not hold the established site hostage indefinitely.
This is bulkhead-like behavior at the scale of a page region. WordPress remained capable of rendering the page, while the new React service received a bounded opportunity to improve one region. A failure consumed a known amount of latency and then fell back within that region.
The fallback also handled ordinary HTTP failure. A completed 404 or 500 was not valid fragment output merely because the network request resolved. writeFragment required an OK response. Exceptions and timeouts followed the same old-content branch.
There was no retry within the reader’s request. Retrying would have consumed more latency, complicated the budget, and risked amplifying a struggling service. The cache and the next page request provided another opportunity. The current user received working legacy HTML immediately.
Highsnobiety used the seam to migrate in public
The practical example was not a demonstration page assembled from a weather widget and a stock ticker. Highsnobiety was replacing a complete WordPress-rendered publication with a React frontend backed by a new Elixir content stack while the site remained busy and the editors continued publishing.
A WordPress Twig helper registered each enabled fragment in X-Fragments. Its template emitted the paired comments and rendered a legacy block inside data-target="react". Feature flags could enable all external fragments or a single fragment type, and query parameters supported deliberate testing. Requests without Worker support simply received the WordPress output.
The React fragment server accepted routes such as /fragments/render/footer. It rendered the component on the server, included the Apollo cache and props needed for hydration, and exposed metadata for fragment-specific CSS, JavaScript, locale, GraphQL endpoints, session context, tracking, and error reporting. The Worker eventually fetched that metadata beside the HTML fragments and inserted assets into the document head and body.
Consider one request from beginning to end:
- WordPress began rendering an article with its existing header, article body, and footer.
- The header and footer templates emitted valid legacy markup between the fragment markers.
- WordPress added two
X-Fragmentsheader values that pointed to React rendering routes. - Cloudflare received the response headers and immediately started both React requests.
- The WordPress article bytes began flowing through the Worker toward the reader.
- When the parser reached the header marker, the React response was ready. The Worker piped the server-rendered React header instead of the buffered WordPress header.
- The article body did not yet have a marker, so the WordPress article continued unchanged.
- During a simulated failure, the footer request exceeded its deadline. The Worker wrote the WordPress footer that was already present in the page.
- The reader received one coherent document: a new React header, the old WordPress article, and the old WordPress footer.
- On a later request, the healthy footer service replaced the footer as well. No route switch or editorial republication was required.
The same page could therefore contain migration states that would normally require separate releases or proxy routes. Ownership moved at the granularity of a component.
The header and footer were good early candidates because they appeared everywhere and had clear visual boundaries. They exercised deployment, server rendering, hydration, assets, caching, and fallback without requiring every article type to be complete. Once confidence grew, larger regions such as article rendering could cross the same seam. WordPress did not disappear in one dramatic launch. It gradually faded behind its replacements.
Cache composition needed explicit ownership
Edge composition only helps if the caches do not make the result incorrect.
The shell and fragments had different rates of change. A WordPress article could remain cacheable while the navigation or footer changed on another schedule. Fragment responses later carried cache-control directives and cache tags, while the metadata described the assets for the exact fragment build. The Worker code preserved the origin’s response metadata while preventing browser cache behavior from bypassing the edge policy.
The keys mattered. The marker’s occurrence ID joined the declaration to its position in the body. Fragment names selected the implementation and assets, while a deployment version identified the generated bundles. Mixing an old server-rendered fragment with incompatible new hydration code would turn a successful edge substitution into a client failure, so the HTML and asset metadata needed a shared release identity.
Personalization also changes the scope of a cache. Forwarding authorization during staging was necessary, but authenticated or geography-sensitive fragments cannot be shared under a cache key that ignores those dimensions. The safe default is simple: cache only output whose variation is represented in the key, and keep the fallback usable without the personalized fragment.
Useful beyond migrations
This pattern fits several practical cases:
- Strangler migrations. A team can replace a navigation bar, recommendation rail, commerce module, or article body before replacing the page’s owner.
- Independent cache lifetimes. The edge can compose a long-lived article shell with shorter-lived navigation or availability data.
- Polyglot delivery. Separate stacks can render bounded HTML without moving the composition and error handling into the browser.
- High-risk launches. The last known server-rendered implementation can remain inline until the new service has earned trust.
- Organizational boundaries. Teams can own deployable page regions through an explicit protocol rather than a shared template repository.
It is a poor fit when fragments require strict atomic consistency, when many sequential boundaries create a latency waterfall, when the fallback cannot work independently, or when scripts and styles cannot be isolated. It also introduces a distributed system into page rendering. Tracing, timeout budgets, cache keys, versioning, and malformed-markup tests are not optional.
What I would change now
I would retain the header discovery, concurrent fetches, streaming substitution, and origin-owned fallback. Those four decisions created most of the value.
I would make the parser framing independent of line breaks. A streaming tokenizer can carry partial markers directly instead of requiring the template formatting to align with newline boundaries. I would abort timed-out fetches rather than merely reject the competing promises. I would propagate one request-scoped deadline to the origins, then allocate the remaining time instead of counting fragments. I would make output cancellation close every reader and writer. I would also specify cache variation and fragment versioning as part of the protocol rather than introducing them later through metadata.
Cloudflare’s current HTML transformation APIs may remove the need for handwritten tokenization in some cases, but they do not determine the failure semantics. The difficult architectural question remains: what does the reader receive when an included service is late or wrong?
For Highsnobiety, the answer was never a blank rectangle or a failed page. It was the WordPress version that had served readers before the migration began.
That made edge-side includes more than a rendering trick. They became a reversible handover mechanism. The new React stack could take visible responsibility for one region at a time, and every request carried its own rollback. That is how a small edge detail helped a large rewrite remain invisible.