Loading Performance
Loading performance determines how quickly users see and can use your page. The critical rendering path, resource loading strategies, and asset optimization all contribute to a fast initial load.
The Critical Rendering Path
The browser follows a specific sequence to turn HTML, CSS, and JavaScript into pixels on screen.
- Parse HTML into the DOM tree
- Parse CSS into the CSSOM tree
- Combine DOM + CSSOM into the Render Tree
- Layout: calculate positions and sizes
- Paint: fill in pixels
- Composite: layer and display
Render-Blocking Resources
CSS and synchronous JavaScript block rendering. The browser will not paint anything until it has processed all render-blocking resources.
<!-- Render-blocking: browser waits for this before painting -->
<link rel="stylesheet" href="/styles.css" />
<!-- Render-blocking: browser stops parsing to execute -->
<script src="/app.js"></script>
<!-- Non-blocking: deferred until HTML parsing is complete -->
<script defer src="/app.js"></script>
<!-- Non-blocking: runs as soon as downloaded, order not guaranteed -->
<script async src="/analytics.js"></script>
Minimizing Render-Blocking Resources
<!-- Inline critical CSS for above-the-fold content -->
<style>
body { margin: 0; font-family: system-ui; }
.hero { height: 100vh; display: flex; align-items: center; }
</style>
<!-- Load the full stylesheet asynchronously -->
<link rel="preload" href="/styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'" />
<noscript><link rel="stylesheet" href="/styles.css" /></noscript>
Lazy Loading
Load images and iframes only when they are about to enter the viewport.
Native Lazy Loading
<!-- Browser handles lazy loading natively -->
<img src="photo.jpg" loading="lazy" alt="Product photo" width="400" height="300" />
<iframe src="https://www.youtube.com/embed/abc" loading="lazy" title="Video"></iframe>
When Not to Lazy Load
- The hero image (LCP element): load it immediately with
fetchpriority="high" - Images above the fold: they are already visible, lazy loading delays them
- Critical iframes the user sees immediately
<!-- Above the fold: load immediately -->
<img src="hero.jpg" alt="Hero" fetchpriority="high" />
<!-- Below the fold: lazy load -->
<img src="product.jpg" loading="lazy" alt="Product" width="300" height="200" />
Code Splitting
Load only the JavaScript the current page needs. Split the rest into chunks loaded on demand.
Dynamic Imports
// Load a module only when needed
button.addEventListener('click', async () => {
const { openEditor } = await import('./editor.js');
openEditor();
});
Route-Based Splitting
Modern frameworks split code by route automatically.
// React example with lazy loading
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
// Only Dashboard.js loads on /dashboard
// Only Settings.js loads on /settings
The Impact
A typical SPA might ship 500 KB of JavaScript. With code splitting, the initial load might be 80 KB, with the rest loaded as the user navigates.
Tree Shaking
Tree shaking removes unused code from your bundles. It works with ES modules (import/export).
// math.js exports 10 functions
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
// ... 7 more functions
// app.js only uses add
import { add } from './math.js';
console.log(add(2, 3));
// After tree shaking: only add() is in the bundle
What Breaks Tree Shaking
// Side effects prevent tree shaking
import './polyfill.js'; // Runs code on import, cannot be removed
// CommonJS modules are not tree-shakeable
const utils = require('./utils'); // Bundler cannot analyze statically
Marking Side-Effect-Free
{
"name": "my-library",
"sideEffects": false
}
Preloading Strategies
Resource hints tell the browser what to load and when.
preload
Load this resource now, it is needed for the current page.
<!-- Preload the LCP image -->
<link rel="preload" as="image" href="/hero.webp" />
<!-- Preload a critical font -->
<link rel="preload" as="font" href="/fonts/inter.woff2" type="font/woff2" crossorigin />
<!-- Preload critical JS module -->
<link rel="modulepreload" href="/app.js" />
prefetch
Load this resource at low priority, it will be needed for the next page.
<!-- Prefetch the next likely page -->
<link rel="prefetch" href="/dashboard.js" />
<link rel="prefetch" href="/dashboard.css" />
preconnect
Establish an early connection to a third-party origin.
<!-- Preconnect to CDN and API server -->
<link rel="preconnect" href="https://cdn.example.com" />
<link rel="preconnect" href="https://api.example.com" />
<!-- dns-prefetch as a fallback for older browsers -->
<link rel="dns-prefetch" href="https://cdn.example.com" />
When to Use Each
| Hint | Use When |
|---|---|
| preload | Resource is critical for the current page but discovered late |
| prefetch | Resource is likely needed on the next navigation |
| preconnect | You will fetch from a third-party origin soon |
| dns-prefetch | You might fetch from a third-party origin |
Font Loading Strategies
Web fonts are a common source of both LCP delays and CLS issues.
font-display
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap; /* Show fallback immediately, swap when loaded */
/* font-display: optional; -- No swap at all if font is slow */
}
| Value | Behavior |
|---|---|
| swap | Show fallback text immediately, swap when font loads (can cause CLS) |
| optional | Use font only if it loads very quickly, otherwise skip it (no CLS) |
| fallback | Short invisible period, then fallback, then swap if fast enough |
| block | Hide text until font loads (bad for performance) |
Preloading Fonts
<link rel="preload" as="font" href="/fonts/inter.woff2" type="font/woff2" crossorigin />
The crossorigin attribute is required even for same-origin fonts. Without it, the browser fetches the font twice.
Reducing Font Size
- Use
woff2format (best compression) - Subset fonts to include only characters you need
- Use variable fonts instead of multiple weight files
Image Optimization
Images are typically the heaviest assets on a page.
Modern Formats
<picture>
<source srcset="photo.avif" type="image/avif" />
<source srcset="photo.webp" type="image/webp" />
<img src="photo.jpg" alt="Fallback for older browsers" width="800" height="600" />
</picture>
| Format | Compression | Browser Support |
|---|---|---|
| AVIF | Best (50% smaller than JPEG) | Chrome, Firefox, Safari 16+ |
| WebP | Great (25-35% smaller than JPEG) | All modern browsers |
| JPEG | Baseline | Universal |
| PNG | Lossless | Universal (use for transparency) |
Responsive Images
<img
src="photo-800.webp"
srcset="photo-400.webp 400w, photo-800.webp 800w, photo-1200.webp 1200w"
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 800px"
alt="Responsive photo"
width="800"
height="600"
loading="lazy"
/>
Always Specify Dimensions
<!-- Prevents CLS: browser reserves space before image loads -->
<img src="photo.jpg" width="800" height="600" alt="Photo" />
/* Modern approach: use aspect-ratio with fluid width */
img {
max-width: 100%;
height: auto;
}
Common Pitfalls
- Loading everything upfront: the initial page load should include only what the user needs immediately. Defer the rest.
- Forgetting the crossorigin attribute on font preloads: the browser fetches the font twice, once from the preload (without CORS) and once from the CSS (with CORS).
- Preloading too many resources: preload competes for bandwidth. Preload only 2-3 critical resources.
- Not using modern image formats: AVIF and WebP can cut image sizes by 50% or more. Use
<picture>for progressive enhancement. - Serving desktop images to mobile: responsive images with
srcsetandsizesprevent this waste. - Inlining too much CSS: inline only above-the-fold critical CSS. Inlining the entire stylesheet defeats caching.
Key Takeaways
- The critical rendering path determines when the browser can first paint; minimize render-blocking CSS and JS
- Use
loading="lazy"for below-the-fold images but never for the LCP element - Code splitting and tree shaking reduce JavaScript bundle sizes dramatically
- preload is for the current page, prefetch is for the next page, preconnect is for third-party origins
- Use
font-display: swaporoptionaland preload critical fonts with the crossorigin attribute - Serve images in AVIF/WebP with responsive srcset and always specify dimensions to prevent CLS