Network Security Lesson 12 – Types of Firewalls | Dataplexa
Front End Engineering · Lesson 12

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>;
}
// Vite build output after route splitting: dist/assets/index-a3f8.js 84.2 KB ← initial bundle (app shell + router) dist/assets/vendor-react-e4f5.js 43.1 KB ← React + ReactDOM (shared, long-cached) dist/assets/chunk-dashboard-b2c1.js 91.8 KB ← loaded on /dashboard visit dist/assets/chunk-editor-d9e7.js 187.4 KB ← loaded on /editor visit dist/assets/chunk-settings-c3a5.js 33.6 KB ← loaded on /settings visit dist/assets/chunk-billing-f2d4.js 27.9 KB ← loaded on /billing visit dist/assets/chunk-onboarding-a1b2.js 40.7 KB ← loaded on /onboarding visit Total JS: 508.7 KB across all routes Initial download (login → dashboard): 84.2 + 43.1 + 91.8 = 219.1 KB // Browser Network — user visits /dashboard then hovers "Editor" link: GET /assets/index-a3f8.js 200 OK | 84KB | 210ms GET /assets/vendor-react-e4f5.js 200 OK | 43KB | 180ms GET /assets/chunk-dashboard-b2c1.js 200 OK | 92KB | 230ms [user hovers "Editor" nav link — prefetch fires] GET /assets/chunk-editor-d9e7.js 200 OK | 187KB | 380ms ← background fetch [user clicks "Editor" 600ms later] → Route transition: instant — chunk already cached by prefetch // Returning user next day (all chunks cached by browser): GET /assets/index-a3f8.js 304 Not Modified | <1ms GET /assets/vendor-react-e4f5.js 304 Not Modified | <1ms GET /assets/chunk-dashboard-b2c1.js 304 Not Modified | <1ms → Time to Interactive: 0.6s (purely from cache)

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';
// Vite build — editor chunks before and after component splitting: BEFORE component split: chunk-editor-d9e7.js 187.4 KB ← includes pdf-lib AFTER component split: chunk-editor-d9e7.js 89.2 KB ← pdf-lib removed chunk-export-modal-a2b3.js 98.1 KB ← pdf-lib + ExportModal // Network — typical session (user edits canvas, never exports): GET /assets/chunk-editor-d9e7.js 200 | 89KB | 190ms → chunk-export-modal-a2b3.js never requested → pdf-lib never downloaded // Network — session where user clicks "Export to PDF": GET /assets/chunk-editor-d9e7.js 200 | 89KB | 190ms [user clicks Export button at 4m 32s into session] GET /assets/chunk-export-modal-a2b3.js 200 | 98KB | 220ms ModalSkeleton renders for 220ms while chunk fetches ExportModal mounts → pdf-lib initialised → canvas serialised to PDF (310ms) Download link shown // Session-level savings: Sessions that never export (92%): save 98.1KB per session Editor route p75 TTI before: 2.1s Editor route p75 TTI after: 1.7s (−400ms) Monthly bandwidth saved: ~14GB across all user sessions

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

1
Generate the treemap — Run 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.
2
Identify the largest rectangles in the initial chunk — Sort by size. The top 5 modules by byte count are your candidates. Ask of each: does this need to be in the initial chunk, or is it only used on a specific route or feature?
3
Check import frequency and usage context — A library appearing in the initial chunk because one page uses it is a route-split candidate. A library in the initial chunk because it's used by a modal that 10% of users open is a component-split candidate.
4
Look for duplicates — The treemap reveals if a library appears in multiple chunks simultaneously — for example, 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.
5
Validate with metrics, not instinct — After each split, re-run Lighthouse and check the RUM data. Initial bundle size, LCP, and TBT should improve measurably. If they don't, the split point was wrong — the module wasn't actually in the critical path, or the chunk waterfall introduced more latency than it saved.

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?

Up Next
Lazy Loading
PixelForge's Performance team extends on-demand loading beyond JavaScript — applying lazy loading to images, fonts, and below-fold components to cut initial page weight and improve LCP on the project dashboard by a further 600ms.