Network Performance and the Critical Chain
The Chain That Controls Everything
Here's the uncomfortable truth most developers ignore: the time between a user clicking a link and seeing actual content isn't some magical black box. It's a deterministic chain of network requests, parsing stages, and rendering steps — and every single link in that chain is an opportunity you're probably wasting.
When a page takes 3 seconds to load, it's not because "the server is slow" or "there's too much JavaScript." It's because you haven't mapped the critical chain — the precise sequence of dependent resources that must load, parse, and execute before the user sees anything.
This is the skill that separates staff engineers from mid-levels. Not knowing the theory — controlling the chain.
Think of a page load like a relay race with mandatory legs. The baton (user's request) passes through DNS, TCP, TLS, server processing, download, HTML parsing, CSS parsing, JavaScript execution, layout, paint, and composite — in that order. You can't skip legs, but you can make each one faster, and you can start warming up the next runner before the baton arrives. That's network performance: shortening each leg and overlapping them wherever possible.
The Critical Rendering Path — Revisited for Network Thinking
You've seen the critical rendering path before. But here we're looking at it through a network lens — which resources block which stages, and what that means for your loading strategy.
The key insight: stages 3 and 4 are the bottleneck. CSS blocks rendering, JavaScript blocks parsing. Everything you do in network performance is about minimizing these two blockers.
Render-Blocking vs Parser-Blocking — The Crucial Distinction
These two terms get confused constantly. They describe different problems.
Render-blocking means the browser won't paint anything to the screen. CSS is render-blocking — the browser waits for CSSOM before compositing a single frame. The user stares at a blank page.
Parser-blocking means the browser stops parsing HTML. Synchronous JavaScript is parser-blocking — the HTML parser freezes until the script downloads and executes. No new resources are discovered, no new DOM nodes are created.
<!-- Render-blocking: browser won't paint until this CSS is fully parsed -->
<link rel="stylesheet" href="/critical.css">
<!-- Parser-blocking: HTML parsing stops until this JS downloads and runs -->
<script src="/app.js"></script>
<!-- NOT parser-blocking: downloads in parallel, runs after parsing -->
<script src="/app.js" defer></script>
<!-- NOT parser-blocking: downloads in parallel, runs when ready -->
<script src="/analytics.js" async></script>
async vs defer — When Each Is Correct
HTML parsing: ████████████░░░░░░████████████████████
sync <script>: ↓ pause ↓ [download] [execute] ↑ resume ↑
async <script>: [download····] [execute] (may interrupt parsing)
defer <script>: [download·········] [execute after parsing]
Use defer for application scripts that need the full DOM — this is your default choice. Scripts execute in document order, after HTML parsing completes but before DOMContentLoaded.
Use async for independent scripts that don't touch the DOM — analytics, monitoring, A/B testing. Execution order is not guaranteed.
Use neither (synchronous) essentially never in modern applications. The only exception: inline scripts that must run before any rendering (critical feature detection, theme initialization to prevent FOUC).
Resource Hints — Telling the Browser What's Coming
Resource hints let you give the browser a head start on resources it hasn't discovered yet. Think of them as you reading the recipe before starting to cook, instead of discovering each ingredient one step at a time.
The Complete Resource Hint Family
| Hint | What It Does | Priority | When to Use |
|---|---|---|---|
| preconnect | DNS + TCP + TLS handshake | High | Third-party origins needed immediately (fonts, CDN, API) |
| dns-prefetch | DNS lookup only | Low | Origins needed later or as preconnect fallback |
| preload | Downloads a specific resource immediately | High | Critical resources discovered late (fonts, hero image, key CSS) |
| prefetch | Downloads a resource for future navigation | Low | Resources needed on the next likely page |
| modulepreload | Preloads ES module with dependency resolution | High | JavaScript modules needed for current page |
preconnect and dns-prefetch
Every connection to a new origin costs time — DNS lookup (20-120ms), TCP handshake (one round trip), TLS negotiation (one to two round trips). That's 100-300ms before a single byte transfers.
<!-- Critical: font CDN, your API, your asset CDN -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://api.yourapp.com" crossorigin>
<!-- Use dns-prefetch as a fallback and for less-critical origins -->
<link rel="dns-prefetch" href="https://fonts.gstatic.com">
<link rel="dns-prefetch" href="https://analytics.example.com">
If the eventual resource request uses CORS (fonts always do, fetch API calls do), the preconnect must include crossorigin. Without it, the browser opens a non-CORS connection, then opens a second CORS connection when the actual request happens — your preconnect was wasted. This is the most common preconnect mistake.
Limit preconnects to 4-6 origins. Each connection costs memory and CPU. Preconnecting to everything is worse than preconnecting to nothing.
preload — The Precision Tool
preload is the most powerful and most dangerous resource hint. It tells the browser: "Download this specific resource right now — I guarantee I need it for this page."
<!-- Preload the hero image that LCP depends on -->
<link rel="preload" href="/hero.avif" as="image" type="image/avif">
<!-- Preload a critical font -->
<link rel="preload" href="/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<!-- Preload critical CSS not in the initial HTML -->
<link rel="preload" href="/above-fold.css" as="style">
The as attribute is mandatory — it tells the browser the resource type so it can assign correct priority, set the right Accept header, and apply CSP checks. Without as, the resource loads at lowest priority.
Every preloaded resource competes for bandwidth with other downloads. Preloading a 500KB image pushes back your CSS download. If you preload something and never use it, the browser warns in the console and you've wasted bandwidth. Preload only resources that are critical for the current page and discovered late in the parsing process.
prefetch — Preparing for the Next Page
prefetch downloads resources the user will probably need on their next navigation. The browser downloads these at idle priority — they never compete with current-page resources.
<!-- User is on the landing page, likely to visit /dashboard next -->
<link rel="prefetch" href="/dashboard.js">
<link rel="prefetch" href="/dashboard.css">
<!-- Prefetch data for a likely next interaction -->
<link rel="prefetch" href="/api/user/profile" as="fetch" crossorigin>
modulepreload — ES Module Awareness
Standard preload for JavaScript doesn't understand ES module dependency chains. modulepreload does — it downloads the module, parses it, and recursively fetches its static imports.
<!-- Preloads the module AND its static imports -->
<link rel="modulepreload" href="/app.js">
<link rel="modulepreload" href="/components/Header.js">
This eliminates the "waterfall" where the browser downloads module A, discovers it imports module B, downloads B, discovers it imports C, and so on. With modulepreload, all modules in the chain start downloading immediately.
fetchpriority — Fine-Grained Control
The fetchpriority attribute overrides the browser's default priority assignment for individual resources. This is newer and incredibly useful.
<!-- Boost the LCP image above other images -->
<img src="/hero.avif" fetchpriority="high" alt="Hero banner">
<!-- Lower the priority of below-fold images -->
<img src="/footer-logo.avif" fetchpriority="low" alt="Company logo" loading="lazy">
<!-- Boost a critical script -->
<script src="/critical-interaction.js" fetchpriority="high" defer></script>
<!-- Lower priority of non-critical CSS -->
<link rel="stylesheet" href="/animations.css" fetchpriority="low">
The three values: high, low, and auto (default). The browser already assigns smart defaults (CSS is high, images are low unless in viewport), but fetchpriority lets you override when you know better.
Early Hints (103) — The Server Joins the Race
Here's a wild idea: what if the server could send resource hints before the page even starts generating?
That's HTTP 103 Early Hints. When a browser sends a request, the server can immediately respond with a 103 status containing Link headers — telling the browser to start preconnecting and preloading — while the server is still generating the actual response.
HTTP/1.1 103 Early Hints
Link: </styles.css>; rel=preload; as=style
Link: </app.js>; rel=preload; as=script
Link: <https://fonts.gstatic.com>; rel=preconnect; crossorigin
HTTP/1.1 200 OK
Content-Type: text/html
...actual page content...
The time between the browser sending the request and the server finishing the response (Time to First Byte, TTFB) is normally wasted. With 103, the browser starts downloading critical resources during that wait.
Where it matters most: server-rendered pages with significant TTFB (database queries, auth checks, API calls). If your TTFB is 50ms, Early Hints barely matter. If it's 500ms, they can save 200-400ms of idle time.
Next.js supports Early Hints through custom server configuration. Cloudflare, Fastly, and most modern CDNs support 103 natively. If your pages have high TTFB, this is low-hanging fruit — the implementation is a server config change, not a code change.
HTTP/2 Multiplexing — Why It Changes Everything
HTTP/1.1 has a fundamental problem: one request per connection at a time. Browsers work around this by opening 6 parallel connections per origin — but that's still just 6 simultaneous requests.
HTTP/2 fixes this with multiplexing: unlimited parallel requests over a single connection. Every request becomes an independent stream, interleaved on the same TCP connection.
HTTP/1.1 (6 connection limit per origin):
Connection 1: [====CSS====] [====JS-1====] [====IMG-1====]
Connection 2: [====JS-2====] [====IMG-2====]
Connection 3: [====FONT====] [====IMG-3====]
Connection 4: [====IMG-4====] ...waiting...
Connection 5: [====IMG-5====] ...waiting...
Connection 6: [====IMG-6====] ...waiting...
HTTP/2 (single connection, all streams multiplexed):
Single conn: [CSS][JS-1][JS-2][FONT][IMG-1][IMG-2][IMG-3][IMG-4]...
(all streams interleaved, priority-based scheduling)
What HTTP/2 Means for Optimization
Several HTTP/1.1 "best practices" become anti-patterns under HTTP/2:
- Domain sharding (spreading resources across multiple domains to bypass the 6-connection limit) — harmful under HTTP/2. More origins means more connections, more TLS handshakes, and no multiplexing benefit.
- CSS/JS concatenation into mega-bundles — less important. Smaller individual files can stream independently and cache individually. A change to one utility file doesn't invalidate your entire CSS bundle.
- Image spriting — obsolete. Individual images load just as fast and can be independently cached and lazy-loaded.
What still matters: reducing total bytes (compression, right formats), reducing round trips (resource hints), and prioritization (which streams get bandwidth first).
Compression — Brotli vs gzip
Every text-based resource (HTML, CSS, JS, JSON, SVG) should be compressed. The question is which algorithm.
| Feature | gzip | Brotli |
|---|---|---|
| Compression ratio | Good (baseline) | 15-25% smaller than gzip at equivalent speed |
| Decompression speed | Fast | Equally fast (on modern hardware) |
| Compression speed | Fast | Slower at high levels (pre-compress static assets) |
| Browser support | Universal | All modern browsers (97%+ global support) |
| Best for | Dynamic responses (API, SSR) | Static assets (JS, CSS, fonts) |
| Content-Encoding | gzip | br |
The strategy is straightforward: use Brotli for static assets (pre-compressed at build time with high compression level), gzip for dynamic content (compressed on-the-fly where Brotli's slower compression speed matters).
# Typical savings for a 200KB JavaScript bundle:
Uncompressed: 200 KB
gzip (level 6): 58 KB (71% reduction)
Brotli (level 11): 46 KB (77% reduction — 20% smaller than gzip)
For a site with 500KB of JavaScript, Brotli saves roughly 60KB over gzip. On a 3G connection, that's about 400ms faster.
Open DevTools, go to Network tab, and check the Content-Encoding response header. If you see gzip on static assets instead of br, you're leaving free performance on the table. Most modern hosting platforms (Vercel, Netlify, Cloudflare) serve Brotli automatically.
Font Loading Strategies
Fonts are uniquely tricky. They're render-blocking by default (the browser won't paint text until the font loads), they cause layout shifts when they swap in, and they're often hosted on third-party origins that need connection warming.
The font-display Property
font-display controls what happens while the font is downloading:
@font-face {
font-family: 'Inter';
src: url('/inter-var.woff2') format('woff2');
font-display: swap; /* Show fallback immediately, swap when ready */
}
| Value | Behavior | Best For |
|---|---|---|
| swap | Show fallback immediately, swap when font loads (may cause layout shift) | Body text where readability matters more than visual stability |
| optional | Show fallback, swap only if font loads very fast (under 100ms). Otherwise stays with fallback | Performance-critical pages. Prevents all layout shift from fonts |
| fallback | Brief invisible period (100ms), then fallback, swap if font loads within 3s | Balance between visual stability and using custom font |
| block | Invisible text for up to 3s while font loads | Icon fonts (where fallback text would be meaningless) |
| auto | Browser default (usually block) | Essentially never — always specify explicitly |
The right default for most sites: font-display: optional. It eliminates all font-related layout shift. If the font is cached (repeat visits), it loads instantly and is used. If not, the user sees the fallback — and the font is cached for next time. This is what Google Fonts uses internally.
The Complete Font Loading Strategy
<!-- 1. Preconnect to font origin -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- 2. Preload the most critical font file -->
<link
rel="preload"
href="/fonts/inter-var-latin.woff2"
as="font"
type="font/woff2"
crossorigin
>
/* 3. Use font-display: optional for zero layout shift */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var-latin.woff2') format('woff2');
font-display: optional;
unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}
The unicode-range descriptor is the secret weapon. It tells the browser to only download the font file if characters from that range actually appear on the page. Combined with subsetting (removing unused glyphs), a 300KB font becomes 30KB.
Image Optimization — The Biggest Byte Win
Images account for roughly half of the average page's bytes. The three levers: format (which codec), sizing (which dimensions), and loading (when to download).
Format Hierarchy: AVIF, WebP, JPEG
The compression quality order is clear: AVIF is better than WebP is better than JPEG. The numbers are dramatic.
Photo (1200x630 hero image):
JPEG: 180 KB
WebP: 126 KB (30% smaller than JPEG)
AVIF: 90 KB (50% smaller than JPEG)
Serve the best format each browser supports:
<picture>
<source srcset="/hero.avif" type="image/avif">
<source srcset="/hero.webp" type="image/webp">
<img src="/hero.jpg" alt="Course overview" width="1200" height="630">
</picture>
Responsive Images with srcset and sizes
Sending a 3000px image to a 375px phone is pouring bandwidth down the drain. srcset with sizes lets the browser pick the right resolution.
<img
srcset="
/hero-400.avif 400w,
/hero-800.avif 800w,
/hero-1200.avif 1200w,
/hero-2400.avif 2400w
"
sizes="(max-width: 640px) 100vw,
(max-width: 1024px) 80vw,
1200px"
src="/hero-1200.avif"
alt="Course overview"
width="1200"
height="630"
loading="lazy"
decoding="async"
>
How it works: sizes tells the browser how wide the image will render (before layout). srcset lists available widths. The browser picks the smallest file that covers the rendered width at the device's pixel density. A 375px phone at 2x picks the 800w image (375 * 2 = 750, rounded up to 800).
Loading Strategies
<!-- Above-fold LCP image: load eagerly, boost priority -->
<img src="/hero.avif" fetchpriority="high" alt="Hero"
width="1200" height="630" decoding="async">
<!-- Below-fold images: lazy load -->
<img src="/feature.avif" loading="lazy" alt="Feature screenshot"
width="600" height="400" decoding="async">
Always include width and height attributes on images. Without them, the browser doesn't know the image's aspect ratio until it downloads, causing Cumulative Layout Shift (CLS). With them, the browser reserves the correct space immediately.
decoding="async" tells the browser it can decode the image off the main thread, preventing frame drops during image decode on slower devices.
Putting It All Together — A Performance-Optimized Head
Here's what a well-optimized document head looks like, combining everything we've covered:
<head>
<!-- Early Hints would preload these even before this HTML arrives -->
<!-- 1. Critical connections first -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://cdn.yourapp.com" crossorigin>
<!-- 2. Preload critical resources discovered late -->
<link rel="preload" href="/fonts/inter-var.woff2"
as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/hero.avif"
as="image" type="image/avif">
<!-- 3. Critical CSS (inline or preloaded) -->
<link rel="stylesheet" href="/critical.css">
<!-- 4. Non-critical CSS loaded without render-blocking -->
<link rel="stylesheet" href="/below-fold.css" media="print"
onload="this.media='all'">
<!-- 5. App scripts deferred -->
<script src="/app.js" defer></script>
<!-- 6. Analytics async (independent, no DOM dependency) -->
<script src="/analytics.js" async></script>
<!-- 7. Prefetch likely next-page resources -->
<link rel="prefetch" href="/dashboard.js">
<!-- 8. DNS prefetch for lower-priority origins -->
<link rel="dns-prefetch" href="https://analytics.example.com">
</head>
The ordering matters. Browsers process the head top-to-bottom, so critical connections and preloads go first.
- 1CSS is render-blocking: minimize critical CSS, defer non-critical CSS with media queries
- 2JavaScript is parser-blocking: use defer for app scripts, async for independent scripts, never synchronous
- 3Preconnect to critical third-party origins (limit 4-6), always include crossorigin for CORS resources
- 4Preload only resources that are critical AND discovered late — over-preloading hurts performance
- 5Use Brotli for static assets, gzip for dynamic content — check Content-Encoding headers
- 6font-display: optional eliminates all font-related layout shift with zero user-perceived downside
- 7Serve AVIF with WebP and JPEG fallbacks — the savings are 50%+ over JPEG alone
- 8Always set width and height on images to prevent CLS, use loading=lazy for below-fold images
- 9fetchpriority=high on the LCP image, fetchpriority=low on below-fold resources
- 10HTTP/2 makes domain sharding, mega-bundles, and image sprites obsolete — stop doing them
| What developers do | What they should do |
|---|---|
| Preconnecting to 15+ origins to speed up all third-party resources Each preconnect consumes CPU and memory for the full TCP+TLS handshake. Too many connections steal resources from actual content downloads, making everything slower. | Preconnect to 4-6 critical origins, use dns-prefetch for the rest |
| Using preload for everything to make it all load faster Preloaded resources compete for bandwidth with naturally discovered resources. Over-preloading pushes back CSS and HTML downloads. If you preload something and never use it, the browser logs a warning — you wasted bandwidth. | Preload only critical resources that the browser discovers late (fonts in CSS, hero images in CSS backgrounds) |
| Using font-display: swap and assuming layout shift is solved swap guarantees a visible layout shift when the font loads — the fallback and custom font have different metrics. optional prevents the shift entirely by keeping the fallback if the font is slow. | Use font-display: optional for zero layout shift, or use size-adjust in the fallback font-face to match metrics |
| Omitting crossorigin on preconnect for font and API origins Without crossorigin, the browser opens a non-CORS connection. When the actual CORS request happens, it opens a second connection, wasting the preconnect entirely. This is the most common resource hint bug. | Always add crossorigin when the eventual request uses CORS (fonts, fetch API) |
| Serving all images as JPEG or PNG without modern format alternatives AVIF saves 50% over JPEG, WebP saves 30%. On a page with 1MB of images, that is 500KB savings with AVIF — a massive improvement on mobile networks. | Use the picture element with AVIF source, WebP source, and JPEG/PNG fallback |
| Omitting width and height attributes on images to keep the HTML clean Without dimensions, the browser reserves zero space for the image. When it loads, surrounding content jumps — causing CLS. The width/height attributes let the browser reserve the correct space immediately. | Always include width and height so the browser can calculate aspect ratio before download |