Loading performance and resource optimization
In this series (12 parts)
- What frontend system design covers
- Rendering strategies: CSR, SSR, SSG, ISR
- Performance fundamentals: Core Web Vitals
- Loading performance and resource optimization
- State management at scale
- Component architecture and design systems
- Client-side caching and offline support
- Real-time on the frontend
- Frontend security
- Scalability for frontend systems
- Accessibility as a system design concern
- Monitoring and observability for frontends
A browser cannot paint pixels until it has built two trees: the DOM from HTML and the CSSOM from CSS. JavaScript can modify both. The sequence in which these resources load, parse, and execute is the critical rendering path, and it is the single biggest lever you have for loading performance. If you measured your Core Web Vitals and found problems, the fixes are almost always here.
The critical rendering path
When a browser receives an HTML document, it processes resources in a specific order:
graph TD
A[HTML Download] --> B[Parse HTML / Build DOM]
B --> C{CSS encountered?}
C -->|Yes| D[Download CSS]
D --> E[Parse CSS / Build CSSOM]
B --> F{JS encountered?}
F -->|Yes, blocking| G[Download JS]
G --> H[Execute JS]
H --> B
F -->|Yes, async/defer| I[Download JS in parallel]
E --> J[Render Tree]
B --> J
J --> K[Layout]
K --> L[Paint]
L --> M[Composite]
The critical rendering path. CSS blocks rendering; synchronous JavaScript blocks HTML parsing. Both must be managed carefully.
Three key principles govern this path:
- CSS is render-blocking. The browser will not paint anything until all CSS in the
<head>is downloaded and parsed. Every kilobyte of CSS delays the first paint. - Synchronous JS is parser-blocking. A
<script>tag withoutasyncordeferstops HTML parsing until the script downloads and executes. - The DOM and CSSOM must both be ready before the browser can build the render tree and paint.
Your goal is to minimize the amount of work on this critical path. Everything that can happen later, should happen later.
Code splitting
A single-page application built with React or Vue can easily produce a 2MB JavaScript bundle. Most users will only interact with a fraction of the code on any given page. Code splitting breaks the bundle into smaller chunks that load on demand.
Route-based splitting
The most common and highest-impact approach: each route gets its own chunk. When the user navigates to /settings, only the settings code downloads.
const Settings = React.lazy(() => import('./pages/Settings'));
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Profile = React.lazy(() => import('./pages/Profile'));
The bundler (webpack, Vite, esbuild) creates separate chunks for each dynamic import. The router loads the appropriate chunk when the route activates.
Component-based splitting
Below the route level, you can split individual components. A rich text editor, a chart library, or a PDF viewer; these are heavy components that many users never encounter.
const RichEditor = React.lazy(() => import('./components/RichEditor'));
function ArticlePage({ isEditing }) {
return (
<div>
<ArticleContent />
{isEditing && (
<Suspense fallback={<EditorSkeleton />}>
<RichEditor />
</Suspense>
)}
</div>
);
}
The cost of splitting too much
Every chunk requires a separate HTTP request. On HTTP/2, this is less of a concern, but each chunk still has overhead: connection setup, parsing, and execution. If you split too aggressively, the cost of managing many tiny chunks exceeds the savings from not loading them.
A practical heuristic: split at route boundaries and for components larger than 30KB (compressed). Below that threshold, the overhead of a separate request usually is not worth it.
Lazy loading
Lazy loading defers the loading of resources until they are needed. For images and iframes below the fold, this means they load only when the user scrolls near them.
Native lazy loading
<img src="photo.webp" loading="lazy" width="800" height="600" alt="Product photo" />
<iframe src="https://maps.example.com" loading="lazy"></iframe>
The loading="lazy" attribute tells the browser to defer loading until the element is near the viewport. This is supported in all modern browsers and requires zero JavaScript.
Do not lazy-load above-the-fold images. The LCP image must load eagerly and ideally be preloaded.
Intersection Observer for custom lazy loading
For more control, use the Intersection Observer API:
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
}, { rootMargin: '200px' });
document.querySelectorAll('img[data-src]').forEach((img) => {
observer.observe(img);
});
The rootMargin of 200px starts loading images 200px before they enter the viewport, preventing visible loading delays during scrolling.
Resource hints: preload, prefetch, preconnect
Resource hints tell the browser about resources it will need, allowing it to start fetching them before it would naturally discover them.
Preload
<link rel="preload"> fetches a resource with high priority for the current page. Use it for critical resources that the browser discovers late, like fonts referenced in CSS or hero images.
<link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/images/hero.webp" as="image" />
Preload is powerful but dangerous. Every preloaded resource competes for bandwidth with other critical resources. Preload only what you genuinely need for the initial render.
Prefetch
<link rel="prefetch"> fetches a resource with low priority for future navigations. Use it for resources the user is likely to need next.
<link rel="prefetch" href="/next-page.js" />
Prefetch is ideal for preloading the JavaScript chunk for the most likely next page. If the user is on the product list, prefetch the product detail page chunk.
Preconnect
<link rel="preconnect"> establishes early connections to third-party origins. DNS lookup, TCP handshake, and TLS negotiation happen before the browser needs the connection.
<link rel="preconnect" href="https://api.example.com" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
Each connection costs time. Preconnecting to your API server and font provider can save 100 to 300ms on the first request to each origin.
graph LR
subgraph preconnect
A1[DNS Lookup] --> A2[TCP Handshake] --> A3[TLS Negotiation]
end
subgraph preload
B1[Fetch resource with high priority]
B2[Current page, critical path]
end
subgraph prefetch
C1[Fetch resource with low priority]
C2[Future navigation, idle time]
end
The three resource hints serve different purposes. Preconnect warms connections. Preload fetches critical resources early. Prefetch loads future resources during idle time.
Image optimization
Images account for the majority of bytes on most web pages. Optimizing them is often the single highest-impact performance improvement you can make.
Modern formats
| Format | Use case | Size vs JPEG |
|---|---|---|
| WebP | General purpose | 25-35% smaller |
| AVIF | Photos, complex images | 40-50% smaller |
| SVG | Icons, logos, illustrations | Resolution independent |
Use the <picture> element for format fallbacks:
<picture>
<source srcset="photo.avif" type="image/avif" />
<source srcset="photo.webp" type="image/webp" />
<img src="photo.jpg" alt="Product photo" width="800" height="600" />
</picture>
Responsive images with srcset
Serve different image sizes based on the user’s viewport and device pixel ratio:
<img
srcset="photo-400.webp 400w, photo-800.webp 800w, photo-1200.webp 1200w"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 800px"
src="photo-800.webp"
alt="Product photo"
width="800"
height="600"
/>
This prevents a mobile user from downloading a 2400px-wide image when they only need 400px. The bandwidth savings are enormous.
Font loading strategies
Custom fonts can cause both performance and layout shift issues. The browser has several behaviors when a font is not yet loaded:
- Block period. The browser renders invisible text (FOIT, Flash of Invisible Text).
- Swap period. The browser renders text with a fallback font, then swaps to the custom font (FOUT, Flash of Unstyled Text).
The font-display property
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter.woff2') format('woff2');
font-display: swap;
}
| Value | Behavior | Best for |
|---|---|---|
swap | Short block, long swap | Body text where readability matters immediately |
optional | Short block, no swap if slow | Headings where layout stability matters more |
fallback | Short block, short swap | Balance between FOIT and FOUT |
Size-adjusted fallback fonts
To eliminate CLS from font swaps, create a fallback font with matching metrics:
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
size-adjust: 107%;
}
body {
font-family: 'Inter', 'Inter Fallback', sans-serif;
}
When the custom font loads, the text does not shift because the fallback has nearly identical metrics.
Putting it all together: optimized vs unoptimized
The optimized version applies everything discussed: critical CSS inlining, code splitting, image optimization with modern formats, font subsetting, and lazy loading for below-fold content. The result is an 88% reduction in initial load bytes. Combined with a CDN and proper caching headers, this translates to sub-second LCP on most connections.
What comes next
Fast loading gets users into the application. Once they are in, the challenge shifts to managing the data and state that drives the UI. State management at scale covers the patterns and tools for keeping complex frontend applications predictable and maintainable.