Edge-Side Includes in Modern Web Architecture: Dynamic Fragment Composition, Cache Segmentation, and Personalization at the CDN Layer
ESI was invented in 2001 and mostly forgotten. Edge computing brought it back in a different form. Here is how fragment composition at the CDN layer works today, when it beats full-page caching, and how to implement personalization without killing your cache hit rate.
Full-page caching is the easiest performance win in web infrastructure. Store the rendered HTML at the edge, serve it from 300+ global PoPs, skip the origin entirely. Cache hit rates above 90% translate directly to lower TTFB, lower origin load, and lower cost.
The problem is that almost no real page is fully cacheable. The navigation shows a logged-in username. The cart badge shows item count. The hero might display a regional promotion. One personalized fragment ruins the cache hit rate for the entire document.
The classic solution was to make the page fully anonymous (no personalization), pull personalization client-side with JavaScript after load, and accept the flash of default content. It works but it shifts latency onto the user and makes content dependent on JavaScript execution.
Edge-Side Includes was designed to solve this at the infrastructure layer. The idea: compose a page from multiple fragments at the CDN, cache each fragment independently, and stitch them together before delivery. A page that is 95% static and 5% personalized does not have to be treated as 100% uncacheable.
This article covers how ESI works, why it is relevant again in 2026, and how modern edge compute platforms implement the same pattern with better primitives.
The Original ESI Specification
ESI (Edge-Side Includes) was proposed as a W3C Note by Akamai and Oracle in 2001. The syntax is XML-based and embedded directly in HTML:
<html>
<body>
<esi:include src="/fragments/header" ttl="3600" />
<main>
<!-- static content cached at full TTL -->
</main>
<esi:include src="/fragments/cart-widget" ttl="0" />
</body>
</html>
The CDN intercepts the response, parses the ESI tags, fetches each fragment URL (from origin or its own cache), stitches them into the document, and delivers the assembled HTML to the browser. The browser receives a normal HTML document with no knowledge that composition happened.
Each <esi:include> carries its own TTL. The outer shell document can be cached for hours. The cart widget fetches fresh from origin on every request. The header fragment with navigation items might be cached for an hour.
ESI also supports conditional logic:
<esi:choose>
<esi:when test="$(HTTP_COOKIE{logged_in}) == 'true'">
<esi:include src="/fragments/user-nav" />
</esi:when>
<esi:otherwise>
<esi:include src="/fragments/guest-nav" />
</esi:otherwise>
</esi:choose>
Varnish Cache shipped a robust ESI implementation. Akamai and Fastly both supported subsets of the spec. But adoption stayed low for two reasons: debugging stitched documents was difficult, and the spec covered only simple composition. Anything beyond basic includes required custom Varnish VCL or edge-specific configuration.
Why Fragment Composition Is Relevant Again
Modern edge computing changed the equation. What ESI expressed as a declarative XML syntax, platforms like Cloudflare Workers, Fastly Compute, and Akamai EdgeWorkers implement as programmable logic running at sub-millisecond latency globally.
The core idea maps directly:
- Shell document: Cached at the edge, long TTL, no user-specific content
- Dynamic fragments: Fetched from origin or a fast KV store, short or zero TTL
- Composition: Happens at the edge worker before delivery
The difference is that you write code instead of XML tags, which means you can implement complex composition logic, vary fragments by audience segment, inject A/B test variants, and handle errors with real fallback logic rather than <esi:remove> blocks.
Cache Segmentation: The Core Problem ESI Solves
Consider a media site. The article body is the same for every reader. The sidebar might show recommended articles based on reading history. The header shows login state.
Without fragment composition, you have three choices:
- Cache the full page: Cache hit rate is high but everyone sees the same content. Personalization is impossible.
- Skip the cache: Every request hits origin. Cache hit rate drops to near zero. TTFB degrades for users far from your origin.
- Vary the cache by user segment: Store a separate cached copy per user or per cohort. Cache hit rate degrades as segmentation granularity increases. At the per-user level you have no cache benefit at all.
Fragment composition gives you a fourth option: cache the static shell at the edge with a long TTL, fetch only the dynamic fragments from origin on each request. Cache hit rates for the static portions remain high. Origin load scales with fragment count rather than page count times concurrent users.
The tradeoffs:
| Approach | Cache hit rate | Personalization | Origin load | Complexity |
|---|---|---|---|---|
| Full-page cache | Very high | None | Very low | Low |
| No cache | Zero | Full | High | Low |
| Cache vary by segment | Medium | Coarse | Medium | Medium |
| Fragment composition | High (shell) | Full (fragments) | Low-medium | High |
| Client-side JS personalization | High | Full | Low | Medium |
Client-side personalization is the current default. It works. The cost is a hydration round-trip and dependency on JavaScript. Fragment composition at the edge removes both.
HTMLRewriter: ESI for Cloudflare Workers
Cloudflare Workers ships a built-in HTMLRewriter class that parses and transforms HTML as a stream. Combined with the fetch API and KV or Durable Objects for state, it is a complete primitive for building ESI-style composition.
Here is a basic fragment injector that reads the outgoing HTML from origin, finds placeholder elements, fetches fragments in parallel, and injects them before delivery:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Fetch the shell document from origin or cache
const shellResponse = await fetch(request, {
cf: { cacheTtl: 3600, cacheEverything: true },
});
if (!shellResponse.ok) return shellResponse;
// Collect fragment fetch promises as we encounter placeholders
const fragmentFetches = new Map<string, Promise<string>>();
// First pass: collect all fragment URLs from placeholder attributes
const scanner = new HTMLRewriter().on(
"[data-fragment-src]",
{
element(el) {
const src = el.getAttribute("data-fragment-src");
if (src && !fragmentFetches.has(src)) {
fragmentFetches.set(
src,
fetch(new URL(src, request.url).toString())
.then((r) => (r.ok ? r.text() : ""))
.catch(() => "")
);
}
},
}
);
// Consume the stream to collect fragment URLs
const cloned = shellResponse.clone();
await scanner.transform(cloned).text();
// Wait for all fragments in parallel
const fragments = new Map<string, string>();
await Promise.all(
Array.from(fragmentFetches.entries()).map(async ([src, promise]) => {
fragments.set(src, await promise);
})
);
// Second pass: inject fragment HTML into placeholders
const composed = new HTMLRewriter()
.on("[data-fragment-src]", {
element(el) {
const src = el.getAttribute("data-fragment-src");
const html = fragments.get(src ?? "") ?? "";
el.setInnerContent(html, { html: true });
el.removeAttribute("data-fragment-src");
},
})
.transform(shellResponse);
return composed;
},
};
The shell HTML uses data attributes as composition markers:
<body>
<div data-fragment-src="/fragments/nav"></div>
<main>
<!-- static article content, cached for hours -->
</main>
<aside data-fragment-src="/fragments/recommendations"></aside>
<div data-fragment-src="/fragments/cart"></div>
</body>
This two-pass approach avoids a full string buffer of the response. The first pass is a streaming scan to collect URLs. The second pass injects content while streaming the response to the client. Real implementations often merge this into a single pass using an outer coordinator, but the two-pass model is easier to reason about for dynamic fragment sets.
Personalization Without Splitting the Cache
The harder problem is per-user personalization. You cannot cache a page that says “Welcome back, Alex” at the edge if the next request is from a different user.
The pattern used in production is to separate personalization concerns:
- Cache the shell and all structural fragments normally
- For personalized fragments, use a short-lived token or a cookie value to look up user-specific data from a fast edge store (Cloudflare KV, Workers KV, Fastly Config Store)
- Inject the personalized fragment at the edge using data from the fast store
This avoids an origin round-trip for personalization:
interface Env {
USER_PROFILES: KVNamespace;
}
async function getPersonalizedNav(
request: Request,
env: Env
): Promise<string> {
const sessionCookie = getCookie(request, "session_id");
if (!sessionCookie) return defaultNavFragment();
const profile = await env.USER_PROFILES.get(sessionCookie, {
type: "json",
}) as { name: string; tier: string } | null;
if (!profile) return defaultNavFragment();
return `
<nav class="user-nav">
<span class="greeting">Hi, ${escapeHtml(profile.name)}</span>
${profile.tier === "pro" ? '<a href="/pro">Pro Dashboard</a>' : ""}
<a href="/account">Account</a>
<a href="/logout">Sign out</a>
</nav>
`;
}
function defaultNavFragment(): string {
return `
<nav class="user-nav">
<a href="/login">Sign in</a>
<a href="/signup">Get started</a>
</nav>
`;
}
KV reads at the edge have latency in the 1-10ms range from most PoPs. Compare that to a full origin round-trip of 50-200ms depending on geography. For session validation and profile lookup, the edge KV path is meaningfully faster.
The same pattern works for A/B testing. Store experiment assignments in KV keyed by a visitor ID, read the assignment at the edge, inject the correct variant fragment. No origin involvement, no JavaScript flicker.
Comparison Across Edge Platforms
The ESI pattern maps onto all major edge compute platforms, but the primitives differ:
| Platform | HTML transformation | Fragment fetching | Edge state | Notes |
|---|---|---|---|---|
| Cloudflare Workers | HTMLRewriter (streaming) | fetch with cache API | KV, Durable Objects, D1 | Best-in-class streaming transformer |
| Fastly Compute | String manipulation in Rust/JS/Go | fetch (backend calls) | Config Store, KV Store | Strict CPU budget per request |
| Akamai EdgeWorkers | DOM manipulation via JS API | httpRequest() | EdgeKV | Complex billing model |
| Vercel Edge Middleware | Response rewriting, limited | No sub-requests in middleware | Edge Config (read-only) | Limited to redirects and header mutation |
| AWS CloudFront Functions | No streaming transformer | Lambda@Edge for sub-requests | No built-in KV | Lambda@Edge has cold start issues |
Cloudflare Workers has the most capable primitive for this pattern. HTMLRewriter streams the document without buffering, handles malformed HTML gracefully, and runs in V8 isolates with consistent sub-millisecond startup. The combination of streaming HTML transformation and low-latency KV access makes the personalization pattern practical.
Fastly Compute is fast and the Rust runtime handles high-throughput workloads well, but the lack of a streaming HTML transformer means you work with string buffers or write your own parser. For large HTML documents, buffering the full response adds memory pressure.
Production Considerations
Fragment error handling. A failed fragment fetch should never break the full page. Wrap every fragment fetch in a try-catch with a sensible default. For critical UI (navigation, checkout), serve a cached stale fragment rather than an empty slot. For non-critical content (recommendations, ads), fail silently with an empty element.
async function fetchFragment(url: string, fallback = ""): Promise<string> {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(500), // 500ms hard limit
cf: { cacheTtl: 60 },
});
return response.ok ? await response.text() : fallback;
} catch {
return fallback;
}
}
Fragment TTLs. Each fragment type has a natural cache lifetime. Navigation items change rarely (cache for 1 hour). Product prices change frequently (cache for 60 seconds or bypass). User-specific content should not be cached at the CDN layer at all (compute from edge state on every request). Model each fragment independently.
Vary headers. If fragments legitimately vary by request attributes (language, device type, geo), use Vary headers appropriately. Over-varying kills cache efficiency. Prefer coarse segmentation (mobile vs. desktop, 5 language buckets) over fine-grained variation.
Observability. Distributed fragment composition creates distributed debugging problems. When a page renders incorrectly, you need to know which fragment was responsible. Log fragment origin (cached or fetched), latency, and status code as response headers in non-production environments. Use Cloudflare’s waitUntil to log to an analytics sink without blocking the response:
ctx.waitUntil(
logFragmentMetrics(env.ANALYTICS, {
url: request.url,
fragments: fragmentResults, // array of { src, ttl, latency, fromCache }
totalLatency: Date.now() - startTime,
})
);
Cache purging. Fragment composition means you can invalidate fragments independently of the shell. When a product price changes, purge only the product fragment cache key. When a user profile updates, update the KV entry. This granular invalidation is one of the concrete operational benefits over full-page caching with heavy Vary logic.
Response streaming. The best implementations pipeline fragment fetches with HTML streaming. Do not wait for all fragments to arrive before sending bytes to the client. Send the document head and above-fold content immediately, stream in below-fold fragments as they arrive. Cloudflare’s HTMLRewriter supports this natively because it operates on a ReadableStream.
When Fragment Composition Is the Wrong Tool
Not every personalization problem needs edge composition. JavaScript-based personalization is simpler to implement, debug, and deploy, and for most applications the performance difference is not measurable.
Fragment composition at the edge makes sense when:
- Pages have very high traffic and cache hit rates matter economically
- Origin infrastructure is expensive or geographically distant from users
- Personalization requirements are incompatible with full-page caching
- You already operate edge workers for other purposes (A/B testing, auth, geo-routing)
It adds meaningful complexity. You are building a distributed document assembly system. Fragment contract changes require coordinated deploys. Cache invalidation now spans multiple keys. HTML structure in the shell must match fragment injection points.
For most applications at typical scale, client-side personalization with a high cache hit rate is the correct default. Fragment composition is the tool you reach for when client-side hydration latency becomes a measured problem or when origin cost at scale makes unconditional cache bypass economically significant.
The Pattern, Restated
ESI was a good idea with a poor development experience. Edge computing delivers the same capability with programmable primitives.
Cache your shell documents aggressively. Model your dynamic content as independent fragments with explicit TTLs. Fetch or compute those fragments at the edge, close to the user, using fast edge state stores. Compose the final document before it leaves the CDN.
The browser receives a complete, rendered document. The origin sees a fraction of the requests it would under a no-cache policy. Users get personalized content without a hydration round-trip.
The core insight has not changed since 2001: not all parts of a page have the same cache characteristics, and treating them as a unit is a forced choice, not a fundamental constraint.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.