How Browsers Load Resources
Understanding the browser's resource loading pipeline is essential before optimizing it. When a browser receives an HTML document, it parses top to bottom, and each resource it encounters can block or defer rendering.
Browser Loading Pipeline:
+--------------------------------------------------------------------+
| 1. DNS Lookup | Resolve domain to IP |
| 2. TCP Connect | Establish connection |
| 3. TLS Handshake | Secure the connection (HTTPS) |
| 4. HTML Download | Receive the HTML document |
| 5. HTML Parse | Build DOM, discover resources |
| 6. CSS Download | Fetch stylesheets (RENDER BLOCKING) |
| 7. CSS Parse | Build CSSOM |
| 8. JS Download | Fetch scripts (PARSER BLOCKING by default) |
| 9. JS Execute | Run scripts |
| 10. Render Tree | Combine DOM + CSSOM |
| 11. Layout | Calculate positions and sizes |
| 12. Paint | Draw pixels to screen |
+--------------------------------------------------------------------+
Render-Blocking vs Parser-Blocking
Render-blocking (CSS):
Browser cannot paint until ALL CSS is downloaded and parsed.
The CSSOM must be complete before the first render.
Parser-blocking (JS without defer/async):
Browser stops parsing HTML when it hits a <script> tag.
Must download and execute the script before continuing.
<html>
<head>
<link rel="stylesheet" href="styles.css" /> <!-- Render blocking -->
<script src="app.js"></script> <!-- Parser blocking -->
</head>
<body>
<!-- Nothing below renders until both above are done -->
<h1>Hello</h1>
</body>
</html>
Script Loading: defer, async, and module
The three attributes that control how JavaScript loads and executes.
Comparison
Default (no attribute):
HTML: |===PARSE===|..BLOCKED..|===PARSE===|
JS: |==DL==|=EX=|
^ ^
Parse stops Parse resumes
async:
HTML: |========PARSE==========|
JS: |==DL==|=EX=|
^ ^
Parse pauses briefly for execution
defer:
HTML: |==========PARSE==========|
JS: |===DL===| |=EX=|
^
Executes after HTML is fully parsed
module (type="module"):
HTML: |==========PARSE==========|
JS: |===DL===| |=EX=|
Same as defer, plus: strict mode, own scope, supports import/export
When to Use Each
<!-- DEFAULT: Parser-blocking — avoid this -->
<script src="app.js"></script>
<!-- DEFER: For scripts that need the DOM and must run in order -->
<script src="app.js" defer></script>
<script src="init.js" defer></script>
<!-- app.js runs first, then init.js, both after DOM is ready -->
<!-- ASYNC: For independent scripts (analytics, ads) -->
<script src="analytics.js" async></script>
<script src="tracking.js" async></script>
<!-- Execute order is NOT guaranteed — whichever downloads first runs first -->
<!-- MODULE: Modern scripts with imports -->
<script type="module" src="app.mjs"></script>
<!-- Automatically deferred, strict mode, supports import/export -->
+----------+---------+---------+--------+-----------+
| Attribute| Blocking| Order | DOM | Use Case |
| | | Guaranteed| Ready | |
+----------+---------+---------+--------+-----------+
| (none) | Yes | Yes | No | Avoid |
| defer | No | Yes | Yes | App code |
| async | Brief | No | No | Analytics |
| module | No | Yes | Yes | ESM code |
+----------+---------+---------+--------+-----------+
Practical Example
<head>
<!-- Critical CSS inline -->
<style>/* Critical styles here */</style>
<!-- Preload important scripts -->
<link rel="preload" href="/js/app.js" as="script" />
<!-- Third-party analytics — async (order doesn't matter) -->
<script src="https://www.googletagmanager.com/gtag/js?id=G-XXX" async></script>
<!-- App scripts — defer (need DOM, need order) -->
<script src="/js/vendor.js" defer></script>
<script src="/js/app.js" defer></script>
</head>
Critical CSS Extraction
Critical CSS is the minimum CSS needed to render above-the-fold content. By inlining it and deferring the rest, you eliminate the render-blocking nature of external stylesheets.
The Problem
Default CSS loading:
Browser requests HTML
-> Discovers <link rel="stylesheet" href="styles.css">
-> Requests styles.css (50KB, 200ms)
-> Waits for full download
-> Parses CSS
-> NOW it can render
Time to first paint: TTFB + 200ms CSS download + parse time
With critical CSS:
Browser requests HTML (includes inline critical CSS)
-> Renders immediately with inlined styles
-> Loads full CSS in background
Time to first paint: TTFB only
Manual Critical CSS
<head>
<!-- Inline critical CSS for above-the-fold content -->
<style>
/* Only styles needed for initial viewport */
*,*::before,*::after{box-sizing:border-box}
body{margin:0;font-family:system-ui,sans-serif;line-height:1.5}
.header{height:64px;background:#1a1a2e;display:flex;align-items:center;padding:0 1rem}
.header__logo{color:#fff;font-size:1.25rem;font-weight:700}
.hero{padding:3rem 1rem;text-align:center}
.hero__title{font-size:2.5rem;margin:0 0 1rem}
</style>
<!-- Load full CSS without blocking render -->
<link
rel="preload"
href="/styles/main.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
<!-- Fallback for browsers without JS -->
<noscript>
<link rel="stylesheet" href="/styles/main.css" />
</noscript>
</head>
Automated Critical CSS with critters
// next.config.js — critters is built into Next.js
module.exports = {
experimental: {
optimizeCss: true, // Enables automatic critical CSS extraction
},
};
// For custom webpack setups
// webpack.config.js
const CrittersPlugin = require('critters-webpack-plugin');
module.exports = {
plugins: [
new CrittersPlugin({
// Inline critical CSS and lazy-load the rest
preload: 'swap', // Use font-display: swap for fonts
inlineFonts: false, // Don't inline font files
pruneSource: false, // Keep original CSS file
}),
],
};
Using the critical npm Package
// scripts/extract-critical.js
const critical = require('critical');
async function extractCritical() {
const { html } = await critical.generate({
base: './dist',
src: 'index.html',
target: 'index-critical.html',
inline: true,
width: 1300,
height: 900,
// Extract for multiple viewports
dimensions: [
{ width: 375, height: 667 }, // Mobile
{ width: 768, height: 1024 }, // Tablet
{ width: 1440, height: 900 }, // Desktop
],
});
console.log('Critical CSS extracted and inlined.');
}
extractCritical();
Preload, Prefetch, and Preconnect
These resource hints tell the browser about resources it will need, allowing it to start fetching them earlier.
preconnect — Warm Up Connections
<!--
preconnect: Establish connection (DNS + TCP + TLS) early.
Use for third-party origins you KNOW you'll need.
Each preconnect costs ~1KB, so don't overdo it (3-5 max).
-->
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- CDN for images -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<!-- API server -->
<link rel="preconnect" href="https://api.example.com" crossorigin />
<!--
dns-prefetch: DNS lookup only (cheaper than preconnect).
Use as fallback or for less critical origins.
-->
<link rel="dns-prefetch" href="https://analytics.example.com" />
preload — Fetch Critical Resources Now
<!--
preload: "I need this resource on THIS page, fetch it NOW."
High priority. Must specify 'as' attribute.
Must be used on the current page — wasted otherwise.
-->
<!-- Preload the LCP image -->
<link rel="preload" href="/hero.webp" as="image" type="image/webp" />
<!-- Preload a critical font -->
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<!-- Preload a critical script -->
<link rel="preload" href="/js/critical-module.js" as="script" />
<!-- Preload with media query (responsive) -->
<link
rel="preload"
href="/hero-mobile.webp"
as="image"
media="(max-width: 768px)"
/>
<link
rel="preload"
href="/hero-desktop.webp"
as="image"
media="(min-width: 769px)"
/>
prefetch — Fetch Resources for Future Navigation
<!--
prefetch: "The user MIGHT need this resource on a FUTURE page."
Low priority. Downloads during idle time.
Great for predictable user flows.
-->
<!-- User is on login page, likely to visit dashboard next -->
<link rel="prefetch" href="/dashboard" />
<link rel="prefetch" href="/js/dashboard-chunk.js" />
<!-- User is reading article 1, likely to read article 2 -->
<link rel="prefetch" href="/articles/2" />
Comparison Table
+--------------+----------+----------+--------------------+------------------+
| Hint | Priority | When | Use Case | Cost |
+--------------+----------+----------+--------------------+------------------+
| preconnect | High | Now | Known 3rd-party | ~1KB per origin |
| dns-prefetch | Low | Now | Less critical 3p | Minimal |
| preload | High | Now | Critical resources | Must be used |
| prefetch | Low | Idle | Future navigations | Wasted bandwidth |
| | | | | if not visited |
+--------------+----------+----------+--------------------+------------------+
Programmatic Resource Hints
// Add resource hints dynamically based on user behavior
function prefetchOnHover(url) {
let link = null;
return {
onMouseEnter() {
link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
document.head.appendChild(link);
},
onMouseLeave() {
if (link) {
document.head.removeChild(link);
link = null;
}
},
};
}
// Usage in React
function NavLink({ href, children }) {
const handlers = useMemo(() => prefetchOnHover(href), [href]);
return (
<a href={href} {...handlers}>
{children}
</a>
);
}
Font Loading Strategies
Web fonts are a common source of render delays and layout shifts. The browser must download fonts before it can render text using them.
The Font Loading Problem
Without optimization:
HTML loads -> CSS parsed -> Font URL discovered -> Font downloads (200ms+)
Text invisible (FOIT)
or text shifts (FOUT)
FOIT (Flash of Invisible Text):
|=====BLANK=====|===Styled text===|
^
Font loaded
FOUT (Flash of Unstyled Text):
|===Fallback font===|===Styled text===|
^
Font loaded, text reflows (CLS!)
font-display Values
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
/* font-display controls the behavior */
font-display: swap;
}
+----------+------------------+-------------------+------------------+
| Value | Block Period | Swap Period | Best For |
+----------+------------------+-------------------+------------------+
| auto | Browser decides | Browser decides | (avoid) |
| block | 3s invisible | Infinite | Icon fonts |
| swap | Minimal (~100ms) | Infinite | Body text |
| fallback | 100ms invisible | 3s | Balanced |
| optional | 100ms invisible | None | Non-essential |
+----------+------------------+-------------------+------------------+
Recommended:
- Body text: font-display: swap (ensures text is always visible)
- Headings: font-display: fallback (short grace period)
- Non-critical: font-display: optional (may never swap)
Preloading Fonts
<head>
<!-- Preload critical fonts — they start downloading immediately -->
<link
rel="preload"
href="/fonts/inter-var-latin.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<style>
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var-latin.woff2') format('woff2');
font-display: swap;
font-weight: 100 900;
/* Subset to only latin characters to reduce size */
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC;
}
</style>
</head>
Self-Hosting vs Google Fonts
+-------------------+---------------------------+---------------------------+
| Aspect | Self-hosted | Google Fonts CDN |
+-------------------+---------------------------+---------------------------+
| Extra connection | No (same origin) | Yes (fonts.googleapis.com |
| | | + fonts.gstatic.com) |
| Cache | Your cache headers | Shared cache (removed in |
| | | Chrome 86+) |
| Privacy | No third-party request | Google sees your users |
| Control | Full (subset, format) | Limited |
| Setup | Manual | Copy-paste |
| Recommendation | Better performance | Easier setup |
+-------------------+---------------------------+---------------------------+
# Self-host Google Fonts using google-webfonts-helper
# Or use fontsource (npm packages)
npm install @fontsource-variable/inter
// Import in your app entry point
import '@fontsource-variable/inter';
// Now use in CSS
// font-family: 'Inter Variable', sans-serif;
Reducing Font CLS with size-adjust
/* Match fallback font metrics to your custom font to eliminate CLS */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap;
}
/* Adjusted fallback that closely matches Inter's metrics */
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107.64%;
ascent-override: 90.49%;
descent-override: 22.48%;
line-gap-override: 0%;
}
body {
/* Browser uses Inter Fallback first, swaps to Inter when loaded */
/* Because metrics match, there's no visible shift */
font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
}
// Next.js handles this automatically with next/font
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
// Automatically generates size-adjusted fallback
});
export default function Layout({ children }) {
return (
<html className={inter.className}>
<body>{children}</body>
</html>
);
}
Network Waterfall Optimization
The network waterfall shows the timeline of all resource requests. Optimizing it means reducing the time between navigation and the last critical resource loading.
Reading a Waterfall
Unoptimized waterfall:
Time: 0ms 200ms 400ms 600ms 800ms 1000ms 1200ms
HTML: |======|
CSS: |============|
Font: |==========|
JS: |====================|
Img: |=============|
^ First meaningful paint
Optimized waterfall:
Time: 0ms 200ms 400ms 600ms 800ms
HTML: |======|
CSS: |=========| (preloaded, critical inlined)
Font: |=======| (preloaded)
JS: |===========|(deferred)
Img: |==========| (preloaded, LCP priority)
^ First meaningful paint (400ms earlier)
Waterfall Optimization Techniques
<head>
<!-- 1. Preconnect to critical origins FIRST -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- 2. Inline critical CSS (no network request) -->
<style>
body { margin: 0; font-family: system-ui; }
.hero { min-height: 400px; }
</style>
<!-- 3. Preload critical resources in parallel -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/hero.webp" as="image" type="image/webp" />
<link rel="preload" href="/js/app.js" as="script" />
<!-- 4. Defer non-critical CSS -->
<link
rel="preload"
href="/styles/main.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
<!-- 5. Defer all JS -->
<script src="/js/app.js" defer></script>
<!-- 6. Async for third-party (lowest priority) -->
<script src="https://analytics.example.com/track.js" async></script>
</head>
103 Early Hints
// Server sends hints before the full response is ready
// The browser starts downloading resources while waiting for HTML
// Node.js / Express
app.get('/', (req, res) => {
// Send 103 Early Hints
res.writeEarlyHints({
link: [
'</styles/main.css>; rel=preload; as=style',
'</fonts/inter.woff2>; rel=preload; as=font; crossorigin',
'</hero.webp>; rel=preload; as=image',
],
});
// Then generate and send the full response
const html = renderPage();
res.status(200).send(html);
});
// Next.js — configure in next.config.js
module.exports = {
async headers() {
return [
{
source: '/',
headers: [
{
key: 'Link',
value: '</fonts/inter.woff2>; rel=preload; as=font; crossorigin',
},
],
},
];
},
};
Putting It All Together
Optimized HTML Head Template
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- 1. Preconnect (first — establish connections early) -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<link rel="dns-prefetch" href="https://analytics.example.com" />
<!-- 2. Preload (second — start fetching critical resources) -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/hero.webp" as="image" type="image/webp" />
<!-- 3. Critical CSS (inline — no network request) -->
<style>
:root{--color-bg:#fff;--color-text:#1a1a1a}
*{box-sizing:border-box;margin:0}
body{font-family:'Inter',system-ui,sans-serif;color:var(--color-text);background:var(--color-bg)}
.header{height:64px;display:flex;align-items:center;padding:0 1rem}
.hero{padding:3rem 1rem;min-height:400px}
</style>
<!-- 4. Non-critical CSS (deferred) -->
<link rel="preload" href="/styles/main.css" as="style"
onload="this.onload=null;this.rel='stylesheet'" />
<noscript><link rel="stylesheet" href="/styles/main.css" /></noscript>
<!-- 5. Font face declarations -->
<style>
@font-face{
font-family:'Inter';
src:url('/fonts/inter.woff2') format('woff2');
font-display:swap;
font-weight:100 900
}
</style>
<!-- 6. App scripts (deferred) -->
<script src="/js/vendor.js" defer></script>
<script src="/js/app.js" defer></script>
<!-- 7. Third-party (async, lowest priority) -->
<script src="https://www.googletagmanager.com/gtag/js?id=G-XXX" async></script>
</head>
Resource Loading Priority Table
+-----------------------+----------+-------------------------------------+
| Resource | Priority | Strategy |
+-----------------------+----------+-------------------------------------+
| Critical CSS | Highest | Inline in <head> |
| LCP image | Highest | preload + fetchpriority="high" |
| Primary font | High | preload + font-display: swap |
| App JS | High | defer (or preload + defer) |
| Non-critical CSS | Medium | preload as="style" with onload swap |
| Below-fold images | Low | loading="lazy" |
| Analytics | Low | async |
| Chat widget | Lowest | Load on interaction (facade) |
| Prefetch next page | Lowest | prefetch during idle |
+-----------------------+----------+-------------------------------------+
Key Takeaways
- CSS is render-blocking by default — inline critical CSS and defer the rest
- JavaScript without
deferorasyncis parser-blocking — always use one of them deferpreserves execution order and waits for DOM;asyncexecutes whenever downloadedpreconnectwarms connections to known third-party origins (use 3-5 max)preloadfetches critical same-page resources earlier in the waterfallprefetchspeculatively fetches resources for future navigationsfont-display: swapprevents invisible text;size-adjustprevents layout shift- Self-hosting fonts eliminates third-party connections and gives full control
- next/font automates font optimization with automatic size-adjusted fallbacks
- Order matters in
<head>— preconnect first, preload second, inline CSS third, defer scripts last - 103 Early Hints let the browser start downloading before the HTML response arrives