Code Splitting & Lazy Loading
Code splitting is the practice of splitting a JavaScript bundle into smaller chunks that can be loaded on demand. Instead of sending the entire application to the browser upfront, only the code needed for the current view is sent — the rest is fetched lazily as the user navigates.
This technique is fundamental to modern web performance. Facebook, Twitter, and Airbnb all use aggressive code splitting to achieve sub-second initial load times on large applications. Combined with lazy loading, it can reduce initial bundle size by 50-80%.
The import() syntax (not to be confused with static import) is the foundation of code splitting. It returns a Promise that resolves to the module namespace, enabling on-demand module loading.
| 1 | // Static import — bundled eagerly (always loaded) |
| 2 | import { formatDistanceToNow } from "date-fns"; |
| 3 | |
| 4 | // Dynamic import — split into separate chunk (loaded on demand) |
| 5 | const formatModule = await import("date-fns/formatDistanceToNow"); |
| 6 | |
| 7 | // Or with .then() |
| 8 | import("lodash-es/debounce").then(({ default: debounce }) => { |
| 9 | const debouncedFn = debounce(() => {}, 300); |
| 10 | }); |
| 11 | |
| 12 | // Dynamic import with type safety (TypeScript) |
| 13 | async function loadChart() { |
| 14 | const Chart = await import("@/components/Chart"); |
| 15 | return Chart.default; |
| 16 | } |
| 17 | |
| 18 | // Dynamic import with default export |
| 19 | const { default: HeavyComponent } = await import("./HeavyComponent"); |
| 20 | |
| 21 | // Dynamic import with named exports |
| 22 | const { parse, format } = await import("date-fns"); |
| 23 | |
| 24 | // Conditional import |
| 25 | if (typeof window !== "undefined" && "IntersectionObserver" in window) { |
| 26 | const observer = await import("./intersection-observer-polyfill"); |
| 27 | } |
| 28 | |
| 29 | // Import with error handling |
| 30 | try { |
| 31 | const utils = await import("./utils").catch(() => import("./utils-fallback")); |
| 32 | utils.run(); |
| 33 | } catch (e) { |
| 34 | console.error("Module failed to load", e); |
| 35 | } |
info
React provides React.lazy for component-level code splitting and Suspense for managing the loading state. This is the standard approach for lazy-loading React components.
| 1 | import React, { Suspense } from "react"; |
| 2 | import LoadingSpinner from "@/components/LoadingSpinner"; |
| 3 | |
| 4 | // React.lazy — dynamic import wrapped for React |
| 5 | const Dashboard = React.lazy(() => import("@/pages/Dashboard")); |
| 6 | const Settings = React.lazy(() => import("@/pages/Settings")); |
| 7 | const Analytics = React.lazy(() => import("@/pages/Analytics")); |
| 8 | const UserProfile = React.lazy(() => import("@/pages/UserProfile")); |
| 9 | |
| 10 | // Each lazy component is a separate chunk: |
| 11 | // Dashboard.hash.js |
| 12 | // Settings.hash.js |
| 13 | // Analytics.hash.js |
| 14 | // UserProfile.hash.js |
| 15 | |
| 16 | function App() { |
| 17 | return ( |
| 18 | <Suspense fallback={<LoadingSpinner />}> |
| 19 | <Routes> |
| 20 | <Route path="/dashboard" element={<Dashboard />} /> |
| 21 | <Route path="/settings" element={<Settings />} /> |
| 22 | <Route path="/analytics" element={<Analytics />} /> |
| 23 | <Route path="/profile" element={<UserProfile />} /> |
| 24 | </Routes> |
| 25 | </Suspense> |
| 26 | ); |
| 27 | } |
| 28 | |
| 29 | // Nested Suspense for granular loading states |
| 30 | function DashboardLayout() { |
| 31 | return ( |
| 32 | <div> |
| 33 | <h1>Dashboard</h1> |
| 34 | <Suspense fallback={<WidgetSkeleton />}> |
| 35 | <RevenueChart /> |
| 36 | </Suspense> |
| 37 | <Suspense fallback={<WidgetSkeleton />}> |
| 38 | <UserActivity /> |
| 39 | </Suspense> |
| 40 | <Suspense fallback={<WidgetSkeleton />}> |
| 41 | <RecentOrders /> |
| 42 | </Suspense> |
| 43 | </div> |
| 44 | ); |
| 45 | } |
| 46 | |
| 47 | const RevenueChart = React.lazy(() => import("./RevenueChart")); |
| 48 | const UserActivity = React.lazy(() => import("./UserActivity")); |
| 49 | const RecentOrders = React.lazy(() => import("./RecentOrders")); |
best practice
| 1 | // Lazy loading modals (common optimization) |
| 2 | function ModalContainer() { |
| 3 | const [isOpen, setIsOpen] = useState(false); |
| 4 | |
| 5 | return ( |
| 6 | <> |
| 7 | <button onClick={() => setIsOpen(true)}>Open Modal</button> |
| 8 | {isOpen && ( |
| 9 | <Suspense fallback={<ModalSkeleton />}> |
| 10 | <CheckoutModal onClose={() => setIsOpen(false)} /> |
| 11 | </Suspense> |
| 12 | )} |
| 13 | </> |
| 14 | ); |
| 15 | } |
| 16 | |
| 17 | const CheckoutModal = React.lazy(() => import("./CheckoutModal")); |
| 18 | |
| 19 | // Preload on hover (predictive loading) |
| 20 | function ProductCard({ productId }: { productId: string }) { |
| 21 | const preloadDetail = () => { |
| 22 | const detailPromise = import("./ProductDetail"); |
| 23 | // Store promise for instant render when navigated |
| 24 | }; |
| 25 | |
| 26 | return ( |
| 27 | <div onMouseEnter={preloadDetail}> |
| 28 | <h3>{productId}</h3> |
| 29 | </div> |
| 30 | ); |
| 31 | } |
| 32 | |
| 33 | // Named exports with React.lazy |
| 34 | const { LazyComponent } = React.lazy(async () => { |
| 35 | const module = await import("./HeavyComponent"); |
| 36 | return { default: module.LazyComponent }; |
| 37 | }); |
Route-based code splitting is the most impactful strategy — each route becomes its own chunk, loaded only when the user navigates to it. This is the default in modern frameworks like Next.js.
| 1 | // React Router with lazy routes |
| 2 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; |
| 3 | import { Suspense, lazy } from "react"; |
| 4 | import Layout from "@/components/Layout"; |
| 5 | import LoadingPage from "@/components/LoadingPage"; |
| 6 | |
| 7 | const Home = lazy(() => import("@/pages/Home")); |
| 8 | const About = lazy(() => import("@/pages/About")); |
| 9 | const Blog = lazy(() => import("@/pages/Blog")); |
| 10 | const BlogPost = lazy(() => import("@/pages/BlogPost")); |
| 11 | const Dashboard = lazy(() => import("@/pages/Dashboard")); |
| 12 | const NotFound = lazy(() => import("@/pages/NotFound")); |
| 13 | |
| 14 | const router = createBrowserRouter([ |
| 15 | { |
| 16 | element: <Layout />, |
| 17 | children: [ |
| 18 | { path: "/", element: <Home /> }, |
| 19 | { path: "/about", element: <About /> }, |
| 20 | { |
| 21 | path: "/blog", |
| 22 | element: <Blog />, |
| 23 | // Nested lazy routes |
| 24 | children: [ |
| 25 | { path: ":slug", element: <BlogPost /> }, |
| 26 | ], |
| 27 | }, |
| 28 | { |
| 29 | path: "/dashboard", |
| 30 | element: <Dashboard />, |
| 31 | loader: () => import("@/pages/Dashboard"), // Preload |
| 32 | }, |
| 33 | { path: "*", element: <NotFound /> }, |
| 34 | ], |
| 35 | }, |
| 36 | ]); |
| 37 | |
| 38 | function App() { |
| 39 | return ( |
| 40 | <Suspense fallback={<LoadingPage />}> |
| 41 | <RouterProvider router={router} /> |
| 42 | </Suspense> |
| 43 | ); |
| 44 | } |
| 45 | |
| 46 | // Next.js App Router — automatic route-level splitting |
| 47 | // app/(routes)/dashboard/page.tsx |
| 48 | // app/(routes)/settings/page.tsx |
| 49 | // app/(routes)/analytics/page.tsx |
| 50 | // Next.js automatically creates separate chunks per route |
warning
Meaningful chunk names make debugging and analysis easier. Both Webpack and Vite support magic comments for naming chunks.
| 1 | // Magic comments for chunk naming (Webpack) |
| 2 | const AdminPanel = React.lazy(() => |
| 3 | import(/* webpackChunkName: "admin-panel" */ "./AdminPanel") |
| 4 | ); |
| 5 | |
| 6 | const ChartLibrary = React.lazy(() => |
| 7 | import(/* webpackChunkName: "charts" */ "./ChartLibrary") |
| 8 | ); |
| 9 | |
| 10 | const PdfExport = React.lazy(() => |
| 11 | import(/* webpackChunkName: "export-pdf" */ "./PdfExport") |
| 12 | ); |
| 13 | |
| 14 | // With prefetch/preload hints (Webpack) |
| 15 | const SettingsPage = React.lazy(() => |
| 16 | import(/* webpackPrefetch: true */ "./SettingsPage") |
| 17 | ); |
| 18 | |
| 19 | const HelpWidget = React.lazy(() => |
| 20 | import(/* webpackPreload: true */ "./HelpWidget") |
| 21 | ); |
| 22 | |
| 23 | // Vite — uses Rollup, configure chunk names in vite.config.ts |
| 24 | import { defineConfig } from "vite"; |
| 25 | |
| 26 | export default defineConfig({ |
| 27 | build: { |
| 28 | rollupOptions: { |
| 29 | output: { |
| 30 | chunkFileNames: "chunks/[name]-[hash].js", |
| 31 | entryFileNames: "entries/[name]-[hash].js", |
| 32 | manualChunks: { |
| 33 | vendor: ["react", "react-dom"], |
| 34 | charts: ["chart.js", "d3"], |
| 35 | utils: ["date-fns", "lodash-es"], |
| 36 | }, |
| 37 | }, |
| 38 | }, |
| 39 | }, |
| 40 | }); |
pro tip
Before optimizing bundle size, you need to know what is in your bundles. Bundle analyzers visualize the composition of your JavaScript chunks, helping identify oversized dependencies and split opportunities.
| 1 | # Webpack Bundle Analyzer |
| 2 | npm install --save-dev webpack-bundle-analyzer |
| 3 | |
| 4 | # Add to webpack.config.js |
| 5 | const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer"); |
| 6 | |
| 7 | module.exports = { |
| 8 | plugins: [ |
| 9 | new BundleAnalyzerPlugin({ |
| 10 | analyzerMode: "server", // Opens interactive treemap |
| 11 | openAnalyzer: true, |
| 12 | generateStatsFile: true, |
| 13 | statsFilename: "stats.json", |
| 14 | defaultSizes: "gzip", |
| 15 | }), |
| 16 | ], |
| 17 | }; |
| 18 | |
| 19 | # Vite — rollup-plugin-visualizer |
| 20 | npm install --save-dev rollup-plugin-visualizer |
| 21 | |
| 22 | # vite.config.ts |
| 23 | import { visualizer } from "rollup-plugin-visualizer"; |
| 24 | |
| 25 | export default defineConfig({ |
| 26 | plugins: [ |
| 27 | visualizer({ |
| 28 | filename: "dist/stats.html", |
| 29 | open: true, |
| 30 | gzipSize: true, |
| 31 | brotliSize: true, |
| 32 | }), |
| 33 | ], |
| 34 | }); |
| 35 | |
| 36 | # Analyze with statoscope (Webpack) |
| 37 | npx statoscope serve dist/stats.json |
| 38 | |
| 39 | # Generate and diff bundle stats |
| 40 | npx webpack --profile --json > stats.json |
| Tool | Bundler | Features |
|---|---|---|
| webpack-bundle-analyzer | Webpack | Interactive treemap, search, side-by-side compare |
| rollup-plugin-visualizer | Rollup/Vite | Treemap, sunburst, network graph, gzip/brotli sizes |
| statoscope | Webpack | Advanced analysis, duplicates, module diffing |
| source-map-explorer | Any | Simple treemap from source maps |
| import-cost (VSCode) | Any | Inline import size in editor |
| 1 | // Common bundle analysis findings and fixes |
| 2 | |
| 3 | // 1. Duplicate dependencies (resolved to multiple versions) |
| 4 | // Fix: Use resolution overrides in package.json |
| 5 | { |
| 6 | "overrides": { |
| 7 | "react": "18.2.0", |
| 8 | "lodash": "4.17.21" |
| 9 | } |
| 10 | } |
| 11 | |
| 12 | // 2. Importing entire libraries when only a function is needed |
| 13 | // Bad: |
| 14 | import _ from "lodash"; // 530KB |
| 15 | // Good: |
| 16 | import debounce from "lodash/debounce"; // 25KB |
| 17 | |
| 18 | // 3. Moment.js (huge locale data) |
| 19 | // Bad: |
| 20 | import moment from "moment"; // 230KB |
| 21 | // Good: |
| 22 | import { format } from "date-fns"; // 4KB (tree-shakable) |
| 23 | |
| 24 | // 4. Chart libraries loaded on every page |
| 25 | // Bad: import Chart from "chart.js" at top level |
| 26 | // Good: dynamic import in component that renders charts |
| 27 | |
| 28 | // 5. Polyfills in modern-browser builds |
| 29 | // Fix: Use @babel/preset-env with targets for modern browsers |
| 30 | // Don't ship core-js if targeting modern Chrome/Firefox |
best practice
Beyond code splitting, resource hints tell the browser to fetch critical resources early. These <link> hints work alongside dynamic imports for a complete loading strategy.
| 1 | <!-- Preload: high-priority fetch for current page resources --> |
| 2 | <link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin /> |
| 3 | <link rel="preload" href="/hero.webp" as="image" /> |
| 4 | <link rel="preload" href="/critical.css" as="style" /> |
| 5 | <link rel="preload" href="/dashboard.js" as="script" /> |
| 6 | |
| 7 | <!-- Prefetch: low-priority fetch for likely next-page resources --> |
| 8 | <link rel="prefetch" href="/analytics.js" as="script" /> |
| 9 | <link rel="prefetch" href="/settings.js" as="script" /> |
| 10 | <link rel="prefetch" href="/api/dashboard-data" as="fetch" crossorigin /> |
| 11 | |
| 12 | <!-- Preconnect: early connection to third-party origins --> |
| 13 | <link rel="preconnect" href="https://api.example.com" /> |
| 14 | <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin /> |
| 15 | <link rel="preconnect" href="https://images.example.com" /> |
| 16 | |
| 17 | <!-- DNS Prefetch: fallback for older browsers --> |
| 18 | <link rel="dns-prefetch" href="https://cdn.example.com" /> |
| 19 | |
| 20 | <!-- Module Preload: preload ES module and its deps --> |
| 21 | <link rel="modulepreload" href="/app.mjs" /> |
| 22 | |
| 23 | <!-- Prerender: full page render in background (use sparingly) --> |
| 24 | <link rel="prerender" href="/next-page" /> |
| 1 | // Programmatic preloading in React |
| 2 | function usePrefetch(url: string) { |
| 3 | useEffect(() => { |
| 4 | const link = document.createElement("link"); |
| 5 | link.rel = "prefetch"; |
| 6 | link.href = url; |
| 7 | link.as = "script"; |
| 8 | document.head.appendChild(link); |
| 9 | return () => document.head.removeChild(link); |
| 10 | }, [url]); |
| 11 | } |
| 12 | |
| 13 | // Predictive preloading based on user behavior |
| 14 | function NavLink({ to, children }: { to: string; children: React.ReactNode }) { |
| 15 | const preload = () => { |
| 16 | const link = document.createElement("link"); |
| 17 | link.rel = "prefetch"; |
| 18 | link.href = to; |
| 19 | document.head.appendChild(link); |
| 20 | }; |
| 21 | |
| 22 | return ( |
| 23 | <Link to={to} onMouseEnter={preload}> |
| 24 | {children} |
| 25 | </Link> |
| 26 | ); |
| 27 | } |
| 28 | |
| 29 | // Webpack magic comments for prefetch/preload |
| 30 | const Analytics = React.lazy(() => |
| 31 | import(/* webpackPrefetch: true */ "./Analytics") |
| 32 | ); |
| 33 | |
| 34 | const Dashboard = React.lazy(() => |
| 35 | import(/* webpackPreload: true */ "./Dashboard") |
| 36 | ); |
| 37 | |
| 38 | // Vite — prefetch via <link> injection in index.html |
| 39 | // or use vite-plugin-push-manifest for advanced prefetching |
| Hint | Priority | Use Case | When to Use |
|---|---|---|---|
| preload | High | Critical current-page resources | Fonts, hero images, critical CSS |
| prefetch | Low | Likely next-page resources | Secondary routes, search results |
| preconnect | High | Early origin connections | CDNs, API servers, font hosts |
| modulepreload | High | ES module + dependencies | Critical import chains |
warning
Critical CSS Extraction
Extract above-the-fold CSS and inline it in the HTML, deferring the full stylesheet. This eliminates render-blocking CSS for the initial viewport.
| 1 | // With critters (Webpack plugin) |
| 2 | const Critters = require("critters-webpack-plugin"); |
| 3 | |
| 4 | module.exports = { |
| 5 | plugins: [ |
| 6 | new Critters({ |
| 7 | preload: "swap", // Preload remaining CSS |
| 8 | inlineThreshold: 0, // Inline everything above fold |
| 9 | minimumExternalSize: 0, // Do not inline large files |
| 10 | pruneSource: true, // Remove inlined CSS from external sheets |
| 11 | mergeStylesheets: true, |
| 12 | additionalStylesheets: [], |
| 13 | }), |
| 14 | ], |
| 15 | }; |
Component-Level Chunking
Split not just by route but by component visibility. Use Intersection Observer to lazy-load components when they enter the viewport.
| 1 | // Intersection Observer + lazy loading |
| 2 | function LazySection({ children }: { children: React.ReactNode }) { |
| 3 | const ref = useRef<HTMLDivElement>(null); |
| 4 | const [shouldLoad, setShouldLoad] = useState(false); |
| 5 | |
| 6 | useEffect(() => { |
| 7 | const observer = new IntersectionObserver( |
| 8 | ([entry]) => { |
| 9 | if (entry.isIntersecting) { |
| 10 | setShouldLoad(true); |
| 11 | observer.disconnect(); |
| 12 | } |
| 13 | }, |
| 14 | { rootMargin: "200px" } // Start loading 200px before visible |
| 15 | ); |
| 16 | |
| 17 | if (ref.current) observer.observe(ref.current); |
| 18 | return () => observer.disconnect(); |
| 19 | }, []); |
| 20 | |
| 21 | return <div ref={ref}>{shouldLoad ? children : <Placeholder />}</div>; |
| 22 | } |
| 23 | |
| 24 | // Usage — component only loads when scrolled near |
| 25 | function ProductPage() { |
| 26 | return ( |
| 27 | <div> |
| 28 | <ProductHero /> {/* Always loaded */} |
| 29 | <LazySection> |
| 30 | <ProductReviews /> {/* Loaded when user scrolls down */} |
| 31 | </LazySection> |
| 32 | <LazySection> |
| 33 | <RelatedProducts /> {/* Loaded when user scrolls further */} |
| 34 | </LazySection> |
| 35 | </div> |
| 36 | ); |
| 37 | } |