Topics
v6.0.0
On this page · sections

Code Splitting Strategies

Code splitting breaks the application bundle into smaller chunks loaded on demand — reducing initial parse/compile/execute cost and improving LCP and TTFI.

StrategySplit boundaryLoad trigger
Route-levelPage/route componentsNavigation
Component-levelHeavy widgets (charts, editors)User action or viewport
Vendor splitnode_modulesShared cache chunk
Dynamic importAny moduleimport() call

Route-Level Splitting

Definition

Each route becomes its own async chunk — only the landing route loads on first visit.

import { lazy, Suspense } from 'react'; import { Routes, Route } from 'react-router-dom'; const Dashboard = lazy(() => import('./pages/Dashboard')); const Settings = lazy(() => import('./pages/Settings')); const Analytics = lazy(() => import('./pages/Analytics')); export function AppRoutes() { return ( <Suspense fallback={<RouteSkeleton />}> <Routes> <Route path="/" element={<Home />} /> <Route path="/dashboard" element={<Dashboard />} /> <Route path="/settings" element={<Settings />} /> <Route path="/analytics" element={<Analytics />} /> </Routes> </Suspense> ); }

Next.js App Router splits automatically per route segment — each page.tsx is a separate chunk. Keep shared layout in parent — don’t duplicate heavy imports in every page.

Prefetch adjacent routes on hover (<Link prefetch>) or after idle. Critical path = landing route only — audit with Coverage panel.

Interview: Route split = one chunk per route. Initial bundle should not include dashboard/analytics code.

Component-Level Dynamic Import

Definition

Split heavy components not needed on first paint — charts, rich text editors, PDF viewers, maps.

import { lazy, Suspense, useState } from 'react'; const ChartPanel = lazy(() => import('./ChartPanel')); function DashboardShell() { const [showChart, setShowChart] = useState(false); return ( <div> <KpiCards /> {/* lightweight — in main bundle */} <button onClick={() => setShowChart(true)}>Show chart</button> {showChart && ( <Suspense fallback={<ChartSkeleton />}> <ChartPanel /> </Suspense> )} </div> ); }

Patterns:

  • Interaction-triggered — load on button click
  • Viewport-triggered — IntersectionObserver loads when near viewport
  • Named exports — lazy(() => import('./X').then(m => ({ default: m.NamedExport })))

Avoid waterfall: don’t chain import() inside useEffect without parallelizing independent chunks.

Interview: Split at the boundary where user might never need the code. Show skeleton during load.

Vendor Chunk Strategy

Definition

Bundlers (Vite, webpack, Turbopack) extract node_modules into vendor chunks via splitChunks / Rollup manual chunks.

// vite.config.ts — conceptual export default { build: { rollupOptions: { output: { manualChunks: { 'vendor-react': ['react', 'react-dom'], 'vendor-charts': ['recharts', 'd3'], }, }, }, }, };

Goals:

  • Stable vendor hash — app code changes don’t invalidate react chunk cache
  • Separate heavy libs — chart library not in main vendor if only analytics route uses it
  • Avoid over-splitting — too many small chunks = HTTP overhead (mitigated by H2)

Move route-only deps into route chunks via dynamic import — don’t import chart library in shared layout.

Interview: Vendor split for cache stability. Route-only libs belong in route chunks, not shared vendor.

Measure and Validate Splits

Definition

Verify splits actually help — not just create more requests.

// Webpack/Vite bundle analyzer — inspect chunk sizes // Chrome Coverage: Ctrl+Shift+P → Coverage → reload // Lighthouse → unused JavaScript audit window.addEventListener('load', () => { const resources = performance.getEntriesByType('resource'); const jsChunks = resources.filter((r) => r.name.endsWith('.js')); jsChunks.forEach((r) => { console.log(r.name, (r.transferSize / 1024).toFixed(1) + ' KB'); }); });

Targets:

  • Initial JS < 200KB gzip for content pages (guideline)
  • < 30% unused bytes on first load (Coverage panel)
  • Route chunks load < 500ms on 3G after navigation

Compare before/after: TBT, LCP, parse/compile time in Performance panel. Splitting that adds waterfall without reducing initial bytes is wasted complexity.

Interview: Measure initial bundle size and unused bytes. Split should reduce first-load JS, not just chunk count.

Deployment and Caching Policy

Definition

Staff-level split + cache strategy:

main.[hash].js → short cache (app code changes often) vendor-react.[hash] → long cache (immutable hash) route-dashboard.[hash] → long cache per route
Cache-Control: public, max-age=31536000, immutable (hashed assets) Cache-Control: no-cache (index.html / SSR shell)

Deployment rules:

  1. Content-hash filenames — safe long cache
  2. Single vendor chunk for stable deps — avoid hash churn on unrelated changes
  3. Modulepreload entry + vendor in HTML
  4. Rollback — old chunks served until CDN TTL if hash changes
  5. RUM — track chunk load failures (404 on stale chunk after deploy)

Feature flags: load experimental chunks only when flag enabled — don’t bloat main bundle. CDN HTTP/2 multiplexing reduces split penalty but bandwidth is still finite.

Interview: Hashed filenames + immutable cache for chunks. HTML/shell is no-cache. Monitor 404 on stale chunks after deploy.

Senior Interview Summary

Mental model

Initial load → landing route + shared vendor only Navigation → lazy route chunks on demand Heavy widgets → dynamic import on interaction/viewport Cache → stable vendor hash, long immutable TTL

Practical rules

  • Route split by default in SPA/router frameworks
  • Dynamic import for charts, editors, maps
  • Keep route-only deps out of shared layout imports
  • Validate with Coverage panel — target low unused bytes
  • Prefetch likely next route on hover/idle

What to say in interviews

“I’d route-split with React.lazy so the landing page doesn’t download dashboard code. Heavy chart libraries load via dynamic import when the user opens analytics. Vendor chunk separates react from app code for cache stability. I’d verify with Coverage that initial unused JS is under 30% and initial gzip is under 200KB.”

Common traps

  • Importing heavy lib in shared layout — defeats route split
  • Over-splitting into dozens of tiny chunks
  • Splitting without measuring — adds waterfall, no initial savings
  • Forgetting Suspense fallback — layout shift on chunk load

Last updated on