2.4 — Delivery, compression & CDN¶
Module 2 · Lesson 4 · 🟢 Foundational · ~35 min
What you'll learn¶
- Compression: Brotli vs Zstd vs gzip, and how to verify what you're actually serving
- Cache‑Control headers that work, per asset class, for a Next.js app
- CDN configuration for commerce: what to cache, what to bypass, how to invalidate
- Connection‑level wins: HTTP/3, preconnect, Early Hints, and their real magnitudes
Compression¶
What to use¶
| Encoding | vs gzip | CPU cost | Use for |
|---|---|---|---|
| Brotli (q11) | −15 to −25% | High (static only) | Pre‑compressed static assets at build time |
| Brotli (q4–q6) | −10 to −15% | Low | Dynamic HTML responses |
| Zstd | ≈ Brotli, much faster | Low | Dynamic responses where supported |
| gzip | baseline | Low | Universal fallback |
The important nuance: Brotli quality level is a completely different trade‑off for static vs dynamic content. Brotli q11 on a dynamic HTML response can add 100+ ms of server CPU — worse than the bytes it saves. Use q11 only for assets compressed once at build time.
# nginx: pre-compressed static (built once), cheap dynamic
brotli_static on; # serve .br files built at deploy time
brotli on;
brotli_comp_level 5; # dynamic HTML: fast, not maximal
brotli_types text/html text/css application/javascript application/json
image/svg+xml application/xml font/woff2;
gzip on;
gzip_comp_level 6;
gzip_types text/html text/css application/javascript application/json image/svg+xml;
# Don't waste CPU compressing what's already compressed
gzip_proxied any;
// next.config.ts — if a CDN/proxy in front handles compression, don't do it twice
const config: NextConfig = {
compress: false, // let the CDN/edge do it; Node's gzip is slower and uses your CPU
};
Verify what you're actually serving¶
The most common finding here is that compression silently isn't applied to one asset class — often JSON API responses or the RSC payload.
# Check encoding + size for a set of assets
for url in \
"https://www.auroramarket.com/" \
"https://www.auroramarket.com/_next/static/chunks/main-abc123.js" \
"https://www.auroramarket.com/api/products/search?q=coat" ; do
echo "── $url"
curl -sI -H 'Accept-Encoding: br, gzip, zstd' "$url" \
| grep -iE 'content-encoding|content-type|content-length|cache-control|age|x-cache'
done
# What are you actually saving? Compare raw vs compressed
curl -s "$URL" -o /tmp/raw.js
curl -s -H 'Accept-Encoding: br' "$URL" -o /tmp/br.js
echo "raw: $(wc -c < /tmp/raw.js) br: $(wc -c < /tmp/br.js)"
Assets people forget to compress: JSON API responses, SVGs (they're text!), the RSC payload,
source maps served in production, .webmanifest, and anything served by a separate service on a
different subdomain with a different config.
Assets you should NOT compress: images (already compressed — re‑compressing wastes CPU for ~0 bytes), WOFF2 fonts (already Brotli‑compressed internally), video.
Cache-Control, by asset class¶
Getting these right is a one‑afternoon change that transforms repeat‑visit performance.
| Asset | Cache-Control |
Why |
|---|---|---|
Hashed JS/CSS (/_next/static/**) |
public, max-age=31536000, immutable |
Filename changes on content change; never revalidate |
| Product images (versioned path) | public, max-age=31536000, immutable |
Same, if you version the URL |
| Product images (unversioned) | public, max-age=86400, stale-while-revalidate=604800 |
Compromise; prefer versioning |
| HTML — cacheable pages (PDP/PLP) | public, max-age=0, s-maxage=300, stale-while-revalidate=86400 |
Browser revalidates; CDN serves instantly and refreshes in the background |
| HTML — personalized (cart/checkout/account) | private, no-store |
Never cache. Non‑negotiable |
| API — public catalog data | public, max-age=60, s-maxage=300, stale-while-revalidate=3600 |
|
| API — user data | private, no-store |
|
| Fonts (self‑hosted, hashed) | public, max-age=31536000, immutable |
|
/robots.txt, /sitemap.xml |
public, max-age=3600 |
The two most valuable directives:
immutable tells the browser not to revalidate even on reload. Without it, a hard refresh
sends conditional requests for every asset — dozens of round trips that return 304s. With
immutable, they're served from disk instantly.
stale-while-revalidate lets the CDN serve a stale response immediately while fetching a
fresh one in the background. The user gets a cache‑hit TTFB (~20 ms) and the content is at most
s-maxage stale. For a PDP where price changes a few times a day, s-maxage=300,
stale-while-revalidate=86400 means near‑100% of users get an edge hit.
// next.config.ts — headers for asset classes Next.js doesn't cover
const config: NextConfig = {
async headers() {
return [
{
// Next.js already sets immutable on /_next/static; this covers your own static dir
source: '/assets/:path*',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
],
},
{
// Never cache anything under the authenticated/commerce paths
source: '/:path(checkout|cart|account)/:rest*',
headers: [
{ key: 'Cache-Control', value: 'private, no-store, must-revalidate' },
],
},
];
},
};
// Per-route control in App Router
// app/p/[slug]/page.tsx
export const revalidate = 300; // ISR: regenerate at most every 5 minutes
// app/api/catalog/[id]/route.ts
export async function GET(req: Request) {
const data = await getCatalogItem(/* … */);
return Response.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=3600',
// Surrogate keys let you purge precisely — see below
'Surrogate-Key': `product-${data.id} category-${data.categoryId}`,
},
});
}
The
no-storetrap: settingCache-Control: no-storeon a navigable page disqualifies it from the back/forward cache in some browsers, costing 1–2.5 s on every back navigation. Useno-storeon cart/checkout where it's required, andprivate, max-age=0, must-revalidatewhere you just want freshness. See 8.3.
CDN configuration for commerce¶
What to cache at the edge¶
┌─────────────────────────────────────────────────────────────┐
│ ALWAYS CACHE (long TTL, immutable) │
│ /_next/static/** /assets/** fonts images │
├─────────────────────────────────────────────────────────────┤
│ CACHE WITH SWR (short s-maxage, long SWR) │
│ / /c/** /p/** /search (popular queries) sitemaps │
│ → the big TTFB win; requires no personalization in HTML │
├─────────────────────────────────────────────────────────────┤
│ NEVER CACHE (bypass entirely) │
│ /cart /checkout/** /account/** /api/user/** /api/cart │
└─────────────────────────────────────────────────────────────┘
Cache key hygiene¶
The cache key determines your hit ratio, and the default is usually wrong for commerce.
// Fastly-style VCL, illustrative
sub vcl_recv {
// 1. Strip tracking params — they fragment the cache catastrophically.
// utm_* alone can turn one cacheable PDP into thousands of cache entries.
set req.url = querystring.regfilter(req.url,
"^(utm_|gclid|fbclid|msclkid|_gl|mc_|ttclid|igshid)");
// 2. Sort remaining params so ?a=1&b=2 and ?b=2&a=1 share a cache entry
set req.url = querystring.sort(req.url);
// 3. Normalize device class into a small number of buckets, not the full UA
if (req.http.User-Agent ~ "(?i)mobile|android|iphone") {
set req.http.X-Device = "mobile";
} else {
set req.http.X-Device = "desktop";
}
unset req.http.User-Agent; // never let raw UA into the cache key
// 4. Bypass for authenticated/commerce paths
if (req.url ~ "^/(cart|checkout|account)" || req.http.Cookie ~ "session_id=") {
return(pass);
}
}
Cache key rules for commerce:
- Strip tracking parameters. This is often the single biggest hit‑ratio win. A PDP linked
from 40 email campaigns with distinct
utm_contentvalues becomes 40 cache misses. - Never include the raw User‑Agent. Bucket to 2–3 device classes at most.
- Bucket geo, don't key on city. Country or currency‑region, not IP.
- Careful with
Vary.Vary: Accept-Encodingis fine.Vary: Cookiedestroys your cache. - Consider a cookie allowlist: strip all cookies from the cache key except the 2–3 that genuinely change the response (currency, locale, A/B bucket).
Invalidation with surrogate keys¶
Purging by URL doesn't scale when one product appears on 40 category pages, the homepage, and 6 search result sets. Tag responses instead:
// When rendering a PLP, tag it with every product it contains
return new Response(html, {
headers: {
'Surrogate-Key': [
`category-${categoryId}`,
...products.map((p) => `product-${p.id}`),
].join(' '),
'Surrogate-Control': 'max-age=300, stale-while-revalidate=86400',
},
});
# Price change on one SKU → purge everywhere it appears, in one call
curl -X POST "https://api.cdn-vendor.com/service/$SERVICE_ID/purge/product-1234" \
-H "Fastly-Key: $CDN_API_KEY" \
-H "fastly-soft-purge: 1" # soft purge: mark stale, serve stale-while-revalidating
Soft purge (mark stale rather than evict) is the right default: users keep getting instant responses from the stale copy while the CDN refreshes in the background. Hard purge causes a thundering herd against your origin — the exact moment you least want it, since price changes often happen at campaign launch.
Full architecture in 7.2 Caching architecture.
Connection‑level wins¶
Preconnect — powerful and easy to overdo¶
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<head>
{/* Only origins needed for the critical path. Two or three, maximum. */}
<link rel="preconnect" href="https://images.auroramarket.com" crossOrigin="anonymous" />
<link rel="preconnect" href="https://api.auroramarket.com" />
{/* Cheaper hint for origins needed later — DNS only, no TCP/TLS */}
<link rel="dns-prefetch" href="https://chat-vendor.example.com" />
</head>
<body>{children}</body>
</html>
);
}
preconnect= DNS + TCP + TLS. Saves 100–300 ms. Costs a connection.dns-prefetch= DNS only. Saves 20–150 ms. Nearly free.- Chrome closes unused preconnected sockets after ~10 s, so preconnecting to something you use at t=15 s is wasted.
crossOriginmust match how the resource is fetched. Fonts and CORS‑fetched images needcrossOrigin="anonymous"; get it wrong and the browser opens a second connection, making things worse.
React 19 also gives you programmatic control, useful when the origin depends on data:
import { preconnect, prefetchDNS, preload, preinit } from 'react-dom';
function ProductPage({ product }) {
// Runs during render, on server and client
preconnect('https://images.auroramarket.com', { crossOrigin: 'anonymous' });
preload(product.heroImageUrl, { as: 'image', fetchPriority: 'high' });
prefetchDNS('https://reviews-vendor.example.com');
return /* … */;
}
HTTP/3¶
QUIC removes a round trip from connection setup and eliminates head‑of‑line blocking at the transport layer. On lossy mobile networks — 22% of Aurora's traffic — this is a real 5–15% improvement in load time; on a good desktop connection it's negligible.
Most CDNs enable it with a toggle. Verify it's actually being used:
curl -sI --http3 https://www.auroramarket.com | head -1
# Or check the Protocol column in DevTools → Network (enable it via right-click on the header row)
Don't expect miracles. HTTP/3 is a "turn it on, take the free 5%" item, not a project.
Early Hints (103)¶
The server sends a 103 Early Hints response with Link headers before the real response,
letting the browser start fetching critical resources during your server's think time. If your
TTFB is 400 ms, that's 400 ms of otherwise‑idle network.
HTTP/1.1 103 Early Hints
Link: </_next/static/css/app.css>; rel=preload; as=style
Link: <https://images.auroramarket.com>; rel=preconnect
HTTP/1.1 200 OK
Content-Type: text/html
…
Caveats: support is uneven across browsers and CDNs, and it only helps when your TTFB is already slow — which means it's a mitigation, not a fix. Fix TTFB first (6.4); add Early Hints for the residual.
Priority Hints¶
<!-- Raise the LCP image above other images in the queue -->
<img src="/hero.avif" fetchpriority="high" alt="…" />
<!-- Lower a below-the-fold decorative image that would otherwise compete -->
<img src="/pattern.avif" fetchpriority="low" loading="lazy" alt="" />
// Deprioritize a non-critical fetch so it doesn't compete with the LCP resource
fetch('/api/recommendations', { priority: 'low' });
next/image's priority prop sets fetchpriority="high" for you. The low cases are the ones
you have to do by hand, and they matter more than people expect on image‑dense PLPs.
Common mistakes¶
| Mistake | Cost |
|---|---|
| Brotli q11 on dynamic HTML | +50–150 ms server CPU per request |
| Compressing at both origin and CDN | Double CPU, no benefit |
| Forgetting to compress JSON/SVG/RSC payloads | 60–80% wasted bytes on API responses |
No immutable on hashed assets |
Revalidation round trips on every reload |
Vary: Cookie on cacheable HTML |
Cache hit ratio → ~0 |
| Tracking params in the cache key | Hit ratio collapse on campaign traffic |
| Raw User‑Agent in the cache key | Effectively per‑user caching |
| Preconnecting to 8 origins | Connection contention; slower than 3 |
no-store on ordinary navigable pages |
Disables bfcache: +1–2.5 s on back navigation |
| Hard purge on price change | Origin stampede at the worst moment |
Lab 2.4 — Delivery audit¶
- Compression sweep. Run the
curlloop over 10 representative URLs (HTML, JS, CSS, JSON API, SVG, font, RSC payload). Find anything missingcontent-encoding. - Cache headers. Same URLs — check
cache-control,age, and your CDN'sx-cacheheader. Anything withage: 0on a repeat request isn't being cached. - Hit ratio. Pull your CDN's cache hit ratio for HTML, split by path pattern. Below 80% on
/p/**means either you're not caching HTML or your cache key is fragmented. - Cache key. Request the same PDP with and without
?utm_source=test. Differentagevalues → tracking params are in your key. Fix it; this alone can double hit ratio. - Protocol. Confirm HTTP/3 is negotiated.
- Preconnect. Count your
preconnecthints; keep the 2–3 that matter, demote the rest todns-prefetch.
Expected: TTFB −200 to −600 ms on cache hits, repeat‑visit LCP −30–50%.
Checklist¶
- Brotli/Zstd on all text assets, verified with
curl(including JSON and RSC payloads) - Static compression at build time (q11), dynamic at a cheap level (q4–6)
- Compression happens in exactly one place
-
immutableon all hashed assets -
s-maxage+stale-while-revalidateon cacheable HTML -
private, no-storeon cart/checkout/account only - Tracking params stripped from the cache key
- Device bucketing (2–3 buckets), no raw UA in the key
- Surrogate keys for precise invalidation; soft purge as the default
- HTTP/3 enabled and verified
- ≤ 3 preconnects, with correct
crossOrigin