Bundle Analysis and Code Splitting
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.
First question on a slow page — how do you find out what’s big?
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.
What is code splitting and what does it do?
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.
Route-based vs component-based splitting?
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 and interaction splitting
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.
What gets put in a chunk vs the main bundle?
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.
Should you split vendor into its own chunk?
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.
How small is “small enough” per chunk?
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.
What’s a long-task budget?
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.
What’s “tree-shakeable”?
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 Tree Shaking — How It Actually Works, What Defeats It.
Gotchas
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 Tree Shaking — How It Actually Works, What Defeats It 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.
Related
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
Interview angle 6
- “Where do you start on a bundle problem?” - the analyser, always. Look for the surprises rather than the things you expect, and the easy wins are usually the same three: a whole utility library imported for one function, polyfills for browsers you no longer support, and the same dependency bundled twice at different versions.
- “What splitting gives the most benefit?” - route level, by a wide margin, because a user pays for one route rather than the whole application. Then component level for heavy below-the-fold widgets, then interaction level for anything behind a click.
- “Should you configure vendor chunks?” - only after measuring. It was worth much more when connections were scarce and vendor code was stable between deploys; with HTTP/2 multiplexing and content-hashed filenames the arithmetic changed, and a hand-tuned split is often slightly worse.
- “What budget would you set?” - roughly 100 KB gzipped for the initial bundle and 50 to 100 KB per route as a north star. And measure parse and compile time, not just transfer: on a mid-range phone, executing the JavaScript costs more than downloading it.
- “How does bundle size connect to INP?” - through long tasks. Anything over 50ms blocks input, and a large bundle means long parse, compile and hydration tasks. Total Blocking Time is the lab proxy, since the lab cannot measure INP itself.
- “What silently defeats tree shaking?” - a default import of a CommonJS
library.
import _ from "lodash"ships all of it no matter what you use; named imports from an ESM build do not.