JavaScripthighperformance
Split large JavaScript bundles
rule · code-splitting
Code splitting uses import() (opens in a new tab) and route boundaries to divide your JavaScript application into separate chunks that load on demand rather than all at once.
Code Example
JavaScript
// ❌ Static import loads everything upfront
import { generatePDF } from './pdf-generator.js' // Heavy library loaded even if never used
// ✅ Dynamic import loads only when needed
async function handleExportClick() {
const { generatePDF } = await import('./pdf-generator.js')
generatePDF(document)
}Why It Matters
A 500 KB JavaScript bundle blocks page interactivity for 3–5 seconds on a mid-range mobile device even after the bytes arrive — JS must be parsed and compiled before execution. The web.dev dynamic imports guide (opens in a new tab) focuses on this exact win: users only download and parse the code they actually need for the current page.
Route-Based Splitting (React)
JavaScript
import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
// Each route is its own chunk — users download code for the page they visit
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import('./pages/Settings'))
const Reports = lazy(() => import('./pages/Reports'))
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
)
}Lazy-Loading Heavy Components
JavaScript
// Rich text editors, chart libraries, map components
const RichEditor = lazy(() => import('./RichEditor'))
const ChartPanel = lazy(() => import('./ChartPanel'))
function PostEditor({ showChart }) {
return (
<div>
<Suspense fallback={<EditorSkeleton />}>
<RichEditor />
</Suspense>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<ChartPanel />
</Suspense>
)}
</div>
)
}Conditional Feature Loading
JavaScript
// Load analytics only in production
if (process.env.NODE_ENV === 'production') {
import('./analytics.js').then(({ init }) => init())
}
// Load a polyfill only when needed
async function setupApp() {
if (!window.ResizeObserver) {
await import('resize-observer-polyfill')
}
initApp()
}Preloading for Anticipated Navigation
JavaScript
// Preload the next likely page without executing it yet
function prefetchSettingsPage() {
import(/* webpackPrefetch: true */ './pages/Settings')
}
// Trigger on hover — user is likely about to click
settingsLink.addEventListener('mouseenter', prefetchSettingsPage)Analyzing Your Bundle
A first pass with Webpack Bundle Analyzer (opens in a new tab) or Bundlephobia (opens in a new tab) usually shows which dependency or route should move behind import().
Shell
# With Vite
pnpm exec vite-bundle-visualizer
# With webpack
pnpm exec webpack-bundle-analyzer stats.json
# With source-map-explorer
pnpm exec source-map-explorer 'build/static/js/*.js'Verification
Automated Checks
- Compare the before/after output from your bundle visualizer and confirm the initial chunk shrinks meaningfully;
import()(opens in a new tab) should move real code out of the initial graph rather than only reshuffle file names. - Test the loading state or suspense fallback so deferred features still feel intentional to users.
- Re-run Lighthouse or your performance budget check to confirm the split improves initial JS cost instead of only moving bytes around.
- If your project uses a JS budget, keep the initial bundle within that threshold rather than only moving bytes into slightly later chunks; a common starting point is
<= 150 KBgzipped for the main route bundle.
Manual Checks
- Verify the lazy-loaded code is not downloaded on first load unless the current route or interaction needs it.