Skip to content

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.

  1. Configure the APT repository as described in APT repository setup.
  2. 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 a location with its own proxy_cache / proxy_cache_valid.
  • The parent page is cached with ESI markup intact and processed on every delivery, exactly like ssi on over 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 gunzip did not, and the gzip filter recompresses the assembled page. Gzipped fragments are the one case that wants a directive — gunzip on on 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 (with alt and onerror), remove, comment, vars as a passthrough, and <!--esi -->. esi:choose/when/otherwise and variable substitution are not implemented, and unknown esi: tags are passed through verbatim rather than guessed at. If your pages use choose, 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_cache plus its compressed runs in the esi_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 on re-compresses a cached body per request. Storing the skeleton inside proxy_cache really 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 &amp; 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 (default off).
  • esi_types mime-type ...; — default text/html.
  • esi_silent_errors on|off; — suppress the <!-- ESI include error --> marker on failed includes (default off).
  • 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; (in http) — 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 (default off).
  • 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 (default off, requires esi_plan). See Stitching the gzip.
  • esi_stitch_level 1..9; — deflate level for the stored segments and for fragments compressed live (default 6, which is Varnish's gzip_level default rather than nginx's gzip_comp_level default 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_plan does 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 off both 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_zone for 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 on where that applies, by the module itself otherwise — and the assembled page is recompressed by the gzip filter as usual. See Gzip.
  • Content-Length is cleared, ranges disabled, ETag/Last-Modified dropped on processed responses — same rules as SSI.
  • Nested ESI works: a fragment whose response matches esi_types in an esi on context is itself processed (subrequest depth is limited by NGINX's built-in guard).