esi: Native ESI (Edge Side Includes) with parallel fragment fetching
Debian/Ubuntu installation
These docs apply to the APT package nginx-module-esi provided by the GetPageSpeed Extras repository.
- Configure the APT repository as described in APT repository setup.
- Install the module:
sudo apt-get update
sudo apt-get install nginx-module-esi
Show suites and architectures
| Distro | Suite | Component | Architectures |
|----------|-------------------|-------------|-----------------|
| debian | bookworm | main | amd64, arm64 |
| debian | bookworm-mainline | main | amd64, arm64 |
| debian | trixie | main | amd64, arm64 |
| debian | trixie-mainline | main | amd64, arm64 |
| ubuntu | focal | main | amd64, arm64 |
| ubuntu | focal-mainline | main | amd64, arm64 |
| ubuntu | jammy | main | amd64, arm64 |
| ubuntu | jammy-mainline | main | amd64, arm64 |
| ubuntu | noble | main | amd64, arm64 |
| ubuntu | noble-mainline | main | amd64, arm64 |
Native ESI (Edge Side Includes) for NGINX. Drop Varnish from your stack:
per-fragment caching with independent TTLs using nothing but NGINX and
proxy_cache.
Status: production candidate. Implements the Varnish-parity ESI subset — see Known gaps vs Varnish for what is deliberately not in it. 428 assertions across nine Test::Nginx files; cppcheck, an AddressSanitizer build and a build-configuration matrix all gate every commit.
Distributed as nginx-module-esi, a GetPageSpeed Pro module. This
source repository is proprietary and not public; see LICENSE.
Why
The classic reason Varnish sits in front of NGINX is ESI: cache a page shell for hours while a cart fragment stays uncached and a header fragment caches for a minute. This module does that natively:
<esi:include src="/uri"/>becomes an NGINX subrequest, so each fragment can resolve to alocationwith its ownproxy_cache/proxy_cache_valid.- The parent page is cached with ESI markup intact and processed on every
delivery, exactly like
ssi onover a cached upstream. - Fragments are fetched concurrently (output ordering is handled by the postpone filter). Open-source Varnish Cache resolves ESI includes sequentially, with the head-of-line blocking that implies; parallel ESI is a Varnish Enterprise feature. Measured: on a page with 32 fragments behind a 50 ms origin this is 9.7x the throughput and a 7x better p99 than Varnish 7.6.5 — see Measured against Varnish.
- Gzipped upstreams work. A gzipped page and clients that do or do not
accept gzip need nothing configured: the module decodes what
gunzipdid not, and thegzipfilter recompresses the assembled page. Gzipped fragments are the one case that wants a directive —gunzip onon the include target. See Gzip below.
Measured against Varnish
Head-to-head against Varnish Cache OSS 7.6.5 on a dedicated 8-vCPU Linode,
one core each, caches warm in both, output verified byte-identical before
timing, with an A/A control arm whose median drift was 0.27%. Full method,
configuration and all 24 rows in bench/; reproduce with
dev/bench-varnish.sh.
A 256 KB cached shell with N uncached fragments behind an origin that delays a fixed time per fragment — the workload ESI exists for — served to a gzip-accepting client, which is what real clients are:
| fragments | origin delay | this module | Varnish 7.6.5 | |
|---|---|---|---|---|
| 1 | 0 ms | 8,877 rps | 3,915 rps | +127% |
| 1 | 50 ms | 620 rps | 614 rps | dead heat |
| 8 | 20 ms | 1,447 rps | 179 rps | +708% |
| 32 | 20 ms | 788 rps | 45 rps | +1661% |
| 32 | 50 ms | 586 rps | 19 rps | +2956% |
Two different things are being won here and they are worth separating.
Fragments in parallel. At 32 fragments and 50 ms, p99 is 107 ms here against 1,680 ms — the latter is 32 × 50 ms, i.e. Varnish OSS resolving includes strictly sequentially, which is what it documents (parallel ESI is a Varnish Enterprise feature). With one fragment there is nothing to parallelise and the two are level at 620 against 614, which is the honest boundary of the result.
Not compressing the same bytes twice. The one-fragment row at 0 ms has no
parallelism in it at all; it is won purely by esi_stitch, which stores what
each unchanged run of the page deflates to and emits that instead of
re-compressing the page per request. Same row before that landed: 392 rps.
See Stitching the gzip.
Across the whole 24-row matrix this module also uses 2-6x less CPU per request, and there is no row in it that Varnish wins.
Known gaps vs Varnish
-
Markup. This is a Varnish-parity subset:
include(withaltandonerror),remove,comment,varsas a passthrough, and<!--esi -->.esi:choose/when/otherwiseand variable substitution are not implemented, and unknownesi:tags are passed through verbatim rather than guessed at. If your pages usechoose, this module is not a drop-in yet. -
Memory, on the gzip path. Varnish stores one copy of the object, already gzipped, and never holds a plaintext one. The equivalent here is a plaintext object in
proxy_cacheplus its compressed runs in theesi_plan_zone— two representations, and the zone has to be sized for the second. That is the price of not being able to write into NGINX's cache file format (see below); it buys the same per-delivery cost. -
Parse cost. Varnish parses the ESI skeleton once, when the object enters the cache. An NGINX body filter runs at delivery, so by default the page is re-parsed on every hit — the same reason
gzip onre-compresses a cached body per request. Storing the skeleton insideproxy_cachereally is out of reach: NGINX writes the cache file from the raw upstream buffers inside the upstream module, before any body filter runs, so no body filter can put anything into that file's format.
Storing it beside the cache is a different matter, and esi_plan does
exactly that — a shared zone holding, per cached object, where its ESI
constructs are. It is recorded on the first hit and replayed on the ones
after, which gets the same once-per-object parse Varnish gets, by another
route. See Memoising the parse below. Independently
of it, the per-delivery cost is paid only on bytes that are actually ESI
markup: verbatim text is emitted as buffers pointing into the input, never
copied.
What it costs, measured
Apple M2, nginx 1.31.4, one worker, markup-dense pages, fragments served by
return 200. Full tables and method in bench/; reproduce with
dev/bench.sh.
Removing the per-delivery memcpy (verbatim text is now emitted as buffers
pointing into the input) bought this much, measured against the commit before
it with both builds resident and interleaved:
| Page | Includes | req/s before | req/s after | |
|---|---|---|---|---|
| 16 KB | 0 | 12,502 | 20,284 | +62% |
| 16 KB | 16 | 9,770 | 12,780 | +31% |
| 256 KB | 0 | 1,181 | 2,640 | +123% |
| 256 KB | 16 | 1,131 | 2,252 | +99% |
| 4 MB | 0 | 77 | 157 | +105% |
| 4 MB | 16 | 62 | 138 | +123% |
The gain grows with page size, which is the shape you would expect from deleting a copy of every byte. Output was verified byte-identical between the two builds before timing them.
A sample profile of what was left put 75% of on-CPU in the parser, 17% in
writev, 7% in pread and under 1% in buffer bookkeeping. So the next round
went at the scan itself: it now probes 24 bytes by hand before handing over to
memchr, and decides a < whose whole opener is in the buffer by comparing
bytes rather than walking the state machine one character at a time.
| Page | Includes | markup-dense | text-heavy |
|---|---|---|---|
| 16 KB | 0 | +35% | +45% |
| 16 KB | 16 | ±0% | +24% |
| 256 KB | 0 | +30% | +240% |
| 256 KB | 16 | +29% | +174% |
| 4 MB | 0 | +37% | +280% |
| 4 MB | 16 | +39% | +253% |
Two columns because the distance between < is the only thing the scan's cost
depends on, and real pages sit anywhere between them: dense is one < per 11
bytes, text-heavy one per 71. The very large text-heavy gains are memchr
finally having runs long enough to be worth calling. The flat row — 16 KB with
16 includes — is a page where sixteen subrequests were always the cost and the
parser never was.
What none of it means is that ESI is free. On the same machine esi on still
runs at a fraction of esi off for the same page, and the ratio falls as
includes are added. The remaining cost is the per-byte parse and the subrequest
per include. Absolute figures are from a laptop that was not idle; the ratios
and the before/after deltas are the part worth quoting.
Supported markup
| Markup | Behavior |
|---|---|
<esi:include src="/uri?args"/> |
Replaced by subrequest output. Same-authority absolute URLs are accepted; encoded paths and & are decoded. |
<esi:include src="..." alt="/uri"/> |
On a 4xx/5xx fragment the alt URI is fetched in its place. |
<esi:include src="..." onerror="continue"/> |
A 4xx/5xx fragment leaves nothing behind, not even the error marker. |
<esi:remove> ... </esi:remove> |
Content dropped. |
<!--esi ... --> |
Markers stripped, content processed. Regular HTML comments untouched. |
<esi:comment text="..."/> |
Dropped. |
<esi:vars> ... </esi:vars> |
Markup stripped, content kept verbatim (no variable substitution yet). |
other <esi:*> |
Passed through verbatim with a warning, like Varnish. |
An include that carries alt= or onerror= is resolved sequentially: the
parser suspends until that fragment's status is known, so the alt fragment
can take the failed one's exact place in the output. Includes without either
attribute keep the concurrent fast path, and a fragment that returns an error
status has its body inlined verbatim — an error response is still a response,
and a subrequest delivers it like any other.
How this differs from Varnish. Here alt= and onerror= key on an HTTP
status of 400 or above. Varnish's onerror covers a failure to fetch the
fragment rather than an error status, it is gated behind the
+esi_include_onerror feature flag (7.3+), and without that flag Varnish
behaves as if onerror="continue" were always present. Varnish does not
document alt= at all. Treat the two as similar in spirit, not interchangeable.
Not yet implemented: esi:choose/when/otherwise, esi:try, variable
substitution, Surrogate-Capability negotiation (advertise it yourself, see
below). Surrogate-Control response headers are stripped when processing.
Neither does Varnish implement variable or cookie substitution, so that gap is
against the ESI spec rather than against Varnish.
Directives
esi on|off;— enable processing (defaultoff).esi_types mime-type ...;— defaulttext/html.esi_silent_errors on|off;— suppress the<!-- ESI include error -->marker on failed includes (defaultoff).esi_buffer_size size;— output buffer size (default one page). Also sizes the buffers the module inflates a gzipped body into, when it is the one doing the decoding — see Gzip.esi_plan_zone name:size;(inhttp) — declare a shared zone for parse plans. Minimum 8 pages; 16 MB holds tens of thousands of plans.esi_plan zone|off;— memoise the parse of cached objects into that zone (defaultoff).esi_stitch on|off;— serve gzip by stitching together the stored deflate form of each verbatim run instead of re-compressing the page on every delivery (defaultoff, requiresesi_plan). See Stitching the gzip.esi_stitch_level 1..9;— deflate level for the stored segments and for fragments compressed live (default6, which is Varnish'sgzip_leveldefault rather than nginx'sgzip_comp_leveldefault of 1; a segment is compressed once per cached object and then served from the zone, so the level is paid on one delivery and never again).
Memoising the parse
http {
esi_plan_zone plans:16m;
location / {
esi on;
esi_plan plans;
proxy_cache pages;
}
}
With esi_plan on, the first cache hit for an object records a parse plan
— a list of steps saying "N bytes go out verbatim, then M bytes are an ESI
construct" — and later hits replay it instead of scanning the body again.
Only positions are memoised; each construct is still parsed and dispatched by
the ordinary code path, so nothing about ESI semantics is duplicated.
Note the economics, because they are the reason this is worth doing: the
expensive pages are expensive precisely because they are full of < that turn
out not to be ESI. A 4 MB page with no ESI markup in it memoises to a single
step. A page with 64 includes is still about 2 KB of plan.
Nothing is recorded on a cache MISS: on a miss the body being filtered is the upstream's, and whether NGINX will store it — and as what — is not yet known. That costs one extra parse per object.
The zone is keyed on the cache key MD5 plus the object's Date, size and
header size. Two objects can in principle agree on all four, so replay does
not trust the key: every step carries the first bytes of its construct, and
they are checked against the bytes actually arriving before the parser is let
near them. A plan that has slipped against its body is dropped and the rest of
the body parsed normally, so being wrong about the key costs a parse, never a
response. t/70-plan.t TEST 6 forces that collision and pins the fallback.
Zone exhaustion evicts least-recently-used plans; a plan that will not fit is
simply not recorded. esi_plan is safe to leave off — it changes throughput,
never output.
What it buys, and where it buys nothing
Apple-silicon laptops are useless for this; the numbers below are from a
temporary dedicated-CPU Linode created for the run and destroyed after it, with
an A/A control arm within ±0.5% on nine of twelve rows. Method and full tables
in bench/.
Markup-dense pages, throughput as a fraction of serving the same cached bytes
with esi off — that is, how much ESI processing costs:
| Page | Includes | before | after |
|---|---|---|---|
| 16 KB | 0 | 72% | 95% |
| 256 KB | 0 | 40% | 97% |
| 4 MB | 0 | 34% | 97% |
| 4 MB | 16 | 37% | 106% |
On a large markup-dense page, ESI processing used to cost about two thirds of the throughput of plain cached delivery and now costs roughly 3%.
Two places it does not help, both worth knowing before you turn it on:
- Prose. Every row is inside its own noise floor. The scan already hands
long runs to
memchr, and one pass over a buffer with no<in it is close to free — there is no scan left to memoise.esi_plandoes not make ESI faster so much as it makes it content-independent, landing markup-dense pages at what used to be the prose best case. - Small pages with many fragments. 16 KB with 16 includes sits at 23% of
esi offboth before and after: the time is in the subrequests, and it always was.
Gzip
ESI needs to see markup, so a gzipped body has to be decoded before the parser gets it. Nothing needs configuring for that; it is worth knowing which of two things does the decoding, because only one of them costs you anything.
The module is ordered in the body filter chain exactly where SSI is — below
ngx_http_gunzip_filter_module, above the postpone and gzip filters. So when
gunzip on decodes a response, the body simply arrives in plaintext and the
module never knows the difference. NGINX decodes only for clients that do
not accept gzip, though: ngx_http_gunzip_header_filter returns early on
ngx_http_gzip_ok(), carrying NGINX's own TODO always gunzip - due to
configuration or module request. That decision is taken before this filter
runs, and nothing below gunzip can revisit it.
For every other client the module inflates the page itself, clears
Content-Encoding, and lets the gzip filter below recompress the assembled
result. That covers a gzip-accepting client on a gzipped upstream, and the
case where gunzip is not enabled at all.
An encoding that is not gzip — br, zstd, deflate — is left alone: the
response goes out exactly as it arrived, ESI markup and all, with an
NGX_LOG_INFO saying so. Handing the parser bytes nobody has decoded would be
the worse answer.
Gzipped fragments need gunzip on
That inflate step applies to the page being parsed, and not to fragments.
A fragment is a subrequest to some other location, usually one without esi
on, so this module never sees its body — the postpone filter splices those
bytes into the parent without either of them looking at the content.
The thing that decodes a fragment is ngx_http_gunzip_filter_module, and it
does so for a gzip-accepting client, precisely because ngx_http_gzip_ok()
declines for subrequests. So if your include targets can answer with
Content-Encoding: gzip, give them one of:
location /fragments/ {
gunzip on; # needs --with-http_gunzip_module
proxy_pass http://backend;
}
## ... or keep the backend in plaintext for that location:
location /fragments/ {
proxy_set_header Accept-Encoding "";
proxy_pass http://backend;
}
--with-http_gunzip_module is not one of NGINX's default modules, though
every distribution build ships it. With neither in place a gzipped fragment
would be spliced into an already-decoded page as a raw gzip stream — a 200
with a compressed hole in the middle of it. The module refuses the request
instead, naming both fixes in the error log. t/60-gzip.t TEST 9 pins that.
When to still strip Accept-Encoding upstream. Inflating and recompressing
costs CPU on every delivery. If the backend is yours and the hop to it is
local, proxy_set_header Accept-Encoding ""; avoids both by having the
upstream speak plaintext in the first place, and remains the cheapest
configuration. The inflate path exists so that ESI is correct when you cannot
do that — a third-party origin, a CDN pull, a shared upstream — not as a reason
to stop.
esi_plan works over inflated bodies. The offsets it records are offsets into
the decoded stream, which is the same stream either route produces from the
same stored object, so a plan recorded on one delivery replays on the other.
Stitching the gzip
http {
esi_plan_zone plans:64m; # segments make entries much larger than
# plans alone: size for compressed bytes
location / {
esi on;
esi_plan plans;
esi_stitch on;
proxy_cache pages;
proxy_set_header Accept-Encoding "";
}
}
Everything above still leaves one cost standing: whichever route decodes the
page, ngx_http_gzip_filter_module then re-compresses the assembled result on
every delivery — the same 256 KB of shell, to the same bytes, per request.
On a one-fragment page that was the single largest number in the whole Varnish
comparison, and it belonged to Varnish.
esi_stitch removes it, the way Varnish does. On the delivery that records the
parse plan, each verbatim run is deflated once and its compressed form is kept
in the same zone entry, next to where the plan says the run is. Later
deliveries emit those bytes straight from the zone and compress only the
fragments. The page's own bytes are then never read at all on a hit — not
scanned, not copied, not compressed, not even checksummed.
Three things make that legal, and all three are worth knowing if you are reading the source:
- Segments concatenate because each is cut with
Z_FULL_FLUSH, which ends the deflate block, pads to a byte boundary and clears the compression window — so no segment can reference anything before itself. Varnish uses the same flush at the same boundary. - The gzip trailer is folded, not computed. A gzip stream ends with a CRC32
and a length over the whole uncompressed body, including every byte we are
refusing to look at. Each segment carries the CRC32 and length of its own
plaintext, and
crc32_combine()folds them together in output order. - Fragments are woven in below the postpone filter. A fragment is a
subrequest and its bytes enter the response at that filter, so a second,
lower filter module does the weaving: anything already deflate output is
forwarded untouched, anything else is compressed live. That is why nested
ESI,
alt=failover and a dropped plan all keep working — whatever is not stitched simply arrives as plaintext and is compressed the ordinary way. Stitching is a fast path, never the only path.
The module claims Content-Encoding: gzip itself, which is what makes the
gzip filter below decline — it refuses an already-encoded response outright.
Vary: Accept-Encoding still follows your gzip_vary setting, and
gzip_disable / gzip_http_version / gzip_proxied still apply, because the
client is asked with the same ngx_http_gzip_ok() the gzip filter would use.
Safety works out one notch stricter than esi_plan's. Believing a wrong plan
costs a re-parse, because the bytes it mis-describes are still the body's own;
believing a wrong segment would put bytes on the wire that the body does not
contain. So a run is only replaced by its segment once the first bytes recorded
with it have been compared against the bytes actually arriving. A run whose
head cannot be checked — it straddles an input buffer boundary — is compressed
live instead, and a head that disagrees drops the plan and parses the rest.
t/80-stitch.t pins both, including the CRC, by reading every stitched
response back through gunzip on.
Two costs, stated rather than buried:
- Zone memory. An entry now holds compressed bytes rather than a few
offsets — kilobytes, not tens of bytes. Size
esi_plan_zonefor the compressed size of your hot set; eviction is LRU and an entry that will not fit is simply not recorded. - A slightly larger response. Cutting the deflate stream at every construct costs some compression ratio, because the window is cleared each time. Varnish pays exactly the same, and on the benchmark page both land on the same wire size to the byte.
Changing esi_stitch_level does not re-compress what is already in the zone:
segments are recorded with whatever level was configured at the time and stay
valid gzip regardless. Objects pick the new level up as they are re-recorded —
on eviction, or when the cached object behind them changes.
esi_stitch requires esi_plan — segments are a second payload on the same
zone entry, keyed and evicted with it — and is a config error without it rather
than a directive that quietly does nothing.
If you are not using esi_stitch, raise output_buffers
Worth knowing whether or not you turn stitching on, because it is not obvious and costs a line of config.
Verbatim text is emitted as buffers pointing into the input buffer, so an
input buffer cannot be released until that output has drained — and once a
subrequest is pending, draining it means waiting for that fragment. The copy
filter owns exactly output_buffers of them. So on a plaintext page the
parser can only run output_buffers worth of page ahead of the last fragment
it issued, and that, not anything about ESI, is what limits how many
fragments are in flight at once.
Measured on a 256 KB shell with eight fragments over a 50 ms origin: 2 32k
(the default) gives 153 req/s at a p99 of 239 ms — about five sequential
rounds of that origin. 4 512k gives 366 req/s at a p99 of 109 ms. Same
code, same page, one directive.
With esi_stitch on the cap does not exist at all: a stitched run is consumed
without being borrowed from, so nothing downstream is holding its input buffer.
Those same rows run at 612 req/s and a p99 of 66 ms — one round — and do not
move when output_buffers changes. Full data and the experiment that isolated
it: bench/.
Example: Varnish-less fragment caching
load_module modules/ngx_http_esi_filter_module.so;
proxy_cache_path /var/cache/nginx/pages keys_zone=pages:16m;
proxy_cache_path /var/cache/nginx/frags keys_zone=frags:16m;
server {
location / {
proxy_pass http://app;
proxy_cache pages;
proxy_cache_valid 200 1h; # page shell: 1 hour
proxy_set_header Accept-Encoding ""; # optional: skips an inflate
proxy_set_header Surrogate-Capability 'nginx="ESI/1.0"';
esi on;
}
# <esi:include src="/esi/header"/> -> cached 1 minute
location /esi/ {
internal;
proxy_pass http://app;
proxy_cache frags;
proxy_cache_valid 200 1m;
}
}
Same-authority absolute http://, https://, and scheme-relative URLs are
reduced to local subrequests. This matches applications such as Magento that
render their public base URL into each ESI tag. A different authority is still
rejected rather than fetched: map remote fragments through an internal
location with proxy_pass, which also gives you upstream keepalive and caching
for free.
Test
dev/smoke.sh # self-contained: downloads nginx, builds, asserts
make tests # full Test::Nginx integration suite in Docker
make lint # exhaustive cppcheck
make tests-asan # static nginx build under AddressSanitizer
make build-configs # build without the gzip module, without zlib, dynamic
dev/bench.sh # wrk throughput, esi on vs esi off (needs wrk)
dev/bench-ab.sh REV # same, but this build against an older revision
The make targets wrap dev/*.sh in a pinned Ubuntu image. CI (myci,
.myci/config.yml) runs those same scripts directly in its job container, so
a green run locally exercises the same commands.
Notes
- Gzipped upstream responses are decoded before parsing — by
gunzip onwhere that applies, by the module itself otherwise — and the assembled page is recompressed by the gzip filter as usual. See Gzip. Content-Lengthis cleared, ranges disabled,ETag/Last-Modifieddropped on processed responses — same rules as SSI.- Nested ESI works: a fragment whose response matches
esi_typesin anesi oncontext is itself processed (subrequest depth is limited by NGINX's built-in guard).