You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Refactor and standardize how Trusted Server sets cache response headers #293
changed the title [-]Refactor and standardize how Trusted Server sets response headers[/-][+]Refactor and standardize how Trusted Server sets cache response headers[/+]on Jul 2, 2026
Define and implement Cache-Control header strategy for TS edge environment
Labels:enhancement, edge, performance, caching Area: Trusted Server runtime (Fetch from Origin / Edge to Clients)
Summary
Today TS emits a single set of Cache-Control headers per asset type, using max-age for everything. In a TS deployment there are two distinct cache hops (origin to edge, and edge to client), and max-age alone controls only one of them well. This issue defines a target caching strategy that separates the edge tier from the browser tier, fixes fingerprinted-asset handling, and reins in the Vary sets that are fragmenting the edge cache key. It also answers the open immutable/public question from the current design.
Background: the two cache tiers
The request path has two cacheable hops:
(Next.js / WP) ──▶ [TS: Fetch from Origin] ──▶ [TS: Edge to Clients] ──▶ Browser
origin edge cache browser cache
Origin to edge (shared cache): how long TS holds a response at the edge. Controlled by s-maxage in Cache-Control, or by a dedicated edge header (Surrogate-Control on Fastly, CDN-Cache-Control on Cloudflare/Akamai).
Edge to client (private cache): how long the browser holds a response. Controlled by max-age.
Using one max-age for both means we cannot make the edge hold something for a year while asking the browser to revalidate frequently (or the reverse). Splitting the tiers is the core change this issue proposes.
Constraint: the SSAT HTML path is not shared-cacheable, and recompressing it in-guest is expensive
This must be settled before the HTML rows below make sense. With server-side ad templates (SSAT) enabled, the root HTML document splits into two distinct objects with two different cache fates, and only one of them is a shared-cache candidate:
Un-injected origin HTML (what the origin renders): still shareable. TS reads it from the origin shield / edge cache, so a cold origin render amortizes once per URL per TTL across users. This is the only HTML object the edge tier (s-maxage/Surrogate-Control) applies to.
Assembled, injected HTML (what TS streams to the browser): TS inlines per-user live RTB bid data (window.tsjs.adSlots / tsjs.bids) into the page. It is deliberately never shared-cached. The current SSAT branch already enforces this: publisher.rs sets Cache-Control: private, max-age=0 and strips Surrogate-Control + Fastly-Surrogate-Control on any ad-stack HTML response, applied regardless of slot match or auction outcome. private, max-age=0 is chosen over no-store on purpose so the page stays BFCache-eligible.
Two consequences for this ticket:
Do not put public, s-maxage, or SWR on the assembled root document. Those directives describe the origin object, not what TS emits downstream. The generic "HTML → public, s-maxage=60, max-age=0, SWR" recommendation is wrong for any SSAT-eligible page; it would try to place per-user bid data in the shared edge cache.
The assembled HTML is streamed, not buffered. TS injects at <head> open, streams head+body immediately, holds only the </body> tail while the auction resolves (deadline-bounded), then emits the tail under Transfer-Encoding: chunked. A streamed, per-user response is not a cache entry under any directive, so cache tuning cannot recover its cost. What can be recovered is the CPU cost: the current pipeline decompresses the gzip origin body and recompresses it gzip→gzip in-guest (process_response_streaming sets input_compression == output_compression). On a large document (≈600 KB seen on Buffalo News) that in-guest deflate is the main risk of hitting the Compute CPU-time limit. Because SSAT runs the auction+inject on every qualifying pageview (no shared-cache amortization), this CPU cost is paid per user, not once. The fix is to fetch Accept-Encoding: identity from origin (the shielded origin gunzips on delivery, moving decompress off the Compute guest) and stream identity chunked downstream. Fastly does not recompress unless explicitly told and streams chunked responses without buffering, so identity-out keeps the guest at zero compression CPU while preserving the early head/body flush. Do not reach for heavier in-guest compression (e.g. Brotli) on this path. See the TS-optimized approach for the last-mile-bandwidth tradeoff.
Current state (from cache audit)
Provenance: these values were captured directly from www.prospecta.com, which is not currently traversing a TS instance. So this table reflects the origin (direct) response — what origin/Next.js emits to the browser today — not what TS produces. It is the input to the strategy below, not a picture of TS behavior. The "Proposed target strategy" and "TS-optimized approach" sections describe what TS should emit once it sits in the path.
Open item recorded in the audit: for images, should we add immutable and/or public? Answered below.
Proposed target strategy
Recommended values assume content-hashed (fingerprinted) filenames wherever the framework provides them. Next.js emits hashed filenames for build output under /_next/static/ (JS chunks, CSS, media). For those, revalidation is wasted work because the URL changes when the content changes.
Carries per-user inline bid data; never enters the shared edge cache. This is already what publisher.rs emits. private, max-age=0 (not no-store) keeps the page BFCache-eligible.
HTML root document — un-injected origin object (non-ad-stack pages)
normalize, do not pass through 3p defaults (see below)
Accept-Encoding
Answering the open question: immutable and public
immutable: Add it, but only for fingerprinted URLs (content hash in the filename). It tells the browser never to revalidate, including on a normal reload, which removes conditional requests entirely for those assets. Do not put immutable on any URL you update in place, because clients can be pinned to a stale copy for the full max-age (up to a year).
public: Add it to anything that is safe for a shared cache to store (all static assets, and any HTML/JSON that is identical across users within its Vary key). It makes the shared-cache intent explicit and avoids heuristic caching surprises. Use private for anything personalized so only the browser stores it.
So for the AVIF images specifically: if their URLs are hashed, yes to both (public, max-age=31536000, immutable). If they are not hashed, public yes, immutable no, and cap the browser tier with max-age + SWR instead.
The Vary problem (highest-impact fix on dynamic responses)
The HTML root document currently varies on six dimensions: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding, Arena-Exp.
Each distinct combination of those header values is a separate edge cache entry. The next-router-* headers carry high-cardinality values (router state trees differ per navigation), so a full-document entry keyed on them will approach a zero percent edge hit rate. Recommendation:
Separate the full HTML document response from the RSC payload response and cache them under different keys. The full document (the initial navigation) does not need to Vary on the router state tree.
For the full document, Vary only on genuinely bounded, response-changing dimensions. Arena-Exp (experiment bucket) is bounded and belongs in the key. Accept-Encoding belongs in the key.
For RSC and API responses, keep only the router headers that actually change the returned payload, and confirm their cardinality is bounded before caching at the edge at all.
Audit whether any next-router-* header can be normalized to a small set of buckets at the edge before it enters the cache key.
Action: produce a short table of each Vary dimension with its observed cardinality and whether it changes the response body, then drop the ones that do not.
TS-optimized approach (recommended)
Grounded in what the current SSAT branch already emits. TS controls three response classes, and each has a different best lever. Ordered by impact-to-effort.
1. Content-hash the tsjs bundle URL and mark it immutable (highest impact, ships independently)
Today TS serves its own JS bundle on a stable URL — /static/tsjs=tsjs-unified.min.js — with public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400 plus Surrogate-Control: max-age=300 and an ETag (serve_static_with_etag, http_util.rs). Every browser therefore re-validates the bundle every 5 minutes with a conditional GET.
The bundle is already hash-addressable — concatenated_hash(ids) exists in bundle.rs. Serve it at /static/tsjs=<hash>.min.js, inject that URL into the page server-side, and set:
This removes revalidation entirely. When the bundle content changes the hash changes, the injected <script src> changes, and browsers fetch the new URL. TS is uniquely positioned here because it generates both the script tag and serves the bundle, so the two sides cannot drift. Because the injected HTML is private, max-age=0, a new hash propagates on the very next navigation — no stale-URL window. Same trick Next.js uses for /_next/static/*.
Applies to the unified bundle and each deferred module (tsjs-{id}.min.js, e.g. prebid).
Keep Vary: Accept-Encoding; keep the ETag as a belt-and-suspenders 304 for any client that ignores immutable.
2. Keep injected HTML private, max-age=0; recover cost at the origin hop
The assembled page cannot be shared — that is inherent to inlining per-user bids, and publisher.rs already enforces it. The recoverable costs are the origin render and the in-guest (de)compression, neither of which changes what the browser sees:
Shield + collapse the un-injected origin fetch. An origin shield POP is already configured today, so concurrent readers of the same URL can share one Next render. The remaining work is to give the origin/Next subrequest a short shared TTL (s-maxage on the order of 5 to 30 s) and confirm TS does not force that subrequest uncacheable. The auction+inject still runs per user (irreducible), but the expensive origin render amortizes across the window.
Fetch Accept-Encoding: identity from origin to move gzip decompress off the Compute guest. This half is close to a pure win: the shielded origin gunzips on delivery to an identity request, so the guest never spends CPU decompressing (process_response_streaming currently sets input_compression == output_compression, i.e. it decompresses and recompresses in-guest). Decompression is the larger and safer piece to offload.
Downstream, the default is to stream identity chunked to the client, with no recompression. Fastly does not recompress unless we explicitly tell it to, and it forwards a chunked response through as chunks arrive rather than buffering the full body to compress it. So streaming the injected HTML uncompressed preserves SSAT's early head/body flush and tail-hold exactly — there is no buffering penalty and no TTFB regression. The guest spends zero CPU on decompression (offloaded to origin) and zero on recompression (not performed).
The only cost of identity-out is last-mile bandwidth: uncompressed HTML on the wire to the browser. For text/HTML that is a real but bounded increase, and it is the one tradeoff to weigh:
Default (recommended): identity in, identity out. Origin gunzips on the identity fetch, the guest splices and streams identity chunked, the client receives uncompressed. Lowest guest CPU, fastest TTFB, higher last-mile bytes.
If last-mile bytes matter more than guest CPU on a given path: either keep in-guest gzip (current behavior — cheap for small post-injection bodies like deferred modules/JSON, expensive for a full ~600 KB document) or explicitly enable Fastly edge recompression. Both are deliberate opt-ins, not defaults, and neither is forced on us by the platform.
Amortization does not change with location: the assembled HTML is private, max-age=0, so any compression runs on every request. That is exactly why the default should keep compression off the CPU-budgeted guest — stream identity out, and reach for in-guest gzip or edge recompression only where last-mile bytes justify it.
3. Upgrade passthrough static and trim dynamic responses
Fingerprinted origin static (/_next/static/*): TS owns the response it emits, so it can rewrite a weak origin max-age up to public, max-age=31536000, immutable regardless of what Next sent. Same for images with hashed URLs.
RSC/flight and JSON: two-tier split with a trimmed Vary and a short edge TTL; bucket/normalize next-router-* before it enters the cache key, or do not edge-cache when cardinality is unbounded (see the Vary section).
4. Codify the two-tier split as config (generalize what TS already does)
TS already emits both Surrogate-Control (edge tier) and Cache-Control (browser tier) on the static path — the two-tier mechanism exists and works on Fastly. Generalize it: one policy struct per asset class {edge_ttl, browser_ttl, swr, sie, immutable, visibility}, and let each runtime adapter emit Surrogate-Control (Fastly), CDN-Cache-Control (Cloudflare/Akamai), or an s-maxage fallback. This is the multi-runtime section below; TS already has the Fastly half working.
Priority order: hashed-tsjs immutable (independent, clear win) → origin shield + identity fetch (cuts origin RPS and guest CPU) → config-driven two-tier for the rest.
Cross-cutting recommendations
Compression. API/JSON fetches currently send no encoding. JSON compresses well; enable gzip and Brotli and add Vary: Accept-Encoding on every compressed response. Confirm Vary: Accept-Encoding is present everywhere compression is applied (missing on the images row today).
Prefer Brotli where available, gzip as fallback — but only for cacheable static assets where compression cost is paid once and reused. This does not apply to the streamed SSAT HTML injection path. That response is per-user and non-amortized, and TS already spends guest CPU decompressing and recompressing the full body in-guest; switching it to Brotli (more CPU than gzip) makes the Compute CPU-time risk worse, not better. For the injected HTML path the goal is the opposite: get compression out of the guest via Accept-Encoding: identity origin fetch + edge-side recompression (see the SSAT constraint section). Static-asset Brotli is a fine, separate win; do not conflate the two.
Rehosted (first-partied) assets. When TS moves a 3p tag into first-party context it also owns the caching. Do not pass through the 3p Cache-Control, which is frequently no-store or a short TTL that defeats the point of rehosting. Normalize: fingerprint where possible and set immutable; for un-fingerprinted assets apply a conservative edge TTL with SWR; leave genuinely dynamic endpoints uncached. Track this as its own line item because it is a policy decision, not a default.
Personalization safety. Any response that can contain per-user content must be private (browser only) or no-store. Do not let a personalized HTML document land in the shared edge cache under a public directive.
TS is moving off Fastly-only toward Cloudflare Workers and Akamai. The header used to control the edge tier differs per runtime:
Fastly: Surrogate-Control (stripped before the response reaches the browser)
Cloudflare / Akamai: CDN-Cache-Control
Portable fallback: s-maxage inside Cache-Control
Recommendation: define the cache policy once in TS config (per asset class: edge TTL, browser TTL, SWR, SIE, immutable flag, visibility), and let each runtime adapter emit the correct header. That keeps a single source of truth as we add runtimes and avoids re-deriving TTLs per platform. This should live alongside the EdgeZero adapter layer.
Acceptance criteria
TS config expresses cache policy per asset class as structured fields (edge TTL, browser TTL, stale-while-revalidate, stale-if-error, immutable, public/private), not hard-coded header strings.
Edge tier and browser tier are independently controllable (via s-maxage/edge header vs max-age).
Root HTML document no longer Varies on high-cardinality next-router-* headers; full-document and RSC responses use separate cache keys. (Applies to the un-injected origin object; the SSAT-assembled document is not shared-cached at all.)
SSAT-assembled HTML continues to emit private, max-age=0 with Surrogate-Control/Fastly-Surrogate-Control stripped; no cache-policy change places per-user bid data in the shared edge cache.
Injected HTML path measured for in-guest compression CPU; Accept-Encoding: identity origin fetch + edge recompression evaluated before any move to Brotli on that path.
tsjs bundle (unified + deferred modules) served on a content-hashed URL and emitting public, max-age=31536000, immutable; injected <script src> references the hashed URL. Revalidation traffic on the bundle drops to zero.
Un-injected origin fetch carries a short shared TTL and is not forced uncacheable, so a Next render amortizes across concurrent readers within the window (origin shield already present).
Injected-HTML decompression offloaded to origin via Accept-Encoding: identity; default downstream path streams identity chunked to the client (no in-guest recompression). In-guest gzip or explicit Fastly edge recompression enabled only on paths where last-mile bandwidth justifies it.
Vary cardinality table produced and reviewed; unused dimensions removed.
JSON/API responses are compressed and carry Vary: Accept-Encoding.
Rehosted assets have an explicit normalized policy rather than 3p pass-through.
Per-runtime adapters (Fastly, Cloudflare, Akamai) emit the correct edge-cache header from the shared policy.
Before/after edge hit-rate measured on the root document and RSC routes.
Open questions / decisions needed
Which asset URLs are actually fingerprinted today vs served on stable URLs? (Drives where immutable is safe.)
Is the 1st-party prebid bundle served on a versioned filename or a stable URL? (Determines immutable vs short edge TTL.)
Does the root HTML document ever contain per-user content, or is it identical within an Arena-Exp bucket? (Determines public vs private.) Note: on any SSAT ad-stack page this is already answered — it contains per-user bid data and is private. The question only applies to non-ad-stack pages.
Does the origin honor Accept-Encoding: identity and gunzip on delivery? (Determines whether we can move injected-HTML decompression off the guest.) Fastly does not recompress unless explicitly told and streams chunked responses without buffering, so identity-out preserves SSAT streaming; the only open call is whether last-mile bandwidth on the largest documents warrants opting into compression on specific paths.
Confirmed cardinality of each next-router-* Vary value in production traffic.
Does each target runtime (Fastly / Cloudflare / Akamai) honor stale-if-error and stale-while-revalidate, and which edge header do we standardize the adapter output on?
Out of scope
Purge/invalidation API design (separate issue).
Origin cache configuration beyond the headers it must send for TS to key on.
Activity
jevansnyc commentedon Jun 19, 2026
Need to review what is currently happening and write a formal spec on this to standardize.
jevansnyc commentedon Jun 19, 2026
Spec should be done by MVP and if there are any findings that are concerning we will address by MVP.
This should also include headers passed to backends
aram356 commentedon Jun 26, 2026
@jevansnyc to write spec
[-]Refactor and standardize how Trusted Server sets response headers[/-][+]Refactor and standardize how Trusted Server sets cache response headers[/+]jevansnyc commentedon Jul 2, 2026
Define and implement Cache-Control header strategy for TS edge environment
Labels:
enhancement,edge,performance,cachingArea: Trusted Server runtime (Fetch from Origin / Edge to Clients)
Summary
Today TS emits a single set of
Cache-Controlheaders per asset type, usingmax-agefor everything. In a TS deployment there are two distinct cache hops (origin to edge, and edge to client), andmax-agealone controls only one of them well. This issue defines a target caching strategy that separates the edge tier from the browser tier, fixes fingerprinted-asset handling, and reins in the Vary sets that are fragmenting the edge cache key. It also answers the openimmutable/publicquestion from the current design.Background: the two cache tiers
The request path has two cacheable hops:
s-maxageinCache-Control, or by a dedicated edge header (Surrogate-Controlon Fastly,CDN-Cache-Controlon Cloudflare/Akamai).max-age.Using one
max-agefor both means we cannot make the edge hold something for a year while asking the browser to revalidate frequently (or the reverse). Splitting the tiers is the core change this issue proposes.Constraint: the SSAT HTML path is not shared-cacheable, and recompressing it in-guest is expensive
This must be settled before the HTML rows below make sense. With server-side ad templates (SSAT) enabled, the root HTML document splits into two distinct objects with two different cache fates, and only one of them is a shared-cache candidate:
s-maxage/Surrogate-Control) applies to.window.tsjs.adSlots/tsjs.bids) into the page. It is deliberately never shared-cached. The current SSAT branch already enforces this:publisher.rssetsCache-Control: private, max-age=0and stripsSurrogate-Control+Fastly-Surrogate-Controlon any ad-stack HTML response, applied regardless of slot match or auction outcome.private, max-age=0is chosen overno-storeon purpose so the page stays BFCache-eligible.Two consequences for this ticket:
public,s-maxage, or SWR on the assembled root document. Those directives describe the origin object, not what TS emits downstream. The generic "HTML →public, s-maxage=60, max-age=0, SWR" recommendation is wrong for any SSAT-eligible page; it would try to place per-user bid data in the shared edge cache.<head>open, streams head+body immediately, holds only the</body>tail while the auction resolves (deadline-bounded), then emits the tail underTransfer-Encoding: chunked. A streamed, per-user response is not a cache entry under any directive, so cache tuning cannot recover its cost. What can be recovered is the CPU cost: the current pipeline decompresses the gzip origin body and recompresses it gzip→gzip in-guest (process_response_streamingsetsinput_compression == output_compression). On a large document (≈600 KB seen on Buffalo News) that in-guest deflate is the main risk of hitting the Compute CPU-time limit. Because SSAT runs the auction+inject on every qualifying pageview (no shared-cache amortization), this CPU cost is paid per user, not once. The fix is to fetchAccept-Encoding: identityfrom origin (the shielded origin gunzips on delivery, moving decompress off the Compute guest) and stream identity chunked downstream. Fastly does not recompress unless explicitly told and streams chunked responses without buffering, so identity-out keeps the guest at zero compression CPU while preserving the early head/body flush. Do not reach for heavier in-guest compression (e.g. Brotli) on this path. See the TS-optimized approach for the last-mile-bandwidth tradeoff.Current state (from cache audit)
max-age=31536000Acceptmax-age=31536000, stale-while-revalidate=86400, stale-if-error=86400Accept-Encodingmax-age=31536000, stale-while-revalidate=86400, stale-if-error=86400max-age=60rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding, Arena-Expmax-age=60rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchmax-age=60rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-EncodingOpen item recorded in the audit: for images, should we add
immutableand/orpublic? Answered below.Proposed target strategy
Recommended values assume content-hashed (fingerprinted) filenames wherever the framework provides them. Next.js emits hashed filenames for build output under
/_next/static/(JS chunks, CSS, media). For those, revalidation is wasted work because the URL changes when the content changes./_next/static/*, hashed CSS/JS/media)public, max-age=31536000, immutableAccept-Encodingimmutablestops even reload revalidationpublic, max-age=31536000, immutableAccept, Accept-EncodingAcceptfor AVIF/WebP/JPEG negotiationpublic, s-maxage=31536000, max-age=86400, stale-while-revalidate=86400, stale-if-error=86400Accept, Accept-Encodingpublic, max-age=31536000, immutableAccept-Encodingpublic, s-maxage=31536000, max-age=86400, stale-while-revalidate=86400, stale-if-error=86400Accept-Encodingpublic, max-age=31536000, immutableAccept-Encoding/prebid.jsupdated in place)public, s-maxage=3600, max-age=300, stale-while-revalidate=86400, stale-if-error=86400Accept-Encodingimmutableon a stable URLprivate, max-age=0(stripSurrogate-Control/Fastly-Surrogate-Control)publisher.rsemits.private, max-age=0(notno-store) keeps the page BFCache-eligible.public, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400Arena-Exp, Accept-Encoding(see Vary section)public, s-maxage=60, max-age=0, stale-while-revalidate=60Accept-Encodingpublic, s-maxage=60, max-age=0, stale-while-revalidate=60Accept-EncodingAccept-EncodingAnswering the open question:
immutableandpublicimmutable: Add it, but only for fingerprinted URLs (content hash in the filename). It tells the browser never to revalidate, including on a normal reload, which removes conditional requests entirely for those assets. Do not putimmutableon any URL you update in place, because clients can be pinned to a stale copy for the fullmax-age(up to a year).public: Add it to anything that is safe for a shared cache to store (all static assets, and any HTML/JSON that is identical across users within its Vary key). It makes the shared-cache intent explicit and avoids heuristic caching surprises. Useprivatefor anything personalized so only the browser stores it.So for the AVIF images specifically: if their URLs are hashed, yes to both (
public, max-age=31536000, immutable). If they are not hashed,publicyes,immutableno, and cap the browser tier withmax-age+ SWR instead.The Vary problem (highest-impact fix on dynamic responses)
The HTML root document currently varies on six dimensions:
rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding, Arena-Exp.Each distinct combination of those header values is a separate edge cache entry. The
next-router-*headers carry high-cardinality values (router state trees differ per navigation), so a full-document entry keyed on them will approach a zero percent edge hit rate. Recommendation:Arena-Exp(experiment bucket) is bounded and belongs in the key.Accept-Encodingbelongs in the key.next-router-*header can be normalized to a small set of buckets at the edge before it enters the cache key.Action: produce a short table of each Vary dimension with its observed cardinality and whether it changes the response body, then drop the ones that do not.
TS-optimized approach (recommended)
Grounded in what the current SSAT branch already emits. TS controls three response classes, and each has a different best lever. Ordered by impact-to-effort.
1. Content-hash the tsjs bundle URL and mark it
immutable(highest impact, ships independently)Today TS serves its own JS bundle on a stable URL —
/static/tsjs=tsjs-unified.min.js— withpublic, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400plusSurrogate-Control: max-age=300and an ETag (serve_static_with_etag,http_util.rs). Every browser therefore re-validates the bundle every 5 minutes with a conditional GET.The bundle is already hash-addressable —
concatenated_hash(ids)exists inbundle.rs. Serve it at/static/tsjs=<hash>.min.js, inject that URL into the page server-side, and set:This removes revalidation entirely. When the bundle content changes the hash changes, the injected
<script src>changes, and browsers fetch the new URL. TS is uniquely positioned here because it generates both the script tag and serves the bundle, so the two sides cannot drift. Because the injected HTML isprivate, max-age=0, a new hash propagates on the very next navigation — no stale-URL window. Same trick Next.js uses for/_next/static/*.tsjs-{id}.min.js, e.g. prebid).Vary: Accept-Encoding; keep the ETag as a belt-and-suspenders 304 for any client that ignoresimmutable.2. Keep injected HTML
private, max-age=0; recover cost at the origin hopThe assembled page cannot be shared — that is inherent to inlining per-user bids, and
publisher.rsalready enforces it. The recoverable costs are the origin render and the in-guest (de)compression, neither of which changes what the browser sees:s-maxageon the order of 5 to 30 s) and confirm TS does not force that subrequest uncacheable. The auction+inject still runs per user (irreducible), but the expensive origin render amortizes across the window.Accept-Encoding: identityfrom origin to move gzip decompress off the Compute guest. This half is close to a pure win: the shielded origin gunzips on delivery to an identity request, so the guest never spends CPU decompressing (process_response_streamingcurrently setsinput_compression == output_compression, i.e. it decompresses and recompresses in-guest). Decompression is the larger and safer piece to offload.Downstream, the default is to stream identity chunked to the client, with no recompression. Fastly does not recompress unless we explicitly tell it to, and it forwards a chunked response through as chunks arrive rather than buffering the full body to compress it. So streaming the injected HTML uncompressed preserves SSAT's early head/body flush and tail-hold exactly — there is no buffering penalty and no TTFB regression. The guest spends zero CPU on decompression (offloaded to origin) and zero on recompression (not performed).
The only cost of identity-out is last-mile bandwidth: uncompressed HTML on the wire to the browser. For text/HTML that is a real but bounded increase, and it is the one tradeoff to weigh:
Amortization does not change with location: the assembled HTML is
private, max-age=0, so any compression runs on every request. That is exactly why the default should keep compression off the CPU-budgeted guest — stream identity out, and reach for in-guest gzip or edge recompression only where last-mile bytes justify it.3. Upgrade passthrough static and trim dynamic responses
/_next/static/*): TS owns the response it emits, so it can rewrite a weak originmax-ageup topublic, max-age=31536000, immutableregardless of what Next sent. Same for images with hashed URLs.next-router-*before it enters the cache key, or do not edge-cache when cardinality is unbounded (see the Vary section).4. Codify the two-tier split as config (generalize what TS already does)
TS already emits both
Surrogate-Control(edge tier) andCache-Control(browser tier) on the static path — the two-tier mechanism exists and works on Fastly. Generalize it: one policy struct per asset class{edge_ttl, browser_ttl, swr, sie, immutable, visibility}, and let each runtime adapter emitSurrogate-Control(Fastly),CDN-Cache-Control(Cloudflare/Akamai), or ans-maxagefallback. This is the multi-runtime section below; TS already has the Fastly half working.Priority order: hashed-tsjs
immutable(independent, clear win) → origin shield + identity fetch (cuts origin RPS and guest CPU) → config-driven two-tier for the rest.Cross-cutting recommendations
Vary: Accept-Encodingon every compressed response. ConfirmVary: Accept-Encodingis present everywhere compression is applied (missing on the images row today).Accept-Encoding: identityorigin fetch + edge-side recompression (see the SSAT constraint section). Static-asset Brotli is a fine, separate win; do not conflate the two.Cache-Control, which is frequentlyno-storeor a short TTL that defeats the point of rehosting. Normalize: fingerprint where possible and setimmutable; for un-fingerprinted assets apply a conservative edge TTL with SWR; leave genuinely dynamic endpoints uncached. Track this as its own line item because it is a policy decision, not a default.private(browser only) orno-store. Do not let a personalized HTML document land in the shared edge cache under apublicdirective.Multi-runtime consideration (EdgeZero / portability)
TS is moving off Fastly-only toward Cloudflare Workers and Akamai. The header used to control the edge tier differs per runtime:
Surrogate-Control(stripped before the response reaches the browser)CDN-Cache-Controls-maxageinsideCache-ControlRecommendation: define the cache policy once in TS config (per asset class: edge TTL, browser TTL, SWR, SIE, immutable flag, visibility), and let each runtime adapter emit the correct header. That keeps a single source of truth as we add runtimes and avoids re-deriving TTLs per platform. This should live alongside the EdgeZero adapter layer.
Acceptance criteria
/_next/static/*, hashed images/CSS/JS) emitpublic, max-age=31536000, immutable.s-maxage/edge header vsmax-age).next-router-*headers; full-document and RSC responses use separate cache keys. (Applies to the un-injected origin object; the SSAT-assembled document is not shared-cached at all.)private, max-age=0withSurrogate-Control/Fastly-Surrogate-Controlstripped; no cache-policy change places per-user bid data in the shared edge cache.Accept-Encoding: identityorigin fetch + edge recompression evaluated before any move to Brotli on that path.public, max-age=31536000, immutable; injected<script src>references the hashed URL. Revalidation traffic on the bundle drops to zero.Accept-Encoding: identity; default downstream path streams identity chunked to the client (no in-guest recompression). In-guest gzip or explicit Fastly edge recompression enabled only on paths where last-mile bandwidth justifies it.Vary: Accept-Encoding.Open questions / decisions needed
immutableis safe.)Arena-Expbucket? (Determinespublicvsprivate.) Note: on any SSAT ad-stack page this is already answered — it contains per-user bid data and isprivate. The question only applies to non-ad-stack pages.Accept-Encoding: identityand gunzip on delivery? (Determines whether we can move injected-HTML decompression off the guest.) Fastly does not recompress unless explicitly told and streams chunked responses without buffering, so identity-out preserves SSAT streaming; the only open call is whether last-mile bandwidth on the largest documents warrants opting into compression on specific paths.next-router-*Vary value in production traffic.stale-if-errorandstale-while-revalidate, and which edge header do we standardize the adapter output on?Out of scope
fix: address all PR IABTechLab#930 review comments
Add cache-header audit design spec for ts origin audit-headers (#930)