Network Security
Code Splitting Strategies
Learn how to break a monolithic JavaScript bundle into on-demand chunks — reducing initial load time dramatically without sacrificing functionality — and build the splitting discipline that keeps bundles lean as your application grows.
The Bundle That Ate the Loading Screen
When Figma's engineering team published a breakdown of their web editor bundle in 2019, the canvas rendering engine alone was over 700KB gzipped. Loading the app on a mid-range laptop over home broadband took north of eight seconds before anything was interactive. Their response was not to rewrite the engine — it was to split the bundle so aggressively that the critical path to "editor is ready" shrunk from 700KB+ to under 200KB, with the rest streaming in the background while the user was already working.
Code splitting is the practice of dividing your JavaScript bundle into smaller chunks that load on demand rather than all at once. The total code hasn't changed. But the browser downloads what the user actually needs right now — and defers everything else until it's required. The difference between "load everything upfront" and "load what you need when you need it" is the difference between an 8-second loading screen and a 1.4-second Time to Interactive.
The PixelForge Performance team inherited a 680KB gzipped initial bundle. Users visiting the login page were downloading the full canvas editor, the admin panel, the billing dashboard, and the onboarding wizard before seeing a single interactive element. This lesson documents exactly what they did about it.
How Code Splitting Works Under the Hood
Code splitting is a coordination between your bundler and the browser. Vite, webpack, and Rollup analyse your module graph at build time. When they encounter a dynamic import — import('./HeavyModule') — they treat it as a split point. Everything reachable only through that dynamic import gets emitted as a separate chunk file. At runtime, when the dynamic import executes, the browser fetches that chunk over the network the first time, then from cache on subsequent visits.
Concept Anatomy — Code Splitting
Concept
Code Splitting
Type
Build-time optimisation + runtime dynamic loading
Mechanism
Dynamic import() → bundler emits a separate chunk file
Tools
Vite (automatic), webpack SplitChunksPlugin, React.lazy(), Suspense
Failure Mode
Too many tiny chunks → sequential fetch waterfall; too few → initial bundle stays large
A dynamic import returns a Promise. The browser handles it asynchronously — fetches the chunk, parses it, evaluates it, and resolves the promise with the module's exports. This is why every code-split boundary needs a loading state: the user sees something between "chunk requested" and "chunk ready." React's Suspense boundary is the standard mechanism for managing that loading state without per-component loading logic scattered everywhere.
The Three Splitting Strategies — Route, Component, Vendor
Not all split points are equal. Route-level splits work differently from component-level splits, which work differently from vendor chunk separation. Understanding when to apply each strategy is what separates disciplined splitting from adding dynamic imports everywhere and creating a waterfall of 40 sequential network requests on first load.
| Strategy | What It Does | PixelForge Use Case |
|---|---|---|
| Route-level splitting | Each route loads its own chunk. Users only download code for pages they actually visit. The router is the natural split boundary — navigation is an explicit user-initiated event. | Splitting /editor, /dashboard, /settings, and /billing into separate chunks reduced the login page download from 680KB to 84KB gzipped. |
| Component-level splitting | A heavy component — PDF viewer, rich text editor, chart library — loads its chunk only when it mounts. Useful for rarely-used features, conditionally shown modals, or content below the fold. | The export-to-PDF modal uses pdf-lib (98KB gzipped). It loads only when the modal opens. 92% of sessions never trigger the download. |
| Vendor chunk splitting | Third-party libraries (React, ReactDOM, lodash) are bundled into a separate vendor chunk that rarely changes. Since it's content-hashed, it stays cached across deploys — users only re-download it when a library version actually changes. | Separating React + ReactDOM into a shared vendor chunk (43KB gzipped) means app deploys don't invalidate the React cache. Returning users have it cached for weeks. |
React Suspense — The Loading State Coordinator for Split Boundaries
React's Suspense wraps lazily-loaded components and renders a fallback while the chunk fetches. Without it, a dynamic import either flickers blank or requires manual loading state in every component. Suspense centralises that concern — one boundary can cover multiple lazy components. The fallback should be a skeleton that approximates the loaded layout, not a spinner. Skeletons reduce perceived loading time by giving users a visual preview of what's coming, even when actual load time is unchanged.
Route-Level Splitting in Practice
Route splitting is the highest-leverage technique because routes are natural, user-initiated boundaries. A user navigating to /settings has already decided to go there — the 200–300ms chunk fetch is invisible inside the normal navigation latency. But the 200KB of settings code that was previously loading on every page visit is now deferred entirely for users who never open settings.
// WHAT: PixelForge router — route-level splitting with React.lazy + Suspense
// Before: all routes imported statically → 680KB initial bundle
// After: each route is a separate chunk → 84KB initial bundle
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// Each lazy() call creates a separate chunk at build time.
// Vite emits: chunk-dashboard.a8f3.js (92KB gzipped)
// chunk-editor.b2c1.js (188KB gzipped)
// chunk-settings.d9e7.js (34KB gzipped)
// chunk-billing.c3a5.js (28KB gzipped)
// chunk-onboarding.f2d4.js (41KB gzipped)
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const EditorPage = lazy(() => import('./pages/EditorPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const BillingPage = lazy(() => import('./pages/BillingPage'));
const OnboardingPage = lazy(() => import('./pages/OnboardingPage'));
// Skeleton fallback — approximates the loaded layout
function PageSkeleton() {
return (
<div style={{ padding: '32px', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ height: 32, width: '40%', background: '#e2e8f0',
borderRadius: 8, marginBottom: 24 }} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}>
{[...Array(6)].map((_, i) => (
<div key={i} style={{ height: 140, background: '#e2e8f0',
borderRadius: 12 }} />
))}
</div>
</div>
);
}
export function AppRouter() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/editor/:canvasId" element={<EditorPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
</Routes>
</Suspense>
);
}
// Prefetch on hover — loads the next likely chunk before the user clicks.
// Fire-and-forget dynamic import: the browser caches the chunk response
// so when the route loads, the chunk is already available.
function NavLink({ to, label }) {
const prefetch = () => {
if (to === '/editor') import('./pages/EditorPage');
if (to === '/settings') import('./pages/SettingsPage');
};
return <a href={to} onMouseEnter={prefetch}>{label}</a>;
}
What just happened?
Four things made this split effective. First, React.lazy() wrapping each page import signals Vite to treat it as a split boundary — the chunk file is emitted automatically, no config required. Second, the vendor chunk separation means React and ReactDOM (43KB) survive across every app deploy unchanged — a returning user from last week already has it cached and skips the download. Third, the hover-prefetch pattern fires a fire-and-forget dynamic import during the 200–800ms gap between hovering a link and clicking it — the editor chunk arrives before the user has finished clicking, making the route transition feel instant even on a 380ms fetch. Fourth, the skeleton fallback is layout-shaped — six placeholder cards rather than a spinner — which reduces perceptual loading time even though actual load time is identical.
Try this: Remove the lazy() wrappers and revert to static imports. Run the Vite build and compare the single bundle size against the split output above. Then throttle to Fast 3G in DevTools and compare TTI for a user visiting only the dashboard — the split saves them 461KB of JavaScript they would never use.
Component-Level Splitting — Inside a Route
Route splitting handles the gross structure. But within a single route, some components carry heavy third-party libraries used by only a fraction of sessions. The PixelForge editor route is 188KB gzipped after route splitting — but inside that chunk lives the export-to-PDF feature, which bundles pdf-lib (98KB gzipped). Only 8% of sessions ever open the export modal. The other 92% download 98KB of PDF library they will never use on that visit.
Component-level splitting moves that 98KB behind a conditional boundary — it only loads when the user actively opens the modal. This is the correct model for any feature that is genuinely optional during a session: load the code only when the user has proven they intend to use the feature.
// WHAT: PixelForge ExportModal — component-level splitting
// pdf-lib (98KB gzipped) loads only when the modal opens
// 92% of sessions never trigger this download
import { lazy, Suspense, useState } from 'react';
// A static import here would include pdf-lib in the editor chunk always:
// import ExportModal from './ExportModal'; ← adds 98KB to every session
// Lazy import — pdf-lib enters the chunk graph only when ExportModal is requested
const ExportModal = lazy(() => import('./ExportModal'));
function ExportButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button
onClick={() => setIsOpen(true)}
className="toolbar-btn"
>
Export to PDF
</button>
{/* Suspense scoped to the modal — fallback renders inside modal container */}
{isOpen && (
<Suspense fallback={<ModalSkeleton />}>
<ExportModal onClose={() => setIsOpen(false)} />
</Suspense>
)}
</>
);
}
function ModalSkeleton() {
return (
<div style={{
position: 'fixed', inset: 0,
background: 'rgba(15,23,42,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 9999
}}>
<div style={{
background: '#fff', borderRadius: 16,
padding: '32px', width: 480, maxWidth: '90vw'
}}>
<div style={{ height: 24, width: '55%', background: '#e2e8f0',
borderRadius: 6, marginBottom: 20 }} />
<div style={{ height: 100, background: '#f8fafc',
borderRadius: 10, marginBottom: 16 }} />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
<div style={{ height: 38, width: 80, background: '#e2e8f0', borderRadius: 8 }} />
<div style={{ height: 38, width: 120, background: '#7c3aed', borderRadius: 8 }} />
</div>
</div>
</div>
);
}
// Inside ExportModal.tsx the heavy library is a regular static import.
// It only runs when the ExportModal chunk is evaluated — which only
// happens when the modal is opened for the first time in a session.
// import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
What just happened?
The critical insight is that static imports are evaluated unconditionally at module parse time. The moment the editor chunk loads, every static import inside it runs — including pdf-lib's full initialisation code. By wrapping the ExportModal in React.lazy(), the pdf-lib import inside ExportModal.tsx is now behind a dynamic boundary — it only runs when the chunk is first requested. Inside ExportModal.tsx itself, the import is still static and clean. The split is at the boundary, not scattered through the code. The ModalSkeleton renders immediately — the user sees a placeholder modal shape before the chunk arrives, which anchors their attention and prevents the feeling of a broken click.
Try this: Apply this same pattern to the PixelForge rich text comment editor — it uses a Tiptap extension bundle (67KB gzipped) only when a comment thread is open. 71% of editor sessions never open a comment thread. The same lazy-on-mount pattern saves another 67KB for those sessions.
Vendor Chunk Strategy — Making Cache Work Across Deploys
Every time you deploy a new version of PixelForge, the content hash of your application chunks changes. Users must download the new chunks. But React itself hasn't changed. ReactDOM hasn't changed. Lodash hasn't changed. If those libraries are bundled into your application chunks, every deploy forces users to re-download them — even though they're identical to the previous version.
Vendor chunk separation solves this by emitting all stable third-party libraries into a separate chunk with its own content hash. That hash only changes when a library version changes. A user who visited PixelForge three weeks ago has the vendor chunk cached with a one-year max-age. When a new PixelForge feature deploys, the vendor chunk hash hasn't changed — the browser serves it from cache instantly, saving the full 43KB download on every returning user's session.
App chunks — short-lived cache
Content hash changes on every deploy. Cache must be busted on deploy. Use Cache-Control: public, max-age=31536000, immutable — the hash in the filename guarantees the URL changes on rebuild, so long cache is safe.
Invalidated: every feature deploy.
Vendor chunks — long-lived cache
Content hash changes only when library versions change — typically monthly or quarterly. React, ReactDOM, lodash, date-fns, and other stable dependencies cached for a full year are served from the CDN edge for most returning users without a single network byte.
Invalidated: library version bump only.
Vite manualChunks config
Vite's build.rollupOptions.output.manualChunks lets you control which modules go into which chunk. Group React ecosystem packages together. Group heavy third-party UI libraries together. Keep rarely-updated low-churn code in stable chunks.
PixelForge groups: react-core, radix-ui, date-utils.
The chunk granularity trap
Too many small chunks create a different problem: the browser must make many sequential or parallel requests at navigation time. HTTP/2 multiplexing reduces but doesn't eliminate this cost. The sweet spot for chunk count on a route is typically 3–8 files. More than 15 chunks on a single route navigation is a signal the splitting is too granular.
Rule: measure waterfall depth before and after any split.
Knowing What to Split — The Bundle Analyser
Code splitting decisions should be data-driven, not intuition-driven. The question "what should I split?" is answered by the bundle analyser — a treemap visualisation of exactly which modules contribute how many bytes to which chunks. Every front end team should run this analysis before the first splitting decision and after each major dependency addition.
For Vite projects, rollup-plugin-visualizer generates an interactive treemap as part of the build. For webpack, webpack-bundle-analyzer serves the same purpose. Both show the same information: every module in your bundle, its size, and which chunk it lives in. In five minutes of exploring the treemap, the PixelForge Performance team identified three libraries that accounted for 41% of the initial bundle — none of which needed to be in the initial chunk at all.
The PixelForge Bundle Analysis Process — Before Every Splitting Decision
vite build --mode analyze with rollup-plugin-visualizer configured. Open the output HTML file. Every rectangle is a module — size proportional to its byte contribution.
lodash in both the dashboard chunk and the editor chunk. Duplicates should be extracted into a shared chunk using manualChunks — the user should download it once, not twice.
The PixelForge bundle analysis findings — initial audit: The treemap revealed that @tiptap/react (comment editor, 67KB), pdf-lib (export feature, 98KB), and recharts (analytics dashboard, 88KB) were all in the initial chunk despite none being needed until specific user actions. The three splits — route split for recharts, component split for pdf-lib and Tiptap — removed 253KB from the initial bundle in a single sprint. Combined with the route-level splits already in place, the initial bundle dropped from 680KB to 84KB gzipped over two sprints of focused work.
The Premature Splitting Anti-pattern
Adding dynamic imports to every component because "splitting is good" creates a different problem: a waterfall of sequential chunk fetches at navigation time. If navigating to /dashboard triggers 22 sequential chunk fetches, each adding 50–200ms of latency, the total navigation time is worse than a single larger chunk would have been. HTTP/2 handles parallel requests better than HTTP/1.1, but there are still limits. Split at natural boundaries — routes, conditionally-rendered features, below-fold content — not at every component file. Every split boundary is a network round-trip. Budget them deliberately.
Quiz
1. PixelForge's login page is downloading 680KB of JavaScript including the canvas editor, admin panel, and billing dashboard — none of which users need until they navigate there. What is the most direct fix?
2. PixelForge deploys a new feature every Tuesday. After each deploy, returning users must re-download React and ReactDOM (43KB) even though neither library changed. What configuration prevents this unnecessary re-download?
3. PixelForge has route-split its editor page, but users notice a visible delay when clicking the "Open Editor" link while the 188KB chunk downloads. How should the team make that transition feel instant without removing the split?