Code Splitting & Loading Patterns
Modern JavaScript applications can easily balloon to megabytes of code—most of which users don't need immediately. Code splitting breaks your bundle into smaller chunks loaded on demand. Combined with lazy loading and resource hints, you can dramatically reduce initial load time while keeping the full application available.
Code Splitting Fundamentals
Code splitting works by identifying natural boundaries in your code—routes, features, or heavy dependencies—and creating separate bundles for each. The browser downloads only what's needed for the current page.
Route-Based Splitting
The most impactful split: each route gets its own bundle. Most frameworks handle this automatically.
Astro (automatic per-page splitting):
---
// src/pages/dashboard.astro
// This page's JavaScript is only loaded on /dashboard
---
<Layout>
<DashboardComponent client:load />
</Layout>
Next.js (automatic with the pages or app router):
// app/dashboard/page.tsx
// Automatically code-split by route
export default function DashboardPage() {
return <Dashboard />;
}
React Router with lazy loading:
// src/App.tsx
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// Each route is a separate chunk
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
Component-Based Splitting
Split at the component level for features that aren't always needed:
// Only load the chart library when the dashboard is visible
const Analytics = lazy(() => import('./components/Analytics'));
function Dashboard({ showAnalytics }) {
return (
<div>
<MainContent />
{showAnalytics && (
<Suspense fallback={<Skeleton height="400px" />}>
<Analytics />
</Suspense>
)}
</div>
);
}
Manual Chunk Configuration
For fine-grained control, configure your bundler to create specific chunks:
Vite/Rollup:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// Vendor libraries in their own cacheable chunk
'vendor-react': ['react', 'react-dom'],
'vendor-charts': ['chart.js', 'd3'],
// Large features separated
'feature-editor': [
'./src/components/Editor.tsx',
'./src/lib/editor-utils.ts'
],
},
},
},
},
});
Webpack:
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
charts: {
test: /[\\/]node_modules[\\/](chart\.js|d3)[\\/]/,
name: 'charts',
chunks: 'all',
priority: 10,
},
},
},
},
};
Dynamic Imports
The import() expression is the foundation of code splitting. It returns a promise that resolves to the module:
// Load module on demand
async function showRichEditor() {
const { RichEditor } = await import('./components/RichEditor');
renderEditor(RichEditor);
}
// Conditional loading based on feature flags
async function loadFeature(name) {
switch (name) {
case 'analytics':
return import('./features/analytics');
case 'admin':
return import('./features/admin');
default:
throw new Error(`Unknown feature: ${name}`);
}
}
Named Exports with Dynamic Imports
// The module
// src/utils/formatters.ts
export function formatDate(d) { /* ... */ }
export function formatCurrency(n) { /* ... */ }
// Dynamic import with destructuring
const { formatDate } = await import('./utils/formatters');
// Or use the namespace
const formatters = await import('./utils/formatters');
formatters.formatDate(new Date());
Magic Comments (Webpack)
Webpack supports magic comments to control chunk behavior:
// Named chunk (appears in build output)
const Admin = lazy(() => import(
/* webpackChunkName: "admin" */
'./pages/Admin'
));
// Prefetch: load during idle time (low priority)
const Settings = lazy(() => import(
/* webpackChunkName: "settings" */
/* webpackPrefetch: true */
'./pages/Settings'
));
// Preload: load immediately (high priority)
const Dashboard = lazy(() => import(
/* webpackChunkName: "dashboard" */
/* webpackPreload: true */
'./pages/Dashboard'
));
Vite doesn't use magic comments but achieves similar results with explicit preloading (covered below).
Lazy Loading
Lazy loading defers resource loading until the resource is actually needed. This applies to images, components, and third-party scripts.
Native Image Lazy Loading
The simplest optimization—just add an attribute:
<!-- Images below the fold load when approaching viewport -->
<img src="/photo.jpg" alt="Photo" loading="lazy" width="800" height="600">
<!-- Hero images should NOT be lazy loaded -->
<img src="/hero.jpg" alt="Hero" fetchpriority="high" width="1200" height="600">
Browser support is excellent (95%+). Images with loading="lazy" start loading when they're within the viewport threshold (typically 1250-2500px, depending on connection speed).
Gotcha: Never lazy load above-the-fold images. This delays your LCP element.
Intersection Observer for Components
Load heavy components only when they scroll into view:
// hooks/useLazyLoad.ts
import { useEffect, useRef, useState } from 'react';
export function useLazyLoad<T extends HTMLElement>(
rootMargin = '200px' // Start loading 200px before visible
) {
const ref = useRef<T>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin }
);
observer.observe(element);
return () => observer.disconnect();
}, [rootMargin]);
return { ref, isVisible };
}
Usage:
function CommentSection() {
const { ref, isVisible } = useLazyLoad<HTMLDivElement>();
return (
<div ref={ref}>
{isVisible ? (
<Comments />
) : (
<Skeleton height="300px" />
)}
</div>
);
}
Lazy Loading Iframes
Iframes (YouTube embeds, maps) are heavy. Use the native loading attribute or a facade pattern:
<!-- Native lazy loading for iframes -->
<iframe
src="https://www.youtube.com/embed/VIDEO_ID"
loading="lazy"
width="560"
height="315"
title="Video"
></iframe>
Facade Pattern: Show a static image until the user interacts:
// components/YouTubeFacade.tsx
import { useState } from 'react';
interface Props {
videoId: string;
title: string;
}
export function YouTubeFacade({ videoId, title }: Props) {
const [clicked, setClicked] = useState(false);
if (clicked) {
return (
<iframe
src={`https://www.youtube.com/embed/${videoId}?autoplay=1`}
width="560"
height="315"
title={title}
allow="autoplay; encrypted-media"
allowFullScreen
/>
);
}
return (
<button
onClick={() => setClicked(true)}
style={{
backgroundImage: `url(https://i.ytimg.com/vi/${videoId}/hqdefault.jpg)`,
width: 560,
height: 315,
border: 'none',
cursor: 'pointer',
position: 'relative',
}}
aria-label={`Play: ${title}`}
>
<PlayIcon />
</button>
);
}
This can save 500KB+ per embed until the user actually wants to play.
Resource Hints
Resource hints tell the browser about resources you'll need, allowing it to prepare in advance.
preload: Critical Resources Now
Use preload for resources needed for the current page but not discovered early in the HTML:
<head>
<!-- Fonts discovered late (in CSS) -->
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin
>
<!-- Hero image (LCP candidate) -->
<link
rel="preload"
href="/images/hero.webp"
as="image"
fetchpriority="high"
>
<!-- Critical CSS if loaded externally -->
<link rel="preload" href="/critical.css" as="style">
</head>
Key attributes:
as: Resource type (script,style,image,font,fetch, etc.)type: MIME type (important for fonts)crossorigin: Required for fonts and when CORS is needed
Gotcha: Don't over-preload. Each preload competes for bandwidth. Preload only truly critical resources (2-3 max).
prefetch: Resources for Next Navigation
Use prefetch for resources likely needed on future pages:
<!-- When user hovers nav link to /dashboard -->
<link rel="prefetch" href="/dashboard.js">
<link rel="prefetch" href="/api/dashboard-data" as="fetch">
Prefetch is low priority and happens during idle time. It won't slow down the current page.
Route prefetching in frameworks:
// Next.js - Automatic prefetching on Link visibility
import Link from 'next/link';
<Link href="/dashboard">Dashboard</Link>
// Disable if needed
<Link href="/dashboard" prefetch={false}>Dashboard</Link>
// React Router - Manual prefetching
import { useNavigate } from 'react-router-dom';
function NavLink({ to, children }) {
const navigate = useNavigate();
const handleMouseEnter = () => {
// Prefetch route on hover
import(`./pages/${to.slice(1)}.tsx`);
};
return (
<a
href={to}
onMouseEnter={handleMouseEnter}
onClick={(e) => {
e.preventDefault();
navigate(to);
}}
>
{children}
</a>
);
}
preconnect: Early Connection Setup
Use preconnect to establish connections to external origins before they're needed:
<head>
<!-- Set up connection to CDN -->
<link rel="preconnect" href="https://cdn.example.com">
<!-- Google Fonts needs two connections -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- API server -->
<link rel="preconnect" href="https://api.example.com">
</head>
Each connection requires DNS lookup, TCP handshake, and TLS negotiation—200-500ms on slow connections. Preconnect eliminates this latency.
Limit to 2-4 origins: Too many preconnects waste resources on connections you might not use.
dns-prefetch: DNS-Only Warmup
A lighter alternative to preconnect—only resolves DNS:
<link rel="dns-prefetch" href="https://analytics.example.com">
Use for origins you'll connect to, but not immediately. Browser support is wider than preconnect.
modulepreload: ES Module Preloading
For ES modules, use modulepreload instead of preload:
<link rel="modulepreload" href="/app.js">
<link rel="modulepreload" href="/utils.js">
This tells the browser to fetch, parse, and compile the module ahead of time.
Priority Hints
The fetchpriority attribute lets you influence resource loading priority:
<!-- High priority: LCP image -->
<img src="/hero.webp" fetchpriority="high" alt="Hero">
<!-- Low priority: below-fold images -->
<img src="/footer-decoration.webp" fetchpriority="low" loading="lazy" alt="">
<!-- High priority: critical script -->
<script src="/app.js" fetchpriority="high"></script>
<!-- Low priority: analytics -->
<script src="/analytics.js" fetchpriority="low" defer></script>
Values: high, low, auto (default)
Use fetchpriority="high" for:
- LCP images
- Critical above-the-fold content
- Fonts if not preloaded
Use fetchpriority="low" for:
- Below-fold images
- Non-critical third-party scripts
- Decorative content
Script Loading Strategies
async vs defer
<!-- Blocking: Halts HTML parsing until downloaded and executed -->
<script src="/app.js"></script>
<!-- Async: Downloads in parallel, executes ASAP (interrupts parsing) -->
<script src="/analytics.js" async></script>
<!-- Defer: Downloads in parallel, executes after HTML parsing, in order -->
<script src="/app.js" defer></script>
When to use each:
| Strategy | Use Case |
|---|---|
| No attribute | Almost never (legacy requirement) |
async |
Independent scripts (analytics, ads) |
defer |
Application code (needs DOM, execution order matters) |
defer is the default recommendation for application scripts:
<head>
<!-- These download in parallel, execute in order after parsing -->
<script src="/vendor.js" defer></script>
<script src="/app.js" defer></script>
</head>
Module Scripts
ES modules are deferred by default:
<!-- Automatically deferred -->
<script type="module" src="/app.js"></script>
<!-- Async still works if you need it -->
<script type="module" src="/analytics.js" async></script>
Third-Party Script Patterns
Third-party scripts (analytics, chat, ads) often hurt performance. Control when they load:
// Load analytics after page is interactive
if (document.readyState === 'complete') {
loadAnalytics();
} else {
window.addEventListener('load', loadAnalytics);
}
function loadAnalytics() {
const script = document.createElement('script');
script.src = 'https://analytics.example.com/script.js';
script.async = true;
document.body.appendChild(script);
}
Or with requestIdleCallback for truly low-priority scripts:
function loadWhenIdle(src) {
const load = () => {
const script = document.createElement('script');
script.src = src;
document.body.appendChild(script);
};
if ('requestIdleCallback' in window) {
requestIdleCallback(load, { timeout: 5000 });
} else {
setTimeout(load, 2000);
}
}
loadWhenIdle('https://chat.example.com/widget.js');
Practical Patterns
Preloading on User Intent
Load resources when users signal intent:
function ProductList({ products }) {
const prefetchProduct = (id: string) => {
// Prefetch product page bundle
import(`./pages/Product`);
// Prefetch product data
fetch(`/api/products/${id}`);
};
return (
<ul>
{products.map(product => (
<li
key={product.id}
onMouseEnter={() => prefetchProduct(product.id)}
onTouchStart={() => prefetchProduct(product.id)}
>
<Link href={`/products/${product.id}`}>
{product.name}
</Link>
</li>
))}
</ul>
);
}
Progressive Component Loading
Load a lightweight version first, then upgrade:
function DataVisualization({ data }) {
const [Chart, setChart] = useState(null);
useEffect(() => {
// Load heavy chart library in background
import('./components/FullChart').then(mod => {
setChart(() => mod.default);
});
}, []);
// Show simple table first
if (!Chart) {
return <SimpleTable data={data} />;
}
// Upgrade to full chart when loaded
return <Chart data={data} />;
}
Critical CSS Inlining
Inline CSS required for above-the-fold content, load the rest asynchronously:
<head>
<!-- Critical CSS inline -->
<style>
/* Only styles needed for first render */
header { display: flex; ... }
.hero { height: 100vh; ... }
</style>
<!-- Full stylesheet loaded asynchronously -->
<link
rel="preload"
href="/styles.css"
as="style"
onload="this.rel='stylesheet'"
>
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
</head>
Tools like critical can extract critical CSS automatically:
npx critical https://example.com --base dist --inline --minify
Bundle Splitting Strategies
Entry Points by Route (Recommended)
Create separate entry points for major routes:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
input: {
main: 'src/main.tsx',
admin: 'src/admin.tsx',
},
},
},
});
Vendor Splitting
Keep frequently-changing app code separate from stable dependencies:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
// Further split large deps
if (id.includes('react')) return 'vendor-react';
if (id.includes('lodash')) return 'vendor-lodash';
return 'vendor';
}
},
},
},
},
});
Granular Chunks
For large apps, split by feature area:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'feature-auth': ['./src/features/auth/index.ts'],
'feature-dashboard': ['./src/features/dashboard/index.ts'],
'feature-settings': ['./src/features/settings/index.ts'],
},
},
},
},
});
Common Pitfalls
Over-Splitting
Too many chunks increase HTTP overhead:
// ❌ 50 tiny chunks = 50 HTTP requests
manualChunks(id) {
if (id.includes('node_modules')) {
return id.split('node_modules/')[1].split('/')[0];
}
}
// ✅ Reasonable groupings
manualChunks: {
vendor: ['react', 'react-dom', 'react-router'],
ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
}
With HTTP/2, many small files are fine, but there's still parsing overhead. Aim for 5-15 chunks for most apps.
Loading Waterfalls
Sequential async loading creates waterfalls:
// ❌ Waterfall: each awaits the previous
const Dashboard = lazy(() => import('./Dashboard'));
// Dashboard imports Charts, which imports D3...
// 3 round trips: Dashboard.js → Charts.js → D3.js
// ✅ Parallel: load dependencies together
const Dashboard = lazy(() =>
Promise.all([
import('./Dashboard'),
import('d3'),
import('chart.js'),
]).then(([mod]) => mod)
);
Blocking the Main Thread During Load
Large JavaScript bundles block parsing:
// 500KB bundle blocks main thread during parse
<script src="/massive-bundle.js" defer></script>
// Solution: Split into smaller chunks that parse in idle time
Use the Coverage tab in DevTools to find unused code that could be split out.
See Also
- Core Web Vitals - How loading strategies affect LCP and INP
- Asset Optimization - Image and font loading optimization
- Vite - Modern bundler with excellent code splitting defaults
- Webpack - Advanced splitting configuration
- React -
lazy()and Suspense patterns - Next.js - Automatic route-based splitting