Blog · Performance & Optimization
Code Splitting Guide 2026: Faster Loads with Dynamic Imports
How to split your JavaScript so users download only what they need — route-based splitting, dynamic import(), React.lazy, vendor chunks, prefetching and handling stale-deploy chunk errors — each with why and a code example.
Code splitting breaks one large JavaScript bundle into smaller chunks that load on demand, so the first page ships only the code it needs instead of your entire app. It’s one of the highest-leverage performance techniques — smaller initial JavaScript means faster loading, less to parse, and better Core Web Vitals. Here’s a complete code splitting guide for 2026, each technique with the reasoning and a code example.
1. Understand what code splitting does
A bundler follows a dynamic import() and emits the imported module (and its unique dependencies) as a separate chunk, loaded at runtime when the import runs. Everything reachable only through static top-level imports stays in the main bundle. So the lever is simple: turn a static import of something-not-needed-immediately into a dynamic one.
// static: goes in the main bundle, loaded up front
import { heavy } from "./heavy";
// dynamic: its own chunk, loaded only when this runs
const { heavy } = await import("./heavy");
2. Split by route first — the biggest win
Users land on one page but a naive build ships every page’s code. Splitting per route means each page loads as its own chunk on navigation, so first load contains only the entry route. This typically cuts initial JavaScript the most. Most frameworks (Next.js, Nuxt, SvelteKit) do route splitting automatically; in a plain SPA, wire it into the router.
const routes = [
{ path: "/", component: () => import("./pages/Home") },
{ path: "/app", component: () => import("./pages/App") },
{ path: "/settings", component: () => import("./pages/Settings") },
];
3. Use React.lazy and Suspense
In React, lazy() wraps a dynamic import so the component loads on first render, and Suspense shows a fallback while the chunk arrives. Wrap route elements (and heavy components) in a Suspense boundary with a skeleton that matches the final size to avoid layout shift.
const Dashboard = lazy(() => import("./Dashboard"));
<Suspense fallback={<PageSkeleton />}>
<Dashboard />
</Suspense>
4. Split heavy features and libraries
A rich text editor, charting library, map SDK, PDF or video tooling can each be larger than your app shell. Import them dynamically so they load only when the user opens that feature, keeping them out of every other page’s critical path.
openEditorBtn.addEventListener("click", async () => {
const { mountEditor } = await import("./editor"); // separate chunk
mountEditor();
});
5. Split stable vendor code into its own chunk
Your dependencies change far less often than your app code. Putting stable libraries in a dedicated vendor chunk means an app-code change doesn’t invalidate the large vendor cache, so returning visitors re-download less. Configure manual chunks — but group by change-frequency, not one giant "vendor" blob.
// vite.config.js
export default { build: { rollupOptions: { output: {
manualChunks: { react: ["react", "react-dom"] },
} } } };
6. Prefetch the next chunk on intent
Splitting adds a small delay when a chunk is first needed. Hide it by prefetching on intent — hovering a nav link, focusing it, or a trigger scrolling into view — so the chunk is cached before the click. Frameworks like Next.js prefetch in-viewport links automatically; in an SPA, trigger import() on hover.
link.addEventListener("mouseenter", () => import("./pages/Settings"), { once: true });
7. Always handle loading and error states
A chunk load is a network request: it takes time and can fail. Show a skeleton (sized to the final content, to protect CLS) while it loads, and an error state with a retry if it fails, so a slow or dropped chunk never leaves a blank screen.
<Suspense fallback={<Skeleton height={400} />}>
<ErrorBoundary fallback={<Retry />}>
<LazyFeature />
</ErrorBoundary>
</Suspense>
8. Handle ChunkLoadError from stale deploys
The most common code-splitting bug in production: you deploy, hashed chunk filenames change, and a user with the old page open requests a chunk that no longer exists — a ChunkLoadError. Catch it and reload so the browser fetches the current chunks. Keeping previous chunks around for a grace period after deploy avoids the error entirely.
window.addEventListener("error", (e) => {
if (/ChunkLoadError|Loading chunk .* failed/.test(e.message)) {
location.reload(); // fetch the current, renamed chunks
}
});
9. Know your bundler: Vite vs webpack
Splitting APIs differ. In webpack you can name and hint chunks with "magic comments" (/* webpackChunkName */, webpackPrefetch). Vite/Rollup ignore those — a bare dynamic import() is automatically its own chunk, and you control naming/grouping via build.rollupOptions.output (manualChunks, chunkFileNames). Don’t copy webpack magic comments into a Vite project expecting them to work.
// webpack: magic comments
import(/* webpackChunkName: "editor", webpackPrefetch: true */ "./editor");
// Vite/Rollup: plain import() splits automatically; configure output instead
import("./editor");
10. Don't over-split into a request waterfall
Every chunk is a network round-trip. Split into dozens of tiny chunks and you replace one download with a waterfall of latency-bound requests — often slower overall. Split at meaningful boundaries (routes, heavy features); let small, always-used modules stay in the main bundle. Measure chunk counts and sizes, not just "did I split it."
// ❌ over-split: many tiny chunks → many round-trips
// ✅ route + heavy-feature boundaries; keep small shared code together
11. Preload critical async chunks
If a chunk is needed almost immediately after load (an above-the-fold widget that must be interactive fast), let the browser fetch it in parallel rather than discovering it late. Modern bundlers can emit modulepreload links for critical async chunks; use them for the few chunks on the critical interaction path, not everything.
<link rel="modulepreload" href="/assets/critical-widget-4f2a.js">
12. Mind SSR and streaming
With server rendering, code-split components must resolve their chunks on the server too, or you get hydration mismatches and flashes. Use your framework’s SSR-aware lazy loading (Next.js next/dynamic, Nuxt’s async components) and stream with Suspense so the shell paints while split parts load. Test that lazy boundaries hydrate cleanly.
// Next.js: SSR-aware dynamic import with a loading state
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("../Chart"), { loading: () => <Skeleton /> });
13. Combine with tree shaking and compression
Code splitting decides *when* code loads; tree shaking decides *whether* dead code ships at all; compression shrinks what’s left on the wire. They’re complementary — split routes, tree-shake each chunk, and serve Brotli. See our tree shaking guide and how to reduce bundle size.
# each chunk is tree-shaken + minified in a production build, then Brotli on the wire
vite build
14. Measure chunk sizes and loading
Verify splitting did what you intended: check the build output’s chunk list, visualize with a bundle analyzer, and watch the Network panel to confirm chunks load when expected (and aren’t a waterfall). Track initial JavaScript over time with a CI size budget so a regression shows up on the PR that caused it.
npx rollup-plugin-visualizer # treemap of chunks
# or inspect the build's printed chunk sizes and Network waterfall
Code splitting checklist
- Split by route first — the single biggest reduction to initial JavaScript.
- Use
React.lazy+Suspense(or your framework’s SSR-aware dynamic import). - Dynamically import heavy features/libraries; split stable vendor code by change-frequency.
- Prefetch the next chunk on intent; show loading and error states.
- Handle
ChunkLoadErrorfrom stale deploys with a reload. - Know your bundler (webpack magic comments vs Vite/Rollup config); don’t over-split.
- Combine with tree shaking + compression; measure chunk sizes and enforce a budget.
Frequently asked questions
What is the best code splitting strategy?
How does React.lazy work?
React.lazy(() => import("./X")) wraps a dynamic import so the component’s chunk loads the first time it renders. Wrap it in a <Suspense fallback={…}> to show a placeholder while the chunk downloads. For server-side rendering, use an SSR-aware helper like Next.js next/dynamic so chunks resolve on the server too.Why do I get ChunkLoadError after deploying?
ChunkLoadError. Catch it and reload the page (which fetches the current chunks), and/or keep previous chunks available for a grace period after each deploy.Do Vite and webpack split code differently?
/* webpackChunkName */ and webpackPrefetch. Vite/Rollup ignore those — a plain dynamic import() becomes its own chunk automatically, and you control naming and grouping via build.rollupOptions.output (manualChunks). Don’t reuse webpack magic comments in a Vite project.Experiment with dynamic imports and Suspense live in XCODX Studio — npm packages run in the browser, no build setup. See also Lazy Loading Best Practices, How to Reduce Bundle Size and the Tree Shaking Guide.