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 / Rollup —
build.rollupOptions.output.manualChunksgroupsnode_modulesby package name. - Webpack —
optimization.splitChunks.cacheGroups.vendorwithtest: /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
onMouseEnterof 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.
- Route splits —
import('./pages/Admin')andimport('./pages/Reports')removed 380 KB from the storefront critical path. - Locale splitting —
import(`./locales/${locale}.json`)with explicit glob in Vite config; only active language ships. - Chart lazy load — Recharts imported inside the
analytics tab's
useEffectafter first paint. - Vendor chunk —
react+react-domisolated; cache hit rate on repeat visits rose from 41% to 78%. - 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 withPromise.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.saveDatabefore 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
- Vite fundamentals explained — Rollup output, manualChunks, and production builds
- Core Web Vitals explained — LCP, INP, and how JS weight affects scores
- React fundamentals explained — component model, lazy, and Suspense basics
- Webpack fundamentals explained — splitChunks, entry points, and Module Federation