Bundle Analysis and Code Splitting
TL;DR
Ship less code. Bundle analysis tells you what’s actually in your output (the surprises are usually moment.js, lodash, polyfills, duplicated dependencies). Code splitting breaks the bundle into chunks that load on demand — by route, by component, by interaction. The senior workflow: measure with a visualizer → identify the largest unexpected chunks → split route-level first, then leaf-level, then defer the rest with dynamic imports.
Interview Q&A
Q: First question on a slow page — how do you find out what’s big?
A: Run a bundle analyzer. Per-bundler:
- Webpack:
webpack-bundle-analyzer— interactive treemap of every module. - Vite / Rollup:
rollup-plugin-visualizer— generates an HTML treemap. - Next.js:
@next/bundle-analyzer(wraps webpack-bundle-analyzer). - esbuild:
--metafile=meta.json+ esbuild’s analyzer.
// Vite
import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
plugins: [visualizer({ filename: "dist/stats.html", gzipSize: true, brotliSize: true })],
});
Run a prod build, open the HTML, look for the biggest blocks. The senior reflex: “huh, why is X.js 400 KB?” then dig.
Common surprises:
- Whole library imported when you only use 2 functions (lodash, date-fns, ramda). Use
import { debounce } from "lodash-es"notimport _ from "lodash". Better: native equivalents or a smaller lib. - Polyfills you don’t need (older
core-jsversions, IE 11 polyfills in a modern-only app). - Duplicate copies of the same library at different versions across packages — fix by aligning peer deps or using package-manager dedupe.
- Source maps shipped to prod by accident.
- Locale files (date-fns/all locales, full moment.js locale bundle).
- Large fonts inlined as data URLs.
Q: What is code splitting and what does it do?
A: Splitting the bundle into multiple chunks that load on demand. The user downloads only what’s needed for the current route/screen, not the entire app.
Without splitting: one giant main.js, every user pays for every feature.
With splitting: a small main.js + chunks per route/feature, loaded as needed.
Q: Route-based vs component-based splitting?
A:
Route-based — the most impactful for SPAs. Each route gets its own chunk:
// React Router
import { lazy } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
const Settings = lazy(() => import("./Settings"));
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
Webpack/Vite see the dynamic import() and emit Dashboard.[hash].js as a separate chunk.
// Vue Router — async components built-in
const routes = [
{ path: "/dashboard", component: () => import("./Dashboard.vue") },
{ path: "/settings", component: () => import("./Settings.vue") },
];
Component-based — split a specific heavy component that isn’t always shown:
const HeavyChart = lazy(() => import("./HeavyChart"));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<>
<button onClick={() => setShowChart(true)}>Show chart</button>
{showChart && <Suspense fallback={<Spinner />}><HeavyChart /></Suspense>}
</>
);
}
The chart’s code (and its dependencies) downloads only when the user clicks.
Interaction-based — split based on user action (open modal, expand panel). Same lazy() pattern, gated on a state.
Q: What gets put in a chunk vs the main bundle?
A: The bundler’s logic:
- Code reachable from the entry without a dynamic
import()= main bundle. - Code only reachable through a dynamic
import()= separate chunk. - Code shared between multiple chunks = a common chunk (vendor / shared).
Webpack’s splitChunks config tunes the boundaries:
// webpack.config.js
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: "vendor",
priority: 10,
},
common: {
minChunks: 2,
priority: 5,
reuseExistingChunk: true,
},
},
},
},
Vite uses Rollup’s defaults, which are usually fine; tune via build.rollupOptions.output.manualChunks if you want explicit control.
Q: Should you split vendor into its own chunk?
A: Conventional wisdom said yes — vendor changes less often than app code, so caching wins. Modern wisdom: not always.
- HTTP/2 multiplexing removed the cost of many parallel requests, so 50 small chunks isn’t worse than 5 big ones.
- Long-term caching matters less when content hashes invalidate the cache on every release anyway.
- Bundlers’ default heuristics (Rollup, Webpack 5) are usually good enough. Tweak only with data.
The right call: measure. If your vendor bundle is huge and changes rarely, splitting helps repeat visitors. If it’s small or your CDN cache hit rate is already high, don’t bother.
Q: How small is “small enough” per chunk?
A: Rough guidance (gzip):
- Initial bundle for above-the-fold rendering: aim for < 100 KB.
- Per-route chunk: < 50-100 KB.
- Total JS shipped on a landing page: < 200-300 KB.
These aren’t hard rules; budget against your audience’s network. A mobile-heavy product should aim lower; an internal admin tool over corporate fiber can be looser.
The dimension that matters most for INP/LCP is parse + compile time, not transfer time. A 200 KB JS bundle on a low-end phone can take 1s+ to parse — independent of network.
Q: What’s a long-task budget?
A: The browser flags any main-thread task > 50ms as a “long task.” Each one delays input handling. A reasonable budget: no long tasks in the first 5s after load, and no long task > 200ms ever.
Long tasks come from:
- Big script parsing/eval (initial bundle, hydration).
- Heavy synchronous work (computed values, rendering thousands of items).
- Sync third-party scripts.
Lighthouse’s “Total Blocking Time” (TBT) metric sums long-task work; aim for TBT < 200ms.
Q: What’s “tree-shakeable”?
A: A library or module from which the bundler can drop unused exports at build time. Requirements:
- ESM source (CJS is not statically analyzable in the same way).
sideEffects: falseinpackage.json(or a list of side-effectful files).- No “barrel files” re-exporting everything, depending on bundler maturity.
lodash-es is tree-shakeable; lodash (the CJS version) historically wasn’t. date-fns is tree-shakeable by default. Most modern libraries are. See 04_tree_shaking.md.
Gotchas / edge cases
import()inside a loop — bundler may produce N small chunks if the path is dynamic. Use a static prefix when possible:import('./pages/${name}')may not split as expected.- Polyfill bundles —
core-jscan inflate massively if yourbrowserslistincludes old browsers you don’t actually support. Auditbrowserslist. @importin CSS is not tree-shaken at the CSS level (different mechanism — see 04_tree_shaking.md for CSS purging).- Vendor chunk eviction — if vendor.js is 500 KB and changes every release because of one dep update, the cache benefit evaporates. Split big vendors into stable vs volatile.
React.lazyreturns a Promise — components that readdisplayNameordefaultPropsat render time will break on lazy-loaded ones.- Lazy load + SSR — Suspense + lazy works in React 18 SSR with streaming; older patterns need different handling. Next.js’
next/dynamicis the safe abstraction.
What a senior is expected to say
- “First step is always bundle analysis — visualizer, find the surprises, kill the easy wins (lodash, polyfills, dupes).”
- “Route-level splitting is the highest-leverage default. Then component-level for heavy below-the-fold widgets, and interaction-level for behind-a-click components.”
- “Vendor chunking is a tuning lever, not a default. Measure before tuning — HTTP/2 multiplexing and content hashes changed the math.”
- “Initial bundle budget < 100 KB gzip is a good north star; per-route < 50-100 KB. Parse/compile time on mobile matters more than transfer.”
- “Long Tasks (>50ms) block INP. Total Blocking Time is the lab proxy; aim for < 200ms.”
- “Tree-shaking needs ESM +
sideEffects: false. Importing whole libraries (lodash without-es) silently defeats it.”
Cross-references
- Tree shaking specifics: 04_tree_shaking.md
- Lazy loading patterns: 05_lazy_loading.md
- Build tools (where the splitting actually happens): ../09_build_tools/
- INP / long tasks: 01_core_web_vitals.md
Further reading
- web.dev — Reduce JavaScript payloads with code splitting: https://web.dev/articles/reduce-javascript-payloads-with-code-splitting
- webpack-bundle-analyzer: https://github.com/webpack-contrib/webpack-bundle-analyzer
- Rollup plugin visualizer: https://github.com/btd/rollup-plugin-visualizer
- Next.js bundle analyzer: https://www.npmjs.com/package/@next/bundle-analyzer