Most teams think of a CDN as a dumb cache: you cache the whole HTML page or you don't. That binary choice is exactly what makes high-traffic dynamic sites painful to scale. I've spent the last decade tuning edge delivery for media, commerce, and SaaS platforms where the header is personal, the body is semi-static, and the footer rarely changes. Edge-Side Includes (ESI) is the mechanism that lets you stop choosing between cacheability and dynamism. Instead of treating a page as a single object, ESI lets the edge assemble it from fragments with independent caching rules, reusing what can be reused and fetching only what has changed. In my experience, a well-implemented ESI architecture can cut origin requests by 70-90% on pages that were previously considered uncacheable, without pushing that complexity to the browser.
Decoding the ESI Processing Model: How Surrogates Assemble the Page
ESI is an XML-based markup language originally specified by Akamai, Oracle, and IBM back in 2001. It never became an IETF standard, but it is still natively supported by most major surrogates - Fastly, Akamai, Varnish, and sections of Cloudflare and Azion via compatible parsers. The core idea is simple: your origin returns a template page containing ESI directives instead of final HTML, and the edge proxy parses, resolves, and stitches the includes before delivering to the client. The browser never sees the ESI tags.
The processing order matters. When a request hits an edge node, the surrogate first checks if it has the parent template cached. If not, it fetches it from origin. It then parses the template for ESI instructions and recursively resolves each src. Each fragment is itself a cacheable HTTP object with its own headers, TTL, and even its own ESI directives. This recursion is what makes ESI powerful and what breaks it if you are not careful about loops and depth limits. In my experience, I cap nesting at 3 levels - for example, Page -> Header Fragment -> Navigation Sub-fragment - to keep assembly time predictable. Most surrogates enforce a default max depth of 5-10 and will fail the parent if you exceed it.
The Four ESI 1.0 Tags That Still Matter
You don't need the full spec. In production, 95% of the work is done by four constructs: esi:include, esi:remove, esi:comment, and esi:try / esi:attempt / esi:except. The vars and choose/when features exist but I rarely use them at the ESI layer; that logic is better handled with request-time edge compute or origin logic.
<!-- Parent template returned by origin with Surrogate-Control -->
<html>
<head><title>Product Page</title></head>
<body>
<esi:include src="/fragments/header" max-stale="5s" timeout="800ms" />
<main>
<h1>Wireless Headphones Pro</h1>
<!-- This fragment is cached for 24 hours -->
<esi:include src="/fragments/product/12345/details" />
</main>
<!-- Fallback content for bots or when ESI is disabled -->
<esi:remove>
<a href="/cart">View Cart</a>
</esi:remove>
<esi:include src="/fragments/recommendations?user=$(HTTP_COOKIE{user_id})"
alt="/fragments/recommendations/anonymous"
onerror="continue" />
</body>
</html>
Notice the onerror="continue" and alt attributes. Without them, a single failing fragment can hole-punch a 500 error into an otherwise cacheable page. I always set an alt or wrap critical includes in a try block, which brings us to fragment-aware TTLs.
How Edge Nodes Handle Nested Includes and Variable Substitution
Some platforms allow ESI variables like $(HTTP_COOKIE{...}) or $(HTTP_HEADER{...}) inside the src. This is useful for personalization but it instantly fragments your cache key space. I have found that variable-based includes should be limited to low-cardinality fragments. If you embed a user ID into an include URL for a cart, you are effectively making that fragment private and unshareable. That is correct for a cart, but disastrous if you apply it to a site-wide header. Use vars only where the fragment cannot be shared anyway.
Fragment TTL Design: Caching a 5-Second Cart Inside a One-Day Shell
The entire value of ESI collapses if you give every fragment the same TTL. The pattern that works is a shell-and-fragments model: the parent template has a short TTL or is stale-while-revalidate, static fragments have long TTLs, and dynamic fragments have very short or no cache. The control is via Surrogate-Control and Cache-Control headers interpreted by the edge, not the browser.
According to the caching model described in the HTTP Working Group Specifications, Surrogate-Control is the correct way to give instructions to a surrogate without leaking them to the client. A browser ignores it, but Varnish, Fastly, and Akamai will honor it over Cache-Control.
HTTP/1.1 200 OK
Content-Type: text/html
# Parent template - revalidate often, serve stale if origin is slow
Surrogate-Control: max-age=60, stale-while-revalidate=600, stale-if-error=86400
Cache-Control: max-age=0, must-revalidate
ESI: enabled
HTTP/1.1 200 OK
Content-Type: text/html
# Header fragment - personalized, short cache, private to surrogate
Surrogate-Control: max-age=10, private
Vary: Cookie
Cache-Control: private, no-store
HTTP/1.1 200 OK
Content-Type: text/html
# Product details fragment - shared, long cache, purge via key
Surrogate-Control: max-age=86400
Surrogate-Key: product-12345 category-audio
Cache-Control: s-maxage=86400
In this setup, a request for the product page might assemble a 60-second-old shell, a 10-second-old header, and a day-old product description, all without contacting origin. I've used this exact pattern for a news site where the article body was cached for a week, the comments fragment for 30 seconds, and the "breaking news" banner for 5 seconds. The origin load dropped from 2,500 RPS to under 120 RPS.
Stale Directives Are Not Optional
If you run ESI without stale-while-revalidate and stale-if-error, your p95 assembly time will be dictated by your slowest fragment. I set stale-while-revalidate on every fragment that can tolerate slight staleness. For fragments that cannot, like pricing or inventory, I use a short max-age with must-revalidate and handle failures explicitly with an alternate fragment or cached stale version.
ESI Versus Edge Compute: When Declarative Assembly Beats Programmable Workers
With the rise of Edge Functions Deep Dive: Cloudflare Workers, Deno Deploy and Vercel Edge, a lot of teams assume they should replace ESI with a Worker that fetches and stitches fragments via fetch(). Both approaches assemble at the edge, but they have fundamentally different operational characteristics. I now choose based on cache reuse and compute cost.
ESI is declarative and cache-native. The surrogate knows each fragment is an independent cache object, can purge it by key, can serve it stale, and can apply its own TTL without executing code. A Worker is procedural: you write JavaScript to fetch, cache via the Cache API, and assemble. That flexibility is powerful, but you pay for CPU time on every request and you are responsible for implementing all the caching logic yourself.
| Capability | ESI (Surrogate Parser) | Edge Functions / Workers | Client-Side Fetch |
|---|---|---|---|
| Assembly Location | Edge cache node, before response | Edge compute isolate, before response | Browser, after initial HTML |
| Fragment Cache Reuse | Automatic, per-fragment TTL & Surrogate-Key | Manual via Cache API + custom keys | Browser cache / no cross-user sharing |
| Compute Cost | Near-zero, parsed in C/VCL | Billed per request + CPU ms | Free for origin, cost to user perf. |
| Failure Isolation | Built-in alt / onerror / esi:try | Requires try/catch + manual fallback | UI flicker or empty sections |
| Best For | High-traffic pages with mixed TTLs | Complex auth, A/B logic, Wasm transforms | Highly personalized, low cache hit rate |
| Time to First Byte | Single assembled response, one round trip | Single assembled response, + compute latency | Extra round trips, worse LCP |
I've found that ESI outperforms Workers for pure assembly by 5-15ms because there is no isolate startup or JavaScript execution. The difference is small per request, but at 10k RPS it saves significant compute budget. I reach for Workers when I need logic ESI cannot do cleanly - for example, Geolocation Routing at the Edge: Country-Level Logic Without Redirects or Edge Authentication: JWT Verification and Token Validation at CDN Layer before deciding which fragment to include. In fact, the most effective architecture I deploy today is hybrid: an Edge Function authenticates the request and sets a header, then the ESI parser uses that header to choose fragments. You get the security of compute with the cache efficiency of declarative assembly.
Assembling Personalized Fragments Without Poisoning the Shared Cache
The fastest way to break an ESI implementation is to cache a personalized fragment as if it were shared. I have seen carts leaking into other sessions because a developer forgot to add Vary: Cookie or set Surrogate-Control: private. The rule is strict: if the response body depends on a cookie, authorization header, or user ID, it must not be stored in the shared cache without a varied key.
There are two safe patterns. The first is to make the fragment uncacheable and fetch it on every assembly. This is correct for a cart or checkout summary.
# Personalized, never shared - fetched per page view but assembled at edge
Surrogate-Control: no-store, private
Cache-Control: private, no-cache, no-store
Vary: Cookie, Authorization
The second, more scalable pattern is to use a cookie or token that is validated at the edge and then include a fragment keyed by that user context but still cached. For example, you can cache a "user menu" fragment for 60 seconds per user ID. Fastly and Akamai will keep thousands of variants in cache without collapsing them. This works well when combined with edge authentication that normalizes the user identifier into a header before ESI processing.
Cookie Forwarding and Vary-on-Cookie Pitfalls
Do not forward the full Cookie header to origin for every fragment fetch. If you do, your cache hit ratio will collapse because each user sends a different cookie string. Instead, configure your surrogate to extract only the session cookie needed for that fragment, or better, have the edge validate the JWT and forward only a clean X-User-ID header. In Varnish/CDN VCL, this is a one-line header unset. I've seen hit ratios jump from 40% to 85% just by stripping irrelevant cookies before the fragment fetch.
Connecting ESI to A/B Tests and Geo Variants
Personalization is not just authentication. If you run experiments, you can expose the variant assignment as a header or cookie at the edge and then ESI-include the appropriate fragment. This pairs cleanly with A/B Testing at the CDN Edge: Implementation Without Origin Overhead - the edge function assigns the bucket, the ESI template includes /fragments/hero?variant=$(HTTP_HEADER{X-Exp-Variant}). You avoid client-side flicker and keep the origin unaware of the test.
Failure Modes I See in Production: Timeouts, Holes, and Silent 502s
ESI assembly introduces a new class of partial failure. Your parent template can be healthy while one fragment times out, returns 500, or exceeds a size limit. By default, some surrogates will fail the entire page if any include fails. That is rarely what you want.
There are three controls you must set on every include: timeout, alt or onerror="continue", and size limits. I default to timeout="800ms" for non-critical fragments like recommendations and timeout="300ms" for secondary personalization. If the fragment does not return in time, the edge should render the parent without it rather than block the whole response.
<!-- Robust assembly with fallback and non-blocking errors -->
<esi:try>
<esi:attempt>
<esi:include src="/fragments/cart" timeout="400ms" onerror="continue" />
</esi:attempt>
<esi:except>
<!-- Fallback to stale or static version -->
<esi:include src="/fragments/cart/stale" alt="/fragments/cart/empty" />
</esi:except>
</esi:try>
<!-- Non-critical fragment - silent continue on error -->
<esi:include src="/fragments/recommendations" timeout="600ms" onerror="continue" />
<esi:remove>
<div class="recs-fallback">Popular products loading...</div>
</esi:remove>
Monitor assembly separately from origin health. I instrument three metrics per fragment: hit ratio, fetch latency, and error rate. When a fragment's error rate spikes, you want to know whether the surrogate served stale or left a hole. Akamai and Fastly both expose ESI-Status or similar debug headers that tell you which includes succeeded. Enable them in staging.
Another subtle failure is infinite loops. If /fragments/header itself includes / which includes /fragments/header, you will trigger the recursion limit and the page will error. I enforce a naming convention where fragment URLs live under /fragments/ and the ESI parser is configured to never process ESI inside responses outside that path.
Purging and Invalidation Patterns for Pages With Dozens of Includes
A page with 12 includes is actually 13 cache objects. Purging the page URL does not purge its fragments. This is the correct behavior, but it confuses teams used to single-object invalidation. The solution is surrogate keys (also called cache tags or surrogate keys).
Every fragment should emit a Surrogate-Key header that describes its dependencies. The parent template also emits keys for all its fragments so you can purge by logical entity rather than URL.
# Fragment for product 12345
Surrogate-Key: product-12345 category-audio brand-acme
# Category page that includes that fragment
Surrogate-Key: category-audio product-12345 product-12389
When product 12345 changes price, you issue a single soft purge for product-12345 and every object carrying that key - the fragment and any parent that included it - is invalidated. The next request will re-fetch the fragment but can still reuse other fragments on the page. In my experience, this key-based purge is what makes ESI viable at scale. URL-based purging alone forces you to purge hundreds of page variants.
Use soft purge (also called revalidate or stale purge) rather than hard purge. A soft purge marks the object stale but allows the edge to serve stale while revalidating in the background, preventing a thundering herd to origin. The web.dev Articles on stale-while-revalidate illustrate the same pattern for browser caching; the principle applies identically at the surrogate layer. Combine soft purge with stale-while-revalidate and your users never wait for origin after an invalidation.
Finally, consider edge-side Wasm for post-processing if you need transforms ESI cannot do. I have paired ESI with lightweight Wasm modules for HTML rewriting on product fragments, similar to the approach described in WebAssembly at the Edge: Running Wasm Modules on CDN Networks, while keeping the overall assembly declarative.
Frequently Asked Questions
Does ESI still make sense if my CDN does not natively support it?
Yes, but you implement it differently. If your CDN lacks an ESI parser, you can emulate the same pattern with Edge Functions that fetch and stitch fragments. The trade-off is that you lose automatic per-fragment TTL and key-based purging that a native parser provides. I recommend checking whether your provider supports ESI or an equivalent like SSI at the edge before building a custom Worker. Fastly, Akamai, and Varnish support it natively; Cloudflare requires Workers for assembly.
How do I prevent ESI assembly from increasing Time to First Byte?
Assembly adds latency equal to the slowest uncached fragment fetch. Minimize this by: 1) caching as many fragments as possible, 2) using stale-while-revalidate so most assemblies never wait for origin, 3) setting aggressive timeouts with onerror="continue" for non-critical fragments, and 4) limiting recursion depth. I budget 50-100ms for assembly and alert if p95 exceeds 150ms. Fetch fragments in parallel - most surrogates do this by default - rather than sequentially.
Can I use ESI with authenticated content and still get cache hits?
You can, but the authenticated fragments must be marked private and varied correctly. Mark shared fragments with Surrogate-Control: max-age and a Surrogate-Key, and mark user-specific fragments with Surrogate-Control: private, no-store and Vary: Cookie or Vary: Authorization. Better yet, validate the JWT at the edge, convert it to an X-User-ID header, and vary on that header only. That avoids poisoning the shared cache while still allowing per-user caching for short periods.
What is the difference between ESI and Server-Side Includes (SSI)?
SSI is processed by your origin web server (Apache, Nginx) before the response leaves the origin. ESI is processed by the surrogate/edge cache after the response leaves origin. SSI does not give you per-fragment caching at the CDN layer or key-based purging - the origin still does all the work. ESI moves assembly to the edge, so the origin only generates fragments when their TTL expires. If you already use SSI, migrating to ESI usually means changing your include syntax and emitting Surrogate-Control headers instead of relying on origin assembly.