Repository navigation
Validate ESI approach to cache fragments on pages to separate cachable content for per-user events #1009
Description
Description
TS cacheable-root architecture: ESI and the alternatives
Date: 2026-08-06
Context: Trusted Server on www.prospect-a.com (prospect-a), live behind a ts-tester=true cookie + basic-auth gate. TS v394, Next.js 16.2.9 App Router, Fastly Compute.
Status: Design exploration (brainstorm). Not a committed plan.
Redaction: Publisher redacted to prospect-a / prospect-a.com. Safe for internal sharing; still confirm before any public posting (GitHub, Slack outside the deal channel, Artifacts).
Numbers labeled measured come from the live A/B below (N=4 per side, unthrottled, single US edge, one session). Numbers labeled modeled are estimates, not observations.
Part 1. Findings: TS-on vs TS-off (shareable)
We ran a tester-cookie A/B on the same URLs in isolated browser contexts: cookie on = TS/SSAT path, cookie off = plain cached origin. Same URL and auth both sides. Measured on the homepage and on a news article that was new to the tester.
Timing (measured, ms)
| Metric | homepage OFF | homepage ON | article OFF | article ON |
|---|---|---|---|---|
| TTFB median | ~123 | ~1085 | ~177 warm (650 first) | ~806 |
| TTFB range | 70–150 | 569–1965 | 65–650 | 529–1229 |
| First creative painted | ~130–190 | 647–2013 | 128–717 | 636–1397 |
| All creatives painted | 3.4–8.6 s | 3.4–5.7 s | 1.4–13.7 s | 2.1–6.3 s |
| Slot fill | 6/8 every load | 6/8 every load | 7/10 every load | 7/10 every load |
Cache behavior (measured, from document response headers)
Same article, two paths:
- TS-off:
hit-state: MISS-CLUSTERbutx-cache: HIT, MISSwithage: 14110→ the edge PoP missed, the Fastly shield had it cached (~4h old, from other traffic). Server time 165 ms. Cacheable. First load in a fresh context was 650 ms (shield fetch), then the PoP cached it and subsequent loads dropped to 65–230 ms. - TS-on:
hit-state: PASS,x-cache: MISS, MISS,cache-control: private, max-age=0,x-ts-version: 394. Server time 773 ms. Never shared-cached; pays the server-side auction on every load.
What it means
TS's measurable cost is TTFB, roughly +600 to +800 ms, and that is the server-side auction (SSAT) running inline before the first byte. Time-to-fully-painted ads is comparable on and off, because TS shifts the auction cost from client-side (post-load) to server-side (pre-first-byte) rather than adding new work. Fill was reliable in all 16 measured loads.
Two secondary findings:
- A URL new to the tester does not defeat the cache. The Fastly cache is shared; a live article is already warm at the shield from other traffic. Only a cache-buster query would force a cold origin build.
- TS's googletag shim discards event listeners queued before it loads, so third-party tooling that hooks
googletagslot events before TS (viewability, some analytics) may not receive them on the TS build. Flagged for follow-up; not yet filed.
The optimization target this doc addresses: the root HTML is uncacheable under TS (PASS) while the same page off-TS is a shared HIT. If we can make the root cacheable again and keep ad slots per-user, we recover most of the ~600–800 ms.
Part 2. The goal
Cache the static root as a shared Fastly HIT (like the origin page), and deliver the per-user parts (bids, identity) out of band. That recovers the TTFB while preserving the first-party server-side auction.
It is reachable, and not by accident. Four enablers already exist in the codebase:
- The auction is already decoupled from first byte: it is dispatched early (async) and only the
</body>tail is held for bids (publisher.rs:2723,:2214). - The per-user payload is already isolated to two injection seams,
tsjs.adSlotsat<head>open andtsjs.bids/adInit()before</body>(html_processor.rs:300,:344), not smeared through the DOM. - A fragment endpoint already exists:
/_ts/page-bidsreturns bids as JSON out-of-band (publisher.rs:3550). - A placeholder/token-substitution engine already exists: the Next.js RSC placeholder rewriter swaps payloads for
__ts_rsc_payload_N__tokens during streaming and restores them in post-processing (rsc_placeholders.rs,html_post_process.rs).
Two blockers stand in the way:
- The
</body>hold welds the bid tail onto the same HTTP response, so the root and the per-user bids are one cache entry. The root can't cache until bids leave the response body. - The cookie-privacy net force-privatizes any
Set-Cookieresponse (response_privacy.rs:31, re-applied at send in the Fastly adaptermain.rs:338), and the EC identity cookie rides the first navigation. A cacheable root must be served without setting that cookie inline; identity has to move to a subrequest or edge-KV path.
Both blockers are prerequisites for any of the approaches below, ESI included.
Part 3. Approaches (three ways to the same goal)
All three serve the Part 2 goal. They sit at different layers and are not mutually exclusive.
Lead: ESI via Fastly's esi crate
Templatize the root once (on cache MISS): TS's lol_html pass emits <esi:include src="/_ts/page-bids?..."> (and per-slot includes) at the two seams instead of inlining per-user data. The resulting ESI template carries no per-user data and is shared-cacheable. On every request, Fastly's esi crate processes the cached template, fetches the per-user fragments (the bids fragment is the SSAT auction), and streams the assembled page. The fragment fetch overlaps streaming the cached root.
Alternative A: cache-root + client-fill (no ESI)
Same cacheable templatized root, but the holes are filled by a small inline script that fetches /_ts/page-bids (which already exists) and resolves identity, client-side, after the root loads. No edge assembly, no new dependency, portable across all adapters.
Alternative B: shimmed React (correctness layer, not a transport)
Intercept React hydration to reconcile TS-rewritten URLs and to render ad slots from a late-arriving bids payload without a full re-render. This is the correctness layer that makes a static/dynamic split hydration-safe on the App Router, and it fixes the existing hydration mismatch (issues doc #1). It complements, it does not replace, whichever transport we pick.
Comparison
| Dimension | ESI (Fastly crate) | Client-fill (/_ts/page-bids) |
React shim |
|---|---|---|---|
| Recovers root TTFB | Yes (shared HIT) | Yes (shared HIT) | No (correctness only) |
| Where assembly happens | Edge (in Compute) | Client | Client |
| Extra client round-trip for bids | No (edge-assembled) | Yes (visible fetch) | n/a |
| Portable across adapters | No (Fastly only) | Yes | Yes |
| New dependency risk | esi 0.7.x |
none | React-internal coupling |
| Fixes hydration (#1) | No | No | Yes |
| Relative effort (modeled) | Medium-High | Low-Medium | Medium |
Recommendation
- Spine on Fastly is ESI, scoped to the bids/ad holes only (see Part 5, option 2). It has the highest ceiling on the perf win because assembly stays at the edge with no client round-trip.
- The portable fallback is client-fill via
/_ts/page-bidsfor the non-Fastly adapters, and the baseline if the portability decision goes against ESI. - Layer the React shim as the correctness fix on top of whichever transport, since the hydration mismatch has to be solved regardless.
- Defer full RSC/flight partitioning. The T-chunk length-preservation logic (
rsc.rs) has no static/dynamic split today; carving cacheable vs per-user flight chunks is a separate, larger effort.
Part 4. ESI deep dive (developer view)
The crate (esi 0.7.1, June 2026, MIT)
A streaming ESI parser and executor built for Fastly Compute, implementing a subset of Akamai ESI 5.0.
- Two modes:
Processor::process_response()(buffered; allows response-manipulation functions like$add_headerand auto-emitsCache-Control) andProcessor::process_response_streaming()(streams body, commits headers early, low TTFB, drops the response-manipulation functions). - Config via
Configuration:with_chunk_size(usize),with_caching(CacheConfig)(caches rendered output and fetched fragments),with_function_recursion_depth(usize). - Fragment sub-requests go through a caller-provided closure (
req.with_ttl(120).send_async("backend")), so we control routing and can issue them concurrently. An optional second callback processes fragment responses before streaming. - Tag support relevant to us:
esi:include,esi:vars,esi:assign,esi:choose/when/otherwise,esi:try/attempt/except,altandonerror="continue",$(VAR)interpolation, request-control attributes (ttl,no-store,maxwait,method). - Requirements: the
fastlycrate (~0.12), so it is Fastly-Compute-only. Viceroy for tests.
The two-stage mechanism
Today TS does one streaming pass: fetch origin → lol_html rewrite → inline per-user data → hold </body> for bids → stream. That is one HTTP response and one (private) cache entry.
Under ESI it becomes two stages with a cache boundary between them:
- Templatize (cache MISS only). Fetch origin,
lol_html-rewrite to emit<esi:include>at the two seams instead of inlining. Cache the resulting ESI template (shared, no per-user data). - Assemble (every request). Run the
esicrate over the cached template; it fetches the per-user fragments (bids = the auction) and streams. Because the include sits low in the document, the fragment fetch overlaps streaming the cached root.
Steady-state per-request cost drops to ESI assembly + one small auction fragment. The origin fetch and full-document rewrite happen only on MISS. That is where the modeled TTFB recovery comes from.
What it touches
- Generalize the placeholder rewriter (
rsc_placeholders.rs,html_post_process.rs) to emitesi:includemarkers at the two seams (html_processor.rs:300,:344). - Make the templatized root cacheable: relax the
should_run_ad_stackprivatization (publisher.rs:2882) for the templated path, and stop setting the EC cookie inline. - Add the
esicrate as a second-stage processor on the Fastly adapter, with the current inline path retained as the non-Fastly fallback. - Move identity resolution to a fragment or edge-KV path (
ec/module).
Hard parts
- Identity off-inline is a hard prerequisite. The privacy net will force-privatize the root otherwise, gate be damned (
main.rs:338). - Two streaming rewriters in series (
lol_htmlthenesi), both!Sendand chunk-oriented. Sequencing plus the cache boundary between them is the real integration cost. - Cache-key / vary discipline. The origin varies on
rsc, next-router-*, prospect-a-Exp(A/B experiments). The template cache key must honor those or serve the wrong variant. - Streaming mode drops ESI response-manipulation, so TS keeps setting its own headers around the processor. Minor.
Feasibility verdict: feasible and architecturally aligned, not a moonshot. The seams already exist.
Part 5. ESI options and the tradeoff (VP of product view)
Three injection models
- Publisher-authored ESI (prospect-a emits the tags). Rejected: pushes work onto the publisher and breaks the "drop TS in front, no origin changes" promise.
- TS-injected ESI, bids/ad holes only (TS templatizes the two seams, caches the root, ESI-fills bids). This is the recommended scope: the smallest change that captures the TTFB win.
- TS-injected ESI, full dynamic partitioning (also carve RSC/flight, identity, consent into fragments). Defer; much larger, new work on
rsc.rs.
Positive view
This kills the single biggest objection we measured against TS: the ~600–800 ms TTFB tax from running the auction inline. It converts "TS makes the page slower" into "the TS root caches like origin, ads stay per-user," which is exactly the publisher's mental model: cache the article, personalize the ads. It does so with Fastly's own, now-improved, supported crate rather than bespoke assembly code, which is on-platform and easier to defend in review. And it strengthens the core story: keep the first-party server-side auction and identity, lose the perf cost. For a performance-sensitive publisher like prospect-a that is a materially better pitch.
Negative view
- Portability. The
esicrate is Fastly-Compute-only. TS is deliberately multi-adapter (Fastly, Cloudflare, Spin/Akamai, Axum), and the EdgeZero portability story is a stated strategic asset. An ESI assembly path is Fastly-exclusive, so we would maintain two rendering architectures and the flagship perf number would exist only on Fastly. That cuts against "run anywhere." - Dependency risk. This puts the critical render path on a 0.7.x crate (young, ~80% documented). If Fastly deprioritizes it, we own a fork.
- Privacy surface. Caching a root that must be per-user-identical is a deliberate exception to the cookie-privacy net that exists to prevent per-user leakage into shared cache. It has to be airtight and continuously tested.
- Priority. This is a perf optimization, not a correctness fix. The inline path already fills reliably (6/8, 7/10 measured). It competes for eng time against open correctness issues (hydration Initialize repo #1, config Init Github actions #3, the SSAT 100x price bug).
- Operational weight. Cache invalidation on article edits, consent changes, and experiment variants is now our problem.
The gating decision
Is Fastly-first acceptable for the flagship perf path, with the inline/client-fill path as the portable fallback?
- If yes: scope 2 is a strong, aligned bet; green-light a spike.
- If portability parity is non-negotiable: ESI stays a Fastly-only accelerator layered on a portable inline baseline. Fine, but it can't be the architecture, only a per-platform boost, and the client-fill approach (Alternative A) becomes the primary way to recover TTFB everywhere.
Part 6. Open questions and next steps
- Portability decision (blocking). Fastly-first-with-fallback, or portability parity? Everything downstream depends on this.
- Identity re-architecture (prerequisite for all approaches). Move EC cookie issuance off the inline navigation response to a fragment/edge-KV path. Needs its own design.
- Cache-key/vary strategy. Enumerate what the templated root must vary on (
rsc,next-router-*,prospect-a-Exp, geo, consent state) and confirm none of it is per-user. - Spike (modeled).
| Step | Modeled effort | Confidence | Why |
|---|---|---|---|
Templatize one page type to emit esi:include at the two seams |
~1 week | Medium | Placeholder engine exists; new emit path |
Wire esi crate as second-stage on Fastly, bids fragment only |
~1–2 weeks | Low-Medium | Two !Send pipelines in series; unproven in this codebase |
| Identity off-inline (throwaway spike version) | ~1 week | Low | Touches EC module + privacy net |
| Measure cached-root TTFB vs today | ~2 days | High | Reuse the A/B harness from Part 1 |
- Validation. The ~600–800 ms recovery is modeled. Confirm it with the same tester-cookie A/B harness once a spike serves a cached templated root: expect TS-on TTFB to approach the TS-off shield-HIT numbers (~65–230 ms warm).
Future-state options (not recommended now)
- Full RSC/flight partitioning (option 3) once the bids-fragment ESI path is proven.
- Fastly Varnish shielding / classic ESI if the topology ever puts Varnish in front of Compute, which would change the portability calculus.
Appendix: code-grounded seams (file:line)
| Concern | Location | Note |
|---|---|---|
| Request entry / route decision | publisher.rs:2519, :2723 |
handle_publisher_request; auction dispatched early |
</body> hold for bids |
publisher.rs:2214, :2088, :772 |
one-behind buffer; awaits collect_dispatched_auction |
| Uncacheable stamp | publisher.rs:2882-2888 |
private, max-age=0; gated on should_run_ad_stack |
| Cookie-privacy net | response_privacy.rs:31, :72-89; adapter main.rs:338 |
force-privatizes Set-Cookie; forbids operator re-enable |
| Injection seams | html_processor.rs:300-333 (head), :344-376 (body-close) |
the two dynamic holes |
| Placeholder engine | rsc_placeholders.rs, html_post_process.rs |
token substitution to generalize for ESI markers |
| RSC URL rewrite | nextjs/shared.rs:100-105, rsc.rs:168-334 |
per-origin regex; T-chunk length preservation |
| Bids fragment endpoint | publisher.rs:3550 |
/_ts/page-bids JSON, already out-of-band |
| Fastly streaming send | adapter main.rs:327-365; platform.rs:544 |
Compute streams; header-only cache control, no VCL/ESI today |
| Cacheable-asset precedent | http_util.rs:293-309; proxy.rs |
TS already emits public/s-maxage/surrogate-control for assets |
Activity
Reopen ESI as feasible and separate it from the Stage 0 optimisation
Fix the docs build and the design breaks in the #1009 spike
Reconcile the #1009 documents with the request-neutrality correction
Close the remaining #1009 cross-document contradictions and gate the …
Make the #1009 docs invariant gate fail closed
Remove the #1009 documentation invariant checker
Align the #1009 spec structure with what it actually covers
Add the esi crate to the Fastly adapter and record Task 1 as passed
Add AssemblyMode and gate the head seam on template neutrality
Add the C2 shared-template cache gate
Architect streaming assembly, and correct the latency reading
Plan the #1009 main merge and ESI hardening
Merge main into #1009 ESI cacheable-root work
Merge current main into #1009 ESI cacheable-root work
probe
Promote the shared template cache out of spike status
Split origin shareability from template eligibility, and add the read…