Guide

Code splitting explained

Harbor Commerce's storefront felt fast in staging — until production analytics showed mobile shoppers abandoning checkout before the payment form rendered. Lighthouse blamed a 2.1 MB initial JavaScript payload: every visitor downloaded the full admin analytics suite, charting library, and merchant reporting tools even though 94% of sessions never left the catalog and cart. The fix was not micro-optimizing individual functions; it was code splitting — breaking one monolithic bundle into smaller chunks loaded on demand. After route-based splits for /admin and /reports, dynamic imports for the heavyweight chart module, and a dedicated vendor chunk for React, the checkout path's first-load script dropped to 312 KB gzip. Time to Interactive improved by 1.8 seconds on mid-tier Android; conversion recovered. Code splitting is the practice of delivering only the JavaScript a user needs for the current view, then fetching additional chunks when navigation or interaction requires them. This guide covers the dynamic import() API, route and component splitting patterns, vendor chunk strategy, preload hints, framework integration with React and Next.js, a Harbor Commerce refactor walkthrough, a strategy decision table, common pitfalls, and a production checklist.

Why bundle size is a product problem

Modern SPAs ship a dependency graph: framework runtime, state management, UI kit, date libraries, charting, analytics SDKs. Tree shaking removes dead exports, but it cannot remove code the bundler believes might execute on every route. A single entry point that imports every page means every user pays the parse-and-compile cost of code they may never run.

That cost shows up in Core Web Vitals: large scripts delay First Contentful Paint on slow networks, block the main thread during hydration, and push Interaction to Next Paint (INP) higher when users tap before secondary chunks arrive. For content and commerce sites, shaving hundreds of kilobytes from the critical path often beats adding a CDN tier. Code splitting is the primary lever once obvious dead-code removal is done.

The dynamic import() primitive

ECMAScript's import() returns a Promise that resolves to a module namespace object. Bundlers (Rollup, esbuild, Webpack) detect static string literals inside import() calls and emit separate output files — one chunk per dynamic import site (or merged according to splitChunks rules).

// charts.ts is NOT in the initial bundle
const { renderRevenueChart } = await import('./charts/revenue');

export async function showDashboard(container: HTMLElement) {
  const mod = await import('./charts/revenue');
  mod.renderRevenueChart(container, data);
}

Key properties:

  • Lazy execution — the chunk downloads only when the import runs, not at HTML parse time.
  • Cacheable artifacts — each chunk gets a content hash filename; long-cache headers apply without invalidating unrelated routes.
  • Parallel fetches — multiple dynamic imports can load concurrently; the browser respects HTTP/2 multiplexing.

Avoid dynamic expressions in the import path (import(`./locales/${lang}`)) unless your bundler is configured for context modules; otherwise chunks may be missed or every file bundled defensively.

Route-based splitting

The highest-leverage split is usually per route: home, product detail, checkout, and admin each become separate async chunks. Users on the catalog never download admin-only dependencies.

React

Pair React.lazy with Suspense for component-level boundaries, or use your router's lazy route API:

const AdminDashboard = React.lazy(() => import('./AdminDashboard'));

function App() {
  return (
    <Suspense fallback={<RouteSkeleton />}>
      <Routes>
        <Route path="/admin/*" element={<AdminDashboard />} />
      </Routes>
    </Suspense>
  );
}

Next.js App Router

Server Components are already split at the module graph level; for client islands, use next/dynamic with ssr: false when a library touches window only after interaction (maps, rich editors). See React Server Components explained for how server boundaries reduce client JS by default.

Vue / Svelte / Angular

Vue Router supports () => import('./views/Admin.vue'); SvelteKit and Angular lazy-load modules via route loadChildren. The pattern is identical: defer the import until the route matches.

Component-level and feature splitting

Not every heavy widget deserves its own route. Split at the component boundary when:

  • The feature sits below the fold or behind a tab.
  • It pulls a large third-party SDK (PDF viewer, video player, Monaco editor).
  • It is gated by authentication or a paid tier.
  • It runs only after explicit user action (export CSV, open analytics drawer).

Harbor Commerce moved its markdown preview editor behind a “Edit description” click: the 180 KB editor chunk loads on first edit, not on every product page view. Pair splits with a lightweight skeleton so layout does not jump when the chunk arrives — the same principle as React Suspense fallbacks.

Vendor chunks and cache stability

Application code changes every deploy; React and lodash change rarely. If they share one bundle, a one-line app fix invalidates the entire cached script. Extract stable dependencies into a vendor chunk:

  • Vite / Rollupbuild.rollupOptions.output.manualChunks groups node_modules by package name.
  • Webpackoptimization.splitChunks.cacheGroups.vendor with test: /node_modules/. See Webpack fundamentals for splitChunks tuning.

Balance granularity: ten tiny vendor files increase HTTP overhead; two or three chunks (framework, UI kit, everything else) is a common sweet spot. Monitor whether HTTP/3 and your CDN collapse the difference on repeat visits.

Preload, prefetch, and navigation hints

Splitting trades initial size for later round-trips. Mitigate perceived latency:

  • <link rel="modulepreload"> — tells the browser to fetch a known-upcoming chunk during idle time on the current page (e.g., preload checkout chunk when the cart drawer opens).
  • Router prefetch — Next.js and Remix prefetch linked routes on viewport intersection; React Router can prefetch route modules on onMouseEnter of nav links.
  • Avoid over-prefetching — prefetching every nav link on a 40-route admin panel defeats the purpose of splitting.

Measure with the Network panel: a successful split shows the main bundle on document load and route chunks on navigation, with prefetch rows marked “Preflight” or initiated by the router.

SSR, hydration, and RSC interactions

Code splitting on the server differs from the client. In classic SSR, the server must know which chunks the requested route needs so it can emit matching <script> tags; frameworks handle this via build manifests. Hydration requires the client to fetch the same chunks the server used — version skew between deploys causes “Loading chunk failed” errors unless you version assets atomically.

With React Server Components, most UI never ships as client JS at all; client splits apply only to interactive islands. Read SSR, CSR, SSG and ISR explained for when full client bundles still dominate (highly interactive dashboards vs static marketing pages).

Worked example: Harbor Commerce dashboard refactor

Starting state: single Vite entry, 2.1 MB raw / 640 KB gzip initial JS. Breakdown: React 142 KB, Recharts 88 KB, admin tables 210 KB, i18n locale packs for 12 languages bundled together.

  1. Route splitsimport('./pages/Admin') and import('./pages/Reports') removed 380 KB from the storefront critical path.
  2. Locale splittingimport(`./locales/${locale}.json`) with explicit glob in Vite config; only active language ships.
  3. Chart lazy load — Recharts imported inside the analytics tab's useEffect after first paint.
  4. Vendor chunkreact + react-dom isolated; cache hit rate on repeat visits rose from 41% to 78%.
  5. Preload on intent — “View reports” button triggers import('./pages/Reports') on hover.

Result: checkout route 312 KB gzip initial, admin first visit +290 KB async (acceptable for authenticated staff). Mobile conversion +6.2% over two weeks A/B. Build pipeline unchanged beyond Vite config and router lazy definitions.

Strategy decision table

Scenario Recommended split Alternative
Multi-section marketing site Per-route dynamic import SSG with minimal client JS (Astro)
Authenticated admin behind login Route split + gate import behind auth check Separate subdomain / admin app
Heavy chart below fold Component dynamic import on scroll (Intersection Observer) Server-render static image placeholder
Third-party widget (maps, payments) Dynamic import on user gesture iframe embed with lazy src
Frequent deploys, stable dependencies Vendor chunk + content-hashed app chunks Module Federation for micro-frontends
12+ locale files Per-locale async JSON/TS modules CDN edge locale negotiation
Mobile-first commerce checkout Aggressive route split; zero admin deps on cart Progressive enhancement without SPA

Common pitfalls

  • Waterfall imports. Sequential await import() chains in one effect add latency; parallelize independent chunks with Promise.all.
  • Splitting too fine. Fifty 5 KB chunks increase connection overhead; merge rarely used siblings.
  • Missing loading UI. Blank screens during chunk fetch hurt UX more than a slightly larger initial bundle.
  • Stale chunk errors after deploy. Users with old HTML requesting deleted hashed files need graceful reload prompts or service-worker versioning.
  • Duplicated dependencies. Two chunks each bundling their own copy of lodash inflate total bytes; dedupe via splitChunks or resolve.dedupe.
  • SSR/client mismatch. Client-only libraries imported in shared modules break SSR unless guarded or dynamically imported with ssr: false.
  • Measuring gzip only. Parse and compile time scales with uncompressed size; audit with Chrome Performance and coverage:report.
  • Prefetch on metered networks. Respect navigator.connection.saveData before aggressive prefetch.

Production checklist

  • Baseline initial JS size and Time to Interactive on 4G throttling.
  • Identify top five modules by parsed size (rollup-plugin-visualizer or Webpack Bundle Analyzer).
  • Split routes that less than 20% of sessions visit.
  • Extract React/framework into a long-cache vendor chunk.
  • Add Suspense or skeleton fallbacks for every lazy boundary.
  • Prefetch high-probability next routes on link hover or cart open.
  • Verify SSR manifest includes correct script tags per route.
  • Test post-deploy navigation after a simulated old-tab session.
  • Confirm no duplicate copies of large libraries across chunks.
  • Re-run Lighthouse and compare INP on checkout after each split.

Key takeaways

  • Code splitting delivers JavaScript in smaller chunks loaded on demand, shrinking the critical path for each route.
  • Route-based splits usually yield the largest wins; component splits handle heavy widgets behind interaction.
  • Vendor chunks improve cache hit rates when application code changes frequently.
  • Preload and router prefetch recover latency introduced by async loading — but only for likely next steps.
  • Pair every lazy boundary with a loading state and monitor chunk errors after deploys.

Related reading