../

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

StepCostsCut it with
Redirectsa full round trip eachlink to final URLs; no http→https→www chains (use HSTS)
DNS lookup1 round trip to the resolverfewer origins; dns-prefetch / preconnect
TCP + TLS handshakeTCP 1 round trip + TLS 1 (TLS 1.3) or 2 (TLS 1.2); QUIC (HTTP/3) 1, or 0 on resumeHTTP/3, connection reuse, fewer origins
Request → first byteserver think time + distanceCDN, edge caching, streaming HTML, faster backend
Downloadsize ÷ bandwidth, plus TCP slow startcompression, smaller assets, CDN
Discovery of sub-resourcesparse HTML → find CSS/JS/images → repeatresources 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

browser cache CDN edge origin fresh: used, no request stale: If-None-Match fresh: answers at the edge stale: asks the origin 304: unchanged, no body or 200 with the new body fresh (max-age / s-maxage) stale-while-revalidate revalidate first age of the cached copy →
Caches between the user and your server, and what fresh, stale and revalidate mean
Cache-Control directiveMeaning
max-age=Nfresh for N seconds in any cache
s-maxage=Noverrides max-age for shared caches (CDNs, proxies)
no-cachemay store, but revalidate before every use (cheap 304s)
no-storenever store anywhere (sensitive data)
privateonly the browser may store it, not CDNs (per-user responses)
publicshared caches may store it even if the request had Authorization
immutablenever changes while fresh: skip revalidation even on reload (Firefox, Safari; Chrome doesn't revalidate subresources on reload anyway)
must-revalidateonce stale, don't serve it without revalidating
stale-while-revalidate=Nfor N seconds after going stale, serve stale and refresh in the background
stale-if-error=Nserve stale for N seconds if the origin errors (mostly a CDN feature; browsers barely honor it)
ValidatorRequest headerAnswer 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:

ResourceHeaderWhy
Hashed assets (app.3f9a1c.js, fonts, images with content hashes)public, max-age=31536000, immutablea new build = a new URL, so cache "forever"
HTML documentsno-cache (plus ETag)always check; get a 304 when unchanged
HTML at a CDNCache-Control: no-cache + CDN-Cache-Control: max-age=60, stale-while-revalidate=300fast edge hits; browsers still revalidate (browsers honor stale-while-revalidate too, so keep it out of Cache-Control)
API JSON per userprivate, no-cache or private, max-age=…never in a shared cache
Secrets, account pagesno-storenothing on disk
  • Vary lists request headers that change the response (Accept-Encoding, Accept). Keep it short: Vary: Cookie or Vary: User-Agent destroys CDN hit rates.
  • A response without Cache-Control still gets cached heuristically (often 10% of its age since Last-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

EncodingContent-EncodingUse forNotes
gzipgzipfallback for everything textuniversal
Brotlibrstatic text assets, precompressed at level 11~15–25% smaller than gzip for JS/CSS; HTTPS only in browsers
Zstandardzstddynamic responses (fast at good ratios)Baseline 2026 (Safari 26.3 was last); keep a br/gzip fallback
Compression dictionariesdcb / dczdeltas between versions of the same bundleRFC 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 by Accept-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.1HTTP/2HTTP/3
TransportTCPTCPQUIC over UDP
Requests per connection1 at a time (browsers open ~6 per origin)multiplexedmultiplexed
Head-of-line blockingper connectionat the TCP level (one lost packet stalls all streams)none across streams
Header compressionnoneHPACKQPACK
HandshakeTCP + TLSTCP + TLScombined, 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 Hints and preload instead.

Resource hints

HintDoesUse for
<link rel="preconnect" href>DNS + TCP + TLS now1–3 critical third-party origins (font CDN, image CDN, API)
<link rel="dns-prefetch" href>DNS onlymany less important origins; fallback for preconnect
<link rel="preload" href as>fetch now at the right priority, don't executelate-discovered critical resources: LCP background image, fonts, a critical script
<link rel="modulepreload" href>fetch + parse + compile one moduleES module chunks; list each one (fetching its imports too is browser-specific)
<link rel="prefetch" href>fetch at lowest priority for a future navigationthe next page's bundle; not in Safari
Speculation rulesprefetch or fully prerender next pagessee below
Hints in the head
<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 crossorigin even on the same origin, or it downloads twice.
  • preload without a matching use within a few seconds logs a console warning; unused preloads steal bandwidth from what matters. Preload 1–3 things, not 20.
  • preconnect a cross-origin host you'll fetch with CORS (fonts, fetch) with crossorigin; 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.

Elementfetchpriority="high"fetchpriority="low"
<img>the LCP imageabove-the-fold images that aren't LCP (carousel slides 2+)
<link rel="preload">a critical preloaded resourcepreloads that can wait
<script>an async script that must run earlynon-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".

Response stream
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.

Speculation rules
<script type="speculationrules">
{
  "prerender": [{
    "where": { "and": [
      { "href_matches": "/*" },
      { "not": { "href_matches": "/logout" } },
      { "not": {
        "selector_matches": "[data-no-prerender]"
      } }
    ]},
    "eagerness": "moderate"
  }]
}
</script>
eagernessTriggers
immediateas soon as the rules are seen
eagerdesktop: a 10 ms hover (Chrome 143+); mobile: shortly after a link enters the viewport
moderatedesktop: a 200 ms hover or pointer down; mobile: viewport heuristics
conservativepointer 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 to conservative, list rules to immediate. Prerendering runs your JavaScript, so analytics must wait: check document.prerendering and listen for prerenderingchange.
  • 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 bfcacheFix
an unload event listeneruse pagehide (or visibilitychange); Chrome is phasing out unload entirely
open WebRTC/WebTransport connections, open IndexedDB connections; WebSockets in Firefoxclose on pagehide, reopen on pageshow (Chrome 149+ and Safari close WebSockets for you)
Cache-Control: no-store on the documentChrome now allows these pages (evicted after 3 min or on a cookie change); other browsers still block
window.opener references between windowsrel="noopener"
Unfinished fetch / XMLHttpRequest with a bodylet them finish or abort them on pagehide
Restore from bfcache
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

LeverEffect
Static files on a CDNserved 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 HTMLsend the <head> and shell immediately, fill in slow parts later
Keep the database near the serverserver-to-DB round trips multiply with every query
Avoid serverless cold starts on the hot pathwarm/regional compute; fewer dependencies
Server-Timing headersshow where server time goes, in DevTools (Measuring)

Common mistakes

MistakeFix
max-age=31536000 on un-hashed filenameslong caching only for content-hashed URLs; no-cache otherwise
Caching HTML for hours at the browserno-cache + ETag in the browser, s-maxage at the CDN
Preloading everythingpreload only late-discovered critical resources
Font preload without crossoriginadd it: fonts are fetched in CORS mode
preconnect to ten origins1–3 critical ones; dns-prefetch for the rest
Brotli at level 11 on every dynamic responselevel 4–6 on the fly, 11 only when precompressing
An unload handler from an analytics tagpagehide; audit third-party tags for unload
Prerendering pages that fire analytics on loaddefer 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.

cache-headers.ts
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.

head.html
<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.

speculation.html
<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.

pageview.ts
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.

bfcache-socket.ts
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