Network & loading
Getting bytes to the browser sooner, and in the right order: HTTP caching, compression, protocols, resource hints and priorities, early hints, speculative loading and the back/forward cache. Most of these improve TTFB and LCP. Service-worker caching is on Service Workers & PWAs.
The request waterfall
| Step | Costs | Cut it with |
|---|---|---|
| Redirects | a full round trip each | link to final URLs; no http→https→www chains (use HSTS) |
| DNS lookup | 1 round trip to the resolver | fewer origins; dns-prefetch / preconnect |
| TCP + TLS handshake | TCP 1 round trip + TLS 1 (TLS 1.3) or 2 (TLS 1.2); QUIC (HTTP/3) 1, or 0 on resume | HTTP/3, connection reuse, fewer origins |
| Request → first byte | server think time + distance | CDN, edge caching, streaming HTML, faster backend |
| Download | size ÷ bandwidth, plus TCP slow start | compression, smaller assets, CDN |
| Discovery of sub-resources | parse HTML → find CSS/JS/images → repeat | resources in the HTML, preload, fewer chained requests |
Latency, not bandwidth, is usually the limit on mobile: every chained request (HTML → CSS →
@import → font) adds round trips. Flatten chains before shrinking bytes.
HTTP caching
Cache-Control directive | Meaning |
|---|---|
max-age=N | fresh for N seconds in any cache |
s-maxage=N | overrides max-age for shared caches (CDNs, proxies) |
no-cache | may store, but revalidate before every use (cheap 304s) |
no-store | never store anywhere (sensitive data) |
private | only the browser may store it, not CDNs (per-user responses) |
public | shared caches may store it even if the request had Authorization |
immutable | never changes while fresh: skip revalidation even on reload (Firefox, Safari; Chrome doesn't revalidate subresources on reload anyway) |
must-revalidate | once stale, don't serve it without revalidating |
stale-while-revalidate=N | for N seconds after going stale, serve stale and refresh in the background |
stale-if-error=N | serve stale for N seconds if the origin errors (mostly a CDN feature; browsers barely honor it) |
| Validator | Request header | Answer if unchanged |
|---|---|---|
ETag: "abc" | If-None-Match: "abc" | 304 Not Modified, no body |
Last-Modified: <date> | If-Modified-Since: <date> | 304 Not Modified |
The standard pattern:
| Resource | Header | Why |
|---|---|---|
Hashed assets (app.3f9a1c.js, fonts, images with content hashes) | public, max-age=31536000, immutable | a new build = a new URL, so cache "forever" |
| HTML documents | no-cache (plus ETag) | always check; get a 304 when unchanged |
| HTML at a CDN | Cache-Control: no-cache + CDN-Cache-Control: max-age=60, stale-while-revalidate=300 | fast edge hits; browsers still revalidate (browsers honor stale-while-revalidate too, so keep it out of Cache-Control) |
| API JSON per user | private, no-cache or private, max-age=… | never in a shared cache |
| Secrets, account pages | no-store | nothing on disk |
Varylists request headers that change the response (Accept-Encoding,Accept). Keep it short:Vary: CookieorVary: User-Agentdestroys CDN hit rates.- A response without
Cache-Controlstill gets cached heuristically (often 10% of its age sinceLast-Modified). Always set the header explicitly. - Never CDN-cache HTML personalized by a cookie. Use
private, or keep personal bits out of the cached HTML and fetch them client-side.
Compression
| Encoding | Content-Encoding | Use for | Notes |
|---|---|---|---|
| gzip | gzip | fallback for everything text | universal |
| Brotli | br | static text assets, precompressed at level 11 | ~15–25% smaller than gzip for JS/CSS; HTTPS only in browsers |
| Zstandard | zstd | dynamic responses (fast at good ratios) | Baseline 2026 (Safari 26.3 was last); keep a br/gzip fallback |
| Compression dictionaries | dcb / dcz | deltas between versions of the same bundle | RFC 9842; Chromium only; Use-As-Dictionary / Available-Dictionary headers |
- Compress text: HTML, CSS, JS, JSON, SVG, TTF/OTF fonts. Don't recompress images, video, WOFF/WOFF2 or zips: they're already compressed.
- Precompress static files at build time (
.br,.gz) and let the server pick byAccept-Encoding. Compress dynamic responses on the fly at a lower level (Brotli 4–6, zstd 3). - Most CDNs (Vercel, Cloudflare, Netlify) compress automatically.
HTTP/2, HTTP/3 & connections
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| Transport | TCP | TCP | QUIC over UDP |
| Requests per connection | 1 at a time (browsers open ~6 per origin) | multiplexed | multiplexed |
| Head-of-line blocking | per connection | at the TCP level (one lost packet stalls all streams) | none across streams |
| Header compression | none | HPACK | QPACK |
| Handshake | TCP + TLS | TCP + TLS | combined, 1 RTT (0-RTT on resume) |
- HTTP/1.1-era tricks hurt on HTTP/2+: domain sharding (new connections), sprites and giant bundles (bad caching granularity). Many small-to-medium files are fine now.
- Each extra origin costs DNS + TCP + TLS. Self-host fonts and critical scripts on your own origin.
- HTTP/2 server push is gone (removed from Chrome); use
103 Early Hintsandpreloadinstead.
Resource hints
| Hint | Does | Use for |
|---|---|---|
<link rel="preconnect" href> | DNS + TCP + TLS now | 1–3 critical third-party origins (font CDN, image CDN, API) |
<link rel="dns-prefetch" href> | DNS only | many less important origins; fallback for preconnect |
<link rel="preload" href as> | fetch now at the right priority, don't execute | late-discovered critical resources: LCP background image, fonts, a critical script |
<link rel="modulepreload" href> | fetch + parse + compile one module | ES module chunks; list each one (fetching its imports too is browser-specific) |
<link rel="prefetch" href> | fetch at lowest priority for a future navigation | the next page's bundle; not in Safari |
| Speculation rules | prefetch or fully prerender next pages | see below |
<link rel="preconnect" href="https://img.example-cdn.com">
<link rel="preload" href="/fonts/inter.woff2" as="font"
type="font/woff2" crossorigin>
<link rel="preload" as="image" href="/hero.avif"
imagesrcset="/hero-800.avif 800w, /hero.avif 1600w"
imagesizes="100vw" fetchpriority="high">
<link rel="modulepreload" href="/assets/app.3f9a1c.js">- Fonts are always fetched in CORS mode, so a font preload needs
crossorigineven on the same origin, or it downloads twice. preloadwithout a matching use within a few seconds logs a console warning; unused preloads steal bandwidth from what matters. Preload 1–3 things, not 20.preconnecta cross-origin host you'll fetch with CORS (fonts,fetch) withcrossorigin; the credentialed and anonymous connections are separate.
Priorities: fetchpriority
Browsers assign each request a priority (Highest → Lowest). CSS in <head> and fonts are high; images
start low until they're known to be in the viewport; async scripts are low. fetchpriority nudges that.
| Element | fetchpriority="high" | fetchpriority="low" |
|---|---|---|
<img> | the LCP image | above-the-fold images that aren't LCP (carousel slides 2+) |
<link rel="preload"> | a critical preloaded resource | preloads that can wait |
<script> | an async script that must run early | non-critical scripts |
fetch(url, { priority }) | "high" for a request the UI waits on | "low" for analytics, prefetching data |
fetchpriority works on <img>, <link> and <script> (not <iframe>) in all major engines (Safari
17.2+, Firefox 132+). Use it on one or two resources; if everything
is high, nothing is.
103 Early Hints
A server (or CDN) can send 103 Early Hints with Link headers while it's still building the HTML, so
the browser starts connecting and fetching during "server think time".
HTTP/1.1 103 Early Hints
Link: </styles.3f9a.css>; rel=preload; as=style
Link: <https://img.example-cdn.com>; rel=preconnect
HTTP/1.1 200 OK
Content-Type: text/html
...Early Hints only help when TTFB is long (server rendering, database calls). Chrome and Firefox act on
preconnect and preload in a 103; Safari only on preconnect. Chrome and Safari ignore 103s over
HTTP/1.1 (the example above uses HTTP/1.1-style status lines only for readability). Cloudflare and Fastly can generate
them from earlier Link headers.
Speculation rules: prefetch & prerender
The Speculation Rules API tells the browser which next pages to prefetch (fetch the document) or prerender (load and render it in a hidden tab), so the next navigation is near-instant.
<script type="speculationrules">
{
"prerender": [{
"where": { "and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": "/logout" } },
{ "not": {
"selector_matches": "[data-no-prerender]"
} }
]},
"eagerness": "moderate"
}]
}
</script>eagerness | Triggers |
|---|---|
immediate | as soon as the rules are seen |
eager | desktop: a 10 ms hover (Chrome 143+); mobile: shortly after a link enters the viewport |
moderate | desktop: a 200 ms hover or pointer down; mobile: viewport heuristics |
conservative | pointer or touch down |
- Chromium ships speculation rules; Safari 26.2 has prefetch-only support behind a flag; other browsers
ignore the
<script>safely. Document rules default toconservative, list rules toimmediate. Prerendering runs your JavaScript, so analytics must wait: checkdocument.prerenderingand listen forprerenderingchange. - Never prerender URLs with side effects (logout, add-to-cart GET links). Exclude them with
not. - Prerender is same-origin (Chrome also allows same-site cross-origin with an opt-in header); cross-origin prefetch works, without cookies.
- For SPAs, the router's own prefetching (Next.js
<Link>) plays this role for client-side navigations.
Back/forward cache
The bfcache keeps a whole page (JS heap included) in memory when you navigate away, so Back/Forward restores it instantly. It's the fastest navigation there is, and it's easy to break.
| Blocks bfcache | Fix |
|---|---|
an unload event listener | use pagehide (or visibilitychange); Chrome is phasing out unload entirely |
| open WebRTC/WebTransport connections, open IndexedDB connections; WebSockets in Firefox | close on pagehide, reopen on pageshow (Chrome 149+ and Safari close WebSockets for you) |
Cache-Control: no-store on the document | Chrome now allows these pages (evicted after 3 min or on a cookie change); other browsers still block |
window.opener references between windows | rel="noopener" |
Unfinished fetch / XMLHttpRequest with a body | let them finish or abort them on pagehide |
addEventListener("pageshow", (event) => {
if (event.persisted) {
refreshStaleData(); // restored from bfcache
}
});Test in DevTools → Application → Back/forward cache → Test back/forward cache: it lists every
blocking reason. In the field, PerformanceNavigationTiming.notRestoredReasons reports them (Chromium).
CDNs & TTFB
| Lever | Effect |
|---|---|
| Static files on a CDN | served from a nearby edge: 10–50 ms instead of 100–300 ms |
Cache HTML at the edge (SSG, ISR, s-maxage) | TTFB near the edge's latency, no server work |
| Stream HTML | send the <head> and shell immediately, fill in slow parts later |
| Keep the database near the server | server-to-DB round trips multiply with every query |
| Avoid serverless cold starts on the hot path | warm/regional compute; fewer dependencies |
Server-Timing headers | show where server time goes, in DevTools (Measuring) |
Common mistakes
| Mistake | Fix |
|---|---|
max-age=31536000 on un-hashed filenames | long caching only for content-hashed URLs; no-cache otherwise |
| Caching HTML for hours at the browser | no-cache + ETag in the browser, s-maxage at the CDN |
| Preloading everything | preload only late-discovered critical resources |
Font preload without crossorigin | add it: fonts are fetched in CORS mode |
preconnect to ten origins | 1–3 critical ones; dns-prefetch for the rest |
| Brotli at level 11 on every dynamic response | level 4–6 on the fly, 11 only when precompressing |
An unload handler from an analytics tag | pagehide; audit third-party tags for unload |
| Prerendering pages that fire analytics on load | defer until prerenderingchange |
Vary: User-Agent for "mobile HTML" | responsive design; client hints if you must vary |
Recipes
Cache headers in Hono
Immutable caching for the bundler's hashed output, revalidation for everything else. It never overrides a
header a route set itself (no-store, private), and never caches errors.
import { Hono } from "hono";
const app = new Hono();
// hashed output: Vite /assets/, Next.js /_next/static/
const HASHED_DIRS = ["/assets/", "/_next/static/"];
app.use("*", async (c, next) => {
await next();
if (c.res.headers.has("Cache-Control")) return;
const hashed = HASHED_DIRS.some((dir) =>
c.req.path.startsWith(dir));
const cc = hashed && c.res.ok
? "public, max-age=31536000, immutable"
: "no-cache";
c.header("Cache-Control", cc);
});
export default app;Preload the LCP image
When the hero image is set from CSS or picked by JS, the preload scanner can't find it; tell it.
<link rel="preload" as="image" fetchpriority="high"
href="/img/hero-1600.avif"
imagesrcset="/img/hero-800.avif 800w,
/img/hero-1600.avif 1600w"
imagesizes="100vw" type="image/avif">Prerender likely next pages
Moderate eagerness: prerenders on hover or pointer down, and skips API routes and logout.
<script type="speculationrules">
{
"prerender": [{
"where": { "and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": "/api/*" } },
{ "not": { "href_matches": "/logout" } }
]},
"eagerness": "moderate"
}]
}
</script>Analytics that respect prerendering
Count a page view only when the user actually sees the page.
function whenActivated(fn: () => void): void {
if (document.prerendering) {
document.addEventListener("prerenderingchange", fn, {
once: true,
});
} else {
fn();
}
}
whenActivated(() => sendPageView(location.pathname));Keep the page bfcache-friendly
Close live connections when the page is hidden and reopen them when it comes back.
let socket: WebSocket | null = null;
const open = () => {
socket = new WebSocket("wss://example.com/live");
};
addEventListener("pagehide", () => {
socket?.close();
socket = null;
});
addEventListener("pageshow", (e) => {
if (e.persisted || !socket) open();
});References
- MDN: HTTP caching (opens in a new tab),
Cache-Control(opens in a new tab),ETag(opens in a new tab),Vary(opens in a new tab), Compression in HTTP (opens in a new tab) - MDN:
rel=preload(opens in a new tab),rel=preconnect(opens in a new tab),rel=modulepreload(opens in a new tab),rel=prefetch(opens in a new tab),fetchpriority(opens in a new tab) - MDN: Speculation Rules API (opens in a new tab),
<script type="speculationrules">(opens in a new tab),103 Early Hints(opens in a new tab), Evolution of HTTP (opens in a new tab) - MDN:
pageshow(opens in a new tab),pagehide(opens in a new tab),notRestoredReasons(opens in a new tab) - RFC 9111: HTTP Caching (opens in a new tab), RFC 5861: stale-while-revalidate (opens in a new tab), RFC 9213: Targeted cache control (
CDN-Cache-Control) (opens in a new tab), RFC 8297: Early Hints (opens in a new tab) - web.dev: Back/forward cache (opens in a new tab), Optimize resource loading with Fetch Priority (opens in a new tab), Prefetch resources (opens in a new tab); Chrome for Developers: Prerender pages (opens in a new tab), Early Hints (opens in a new tab)
- Ilya Grigorik: High Performance Browser Networking (opens in a new tab) (TCP, TLS, HTTP/2 chapters)