Implementing Route Level Code Splitting in JS
Learn how to split JavaScript code by route so users only download what they need for the page they visit. Covers dynamic imports, bundler configuration, and practical patterns.
Route level code splitting means loading JavaScript for each page only when the user navigates to that page. Instead of one giant bundle containing code for every route, the bundler creates smaller chunks, one per route, and the browser downloads them on demand. This technique is one of the most effective performance optimizations available for multi-page JavaScript applications.
Here is the problem it solves:
Without code splitting:
main.bundle.js (800 KB, contains every page)
With route-level code splitting:
main.bundle.js (150 KB, shared core only)
home.chunk.js (40 KB, loaded on the homepage)
dashboard.chunk.js (200 KB, loaded only on /dashboard)A user who only visits the homepage downloads 190 KB instead of 800 KB. The difference grows with every route you add.
The Core Pattern: Dynamic Import by Route
The fundamental technique is pairing routes with dynamic imports. Here is the pattern in vanilla JavaScript with a simple client-side router. First, define the route map, where each path points to a function that dynamically imports the page module:
// router.js
const routes = {
"/": () => import("./pages/home.js"),
"/dashboard": () => import("./pages/dashboard.js"),
"/settings": () => import("./pages/settings.js"),
"/reports": () => import("./pages/reports.js"),
};Each route value is an arrow function that returns import(). The import does not execute until the function is called. The navigation function below looks up the matching route, calls it, and renders the result, using a try/catch to handle any loading failures:
async function navigate(path) {
const loadPage = routes[path];
if (!loadPage) {
console.warn(`No route found for ${path}`);
return;
}
try {
const page = await loadPage();
page.render(document.querySelector("#app"));
} catch (error) {
console.error(`Failed to load ${path}:`, error);
}
}If no route matches, the function warns and exits early. Otherwise it awaits the dynamic import and calls render on the result, so a failed network request lands in the catch block instead of crashing the page.
The router maps each path to a function that returns import(). When the user navigates, only the target route's code downloads. After the first visit, the browser caches the chunk, so subsequent navigations are instant.
How Bundlers Create Route Chunks
When a bundler like Vite or Webpack encounters a dynamic import() call, it automatically extracts the imported module and its dependencies into a separate file:
// Your source code
const page = await import("./pages/dashboard.js");
// Bundler output (conceptual)
// main.js - does not contain dashboard code
// dashboard-abc123.js - contains dashboard.js and its dependenciesYou do not need special configuration. The bundler detects every import() call and creates a chunk for each one. This is the magic that makes route-level splitting practical.
The chunk filename includes a content hash (like abc123), which changes only when the source code changes. This enables long-term browser caching: users download a new chunk only when its code actually updated.
Practical Pattern: SPA with Vanilla JavaScript
The navigate function above already loads route code on demand. To make it feel like a real single-page app, wire it to the History API so link clicks update the URL without a full page reload:
document.addEventListener("click", (event) => {
const link = event.target.closest("a[data-route]");
if (!link) return;
event.preventDefault();
const path = link.getAttribute("data-route");
history.pushState(null, "", path);
navigate(path);
});Clicking a link with a data-route attribute updates the address bar with history.pushState and calls the same navigate function shown earlier, instead of letting the browser reload the page. The back and forward buttons need their own listener, because pushState does not fire a popstate event on its own:
window.addEventListener("popstate", () => navigate(location.pathname));
navigate(location.pathname);The last line calls navigate once when the script first runs, so the correct route renders immediately instead of waiting for a click.
Prefetching Routes for Faster Navigation
You can predict which route the user is likely to visit next and prefetch it silently:
// Prefetch a route when the user hovers over its link
document.addEventListener("mouseover", (event) => {
const link = event.target.closest("a[data-route]");
if (!link) return;
const path = link.getAttribute("data-route");
const loadPage = routes[path];
if (loadPage) loadPage();
});The chunk downloads while the user is still reading, so when they click, the code is already cached and the navigation feels instant.
Webpack also supports this natively through magic comments like import(/* webpackPrefetch: true */ "./page.js"). Vite has no built-in equivalent, so the manual hover-triggered pattern above is the standard approach there.
Common Mistake: Splitting Too Aggressively
Not every file benefits from being its own chunk. Splitting too much creates many small network requests, which can hurt performance:
// Too aggressive - each tiny utility is its own chunk
import { formatDate } from "./utils.js"; // 200 bytes - not worth splitting
import { formatPrice } from "./utils.js"; // 300 bytes - not worth splitting
// Better - keep small utilities together
import { formatDate, formatPrice } from "./utils.js";A good rule: only split at route boundaries or for modules larger than 20-30 KB. Let the bundler handle fine-grained splitting through its automatic chunking algorithms.
How Frameworks Handle Route-Level Splitting
Most frameworks have built-in support for route-level code splitting. The concept is the same as the vanilla approach, with cleaner syntax:
In React with React Router, first import the lazy helper and define each route component using it:
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));Each lazy call wraps a dynamic import so React only fetches that component's chunk when it is about to render. Wrap the routes in Suspense so the app shows a loading indicator while a chunk downloads instead of rendering a blank page:
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}React's lazy function wraps the dynamic import, and Suspense shows a fallback while the chunk loads. This is functionally the same as the vanilla approach with cleaner syntax.
For more advanced strategies, including shared chunks and vendor splitting, see the advanced code splitting guide.
Rune AI
Key Insights
- Route-level code splitting uses dynamic
import()to load page code on demand. - Bundlers automatically create separate chunks for each dynamic import.
- Users only download JavaScript for the routes they actually visit.
- Start with heavy routes like admin panels, dashboards, and chart pages.
- Combine with lazy loading and prefetching for the best user experience.
Frequently Asked Questions
How much smaller does code splitting make my initial bundle?
Does route-level code splitting affect SEO?
Can I code-split CSS too?
Conclusion
Route-level code splitting is one of the highest-impact performance optimizations you can make. By loading page-specific code only when the user navigates to that page, you dramatically reduce the initial JavaScript download. Start with your heaviest routes and use dynamic imports to split them out. Modern bundlers handle the rest automatically.
More in this topic
Using Decorators for Logging in JS Architecture
Learn how JavaScript decorators wrap class methods to add logging without touching the original code, including setup with Babel and how execution order works.
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.