|$ curl https://forge-ai.dev/api/markdown?path=docs/performance/code-splitting
$cat docs/code-splitting-&-lazy-loading.md
updated Recently·35 min read·published

Code Splitting & Lazy Loading

PerformanceOptimizationIntermediate
Introduction

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%.

Dynamic import() Syntax

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.

dynamic-imports.ts
TypeScript
1// Static import — bundled eagerly (always loaded)
2import { formatDistanceToNow } from "date-fns";
3
4// Dynamic import — split into separate chunk (loaded on demand)
5const formatModule = await import("date-fns/formatDistanceToNow");
6
7// Or with .then()
8import("lodash-es/debounce").then(({ default: debounce }) => {
9 const debouncedFn = debounce(() => {}, 300);
10});
11
12// Dynamic import with type safety (TypeScript)
13async function loadChart() {
14 const Chart = await import("@/components/Chart");
15 return Chart.default;
16}
17
18// Dynamic import with default export
19const { default: HeavyComponent } = await import("./HeavyComponent");
20
21// Dynamic import with named exports
22const { parse, format } = await import("date-fns");
23
24// Conditional import
25if (typeof window !== "undefined" && "IntersectionObserver" in window) {
26 const observer = await import("./intersection-observer-polyfill");
27}
28
29// Import with error handling
30try {
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

Webpack and Vite use the import() call as a split point automatically. The module path must be a string literal (not a variable) for chunk naming to work. Dynamic expressions like import(`locale-${lang}`) create a context module — Webpack bundles all matching files.
React.lazy & Suspense

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.

ReactLazy.tsx
TypeScript
1import React, { Suspense } from "react";
2import LoadingSpinner from "@/components/LoadingSpinner";
3
4// React.lazy — dynamic import wrapped for React
5const Dashboard = React.lazy(() => import("@/pages/Dashboard"));
6const Settings = React.lazy(() => import("@/pages/Settings"));
7const Analytics = React.lazy(() => import("@/pages/Analytics"));
8const 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
16function 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
30function 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
47const RevenueChart = React.lazy(() => import("./RevenueChart"));
48const UserActivity = React.lazy(() => import("./UserActivity"));
49const RecentOrders = React.lazy(() => import("./RecentOrders"));

best practice

Place Suspense at strategic boundaries — not just at the route level. Wrap individual widgets, modals, and below-the-fold sections. This prevents a single slow chunk from blocking the entire page and allows progressive rendering.
lazy-patterns.tsx
TypeScript
1// Lazy loading modals (common optimization)
2function 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
17const CheckoutModal = React.lazy(() => import("./CheckoutModal"));
18
19// Preload on hover (predictive loading)
20function 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
34const { LazyComponent } = React.lazy(async () => {
35 const module = await import("./HeavyComponent");
36 return { default: module.LazyComponent };
37});
Route-Based Splitting

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.

route-splitting.tsx
TypeScript
1// React Router with lazy routes
2import { createBrowserRouter, RouterProvider } from "react-router-dom";
3import { Suspense, lazy } from "react";
4import Layout from "@/components/Layout";
5import LoadingPage from "@/components/LoadingPage";
6
7const Home = lazy(() => import("@/pages/Home"));
8const About = lazy(() => import("@/pages/About"));
9const Blog = lazy(() => import("@/pages/Blog"));
10const BlogPost = lazy(() => import("@/pages/BlogPost"));
11const Dashboard = lazy(() => import("@/pages/Dashboard"));
12const NotFound = lazy(() => import("@/pages/NotFound"));
13
14const 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
38function 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

Do not lazy-load every route. Critical routes (landing page, login) should be eagerly loaded for instant rendering. Reserve lazy loading for secondary routes: admin panels, settings, dashboards, and rarely accessed pages.
Chunk Naming & Management

Meaningful chunk names make debugging and analysis easier. Both Webpack and Vite support magic comments for naming chunks.

chunk-naming.ts
TypeScript
1// Magic comments for chunk naming (Webpack)
2const AdminPanel = React.lazy(() =>
3 import(/* webpackChunkName: "admin-panel" */ "./AdminPanel")
4);
5
6const ChartLibrary = React.lazy(() =>
7 import(/* webpackChunkName: "charts" */ "./ChartLibrary")
8);
9
10const PdfExport = React.lazy(() =>
11 import(/* webpackChunkName: "export-pdf" */ "./PdfExport")
12);
13
14// With prefetch/preload hints (Webpack)
15const SettingsPage = React.lazy(() =>
16 import(/* webpackPrefetch: true */ "./SettingsPage")
17);
18
19const HelpWidget = React.lazy(() =>
20 import(/* webpackPreload: true */ "./HelpWidget")
21);
22
23// Vite — uses Rollup, configure chunk names in vite.config.ts
24import { defineConfig } from "vite";
25
26export 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

Use /* webpackPrefetch: true */ for routes the user is likely to navigate to next (e.g., the settings link in the navbar). The browser downloads the chunk in idle time with low priority. Use /* webpackPreload: true */ sparingly for chunks needed immediately by the current page.
Bundle Analysis

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.

bundle-analysis.js
Bash
1# Webpack Bundle Analyzer
2npm install --save-dev webpack-bundle-analyzer
3
4# Add to webpack.config.js
5const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");
6
7module.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
20npm install --save-dev rollup-plugin-visualizer
21
22# vite.config.ts
23import { visualizer } from "rollup-plugin-visualizer";
24
25export 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)
37npx statoscope serve dist/stats.json
38
39# Generate and diff bundle stats
40npx webpack --profile --json > stats.json
ToolBundlerFeatures
webpack-bundle-analyzerWebpackInteractive treemap, search, side-by-side compare
rollup-plugin-visualizerRollup/ViteTreemap, sunburst, network graph, gzip/brotli sizes
statoscopeWebpackAdvanced analysis, duplicates, module diffing
source-map-explorerAnySimple treemap from source maps
import-cost (VSCode)AnyInline import size in editor
analysis-fixes.js
JavaScript
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:
14import _ from "lodash"; // 530KB
15// Good:
16import debounce from "lodash/debounce"; // 25KB
17
18// 3. Moment.js (huge locale data)
19// Bad:
20import moment from "moment"; // 230KB
21// Good:
22import { 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

Run bundle analysis before every major release. Set up CI to fail if bundle size exceeds a threshold. Tools like bundlesize or size-limit can enforce budgets automatically.
Preload, Prefetch & Preconnect

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.

resource-hints.html
HTML
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" />
prefetch-react.ts
TypeScript
1// Programmatic preloading in React
2function 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
14function 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
30const Analytics = React.lazy(() =>
31 import(/* webpackPrefetch: true */ "./Analytics")
32);
33
34const 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
HintPriorityUse CaseWhen to Use
preloadHighCritical current-page resourcesFonts, hero images, critical CSS
prefetchLowLikely next-page resourcesSecondary routes, search results
preconnectHighEarly origin connectionsCDNs, API servers, font hosts
modulepreloadHighES module + dependenciesCritical import chains

warning

Do not overuse preload. It competes with other critical resources for bandwidth. Only preload resources that the browser would discover late (e.g., fonts loaded from CSS, hero images referenced in stylesheets). Over-preloading can actually hurt performance.
Advanced Strategies

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.

critical-css.js
JavaScript
1// With critters (Webpack plugin)
2const Critters = require("critters-webpack-plugin");
3
4module.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.

intersection-lazy.tsx
TypeScript
1// Intersection Observer + lazy loading
2function 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
25function 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}
Best Practices
Lazy-load by route first — it provides the best ROI for initial load time reduction
Use React.lazy + Suspense for component-level splitting within pages
Set chunk budgets (max 200KB gzipped per chunk) and enforce in CI
Preload critical chunks (above-the-fold) and prefetch likely-next chunks
Analyze your bundles before and after every major dependency change
Avoid lazy-loading tiny components — the overhead of a network request is not worth it
Use Intersection Observer for below-the-fold component loading
Extract vendor chunks manually: react/react-dom, UI libraries, and charting libs
Monitor chunk loading performance with a tool like Lighthouse or WebPageTest
Consider streaming SSR (React 18) for progressive HTML delivery
$Blueprint — Engineering Documentation·Section ID: PERF-CS-01·Revision: 1.0