Bundle Splitting and Tree Shaking
Every Byte Has a Cost — Three Times Over
Here's something most developers don't fully internalize: JavaScript is the most expensive asset you ship, byte for byte. A 200KB image and a 200KB JS bundle are not equal. The image gets decoded on a background thread and composited by the GPU. The JavaScript goes through three sequential, main-thread-blocking stages:
- Download — network transfer, affected by bandwidth and latency
- Parse — the engine reads source text and builds an AST. V8's scanner and parser run on the main thread (with some off-thread preparse)
- Execute — the code runs: top-level statements, module initialization, side effects
On a mid-range Android phone over 4G, 200KB of JavaScript (gzipped) can easily take 2-3 seconds end-to-end. That's 2-3 seconds where the main thread is busy and the user is staring at a frozen screen.
Think of JavaScript bundles like luggage at an airport. Download is the flight — getting the suitcase from A to B. Parse is customs — opening every bag and inspecting every item. Execute is the traveler actually wearing the clothes and using the items. Images are like sealed, pre-screened containers that skip customs entirely. JavaScript always goes through all three checkpoints, on the main thread, blocking everything else.
The solution is two-pronged: ship less code (tree shaking) and ship it smarter (bundle splitting). Let's dig into both.
Code Splitting with Dynamic import()
Static import statements are resolved at build time — the bundler follows every import chain and bundles everything into one (or a few) output files. Dynamic import() is different. It returns a Promise that resolves to the module, and the bundler treats it as a split point — a boundary where a new chunk is created.
// Static import — included in the main bundle no matter what
import { HeavyChart } from './HeavyChart'
// Dynamic import — creates a separate chunk, loaded on demand
const HeavyChart = await import('./HeavyChart')
The key insight: dynamic import() is not just lazy loading. It's a signal to the bundler that says "this is a separate universe of code — put it in its own chunk and only load it when I ask."
When to Split
Not everything should be code-split. There's an overhead to each chunk: an extra HTTP request, chunk loading runtime, and potential waterfall delays. Split when:
- The code is not needed on initial render (modals, tooltips, admin panels, below-the-fold widgets)
- The code is large (charting libraries, rich text editors, syntax highlighters — anything over ~30KB gzipped)
- The code is conditionally used (feature flags, user roles, A/B tests)
- The code is route-specific (different pages need different code)
// Good: heavy library loaded only when user opens the chart tab
async function showChart(data) {
const { renderChart } = await import('./chartLib')
renderChart(data)
}
// Bad: splitting a 2KB utility function — the chunk overhead negates the savings
const { formatDate } = await import('./formatDate') // Don't do this
React.lazy and Suspense
React provides a first-class API for component-level code splitting. React.lazy wraps a dynamic import and returns a component that can be rendered normally — React handles the loading state via Suspense.
import { lazy, Suspense } from 'react'
const HeavyEditor = lazy(() => import('./HeavyEditor'))
function EditorPage() {
return (
<Suspense fallback={<EditorSkeleton />}>
<HeavyEditor />
</Suspense>
)
}
What happens under the hood:
- On first render,
HeavyEditorhasn't loaded yet. React "suspends" — it throws a Promise (yes, literally throws it). - The nearest
Suspenseboundary catches the thrown Promise and renders thefallback. - When the chunk loads and the Promise resolves, React re-renders and the real
HeavyEditorappears.
Suspense Boundary Placement Matters
Where you place Suspense boundaries determines the loading experience. Too high and you get full-page loading spinners. Too low and you get a jarring popcorn effect of content popping in piece by piece.
// Bad: entire page shows a spinner while one component loads
<Suspense fallback={<FullPageSpinner />}>
<Header />
<Sidebar />
<LazyContent />
<Footer />
</Suspense>
// Good: only the lazy component area shows a skeleton
<Header />
<Sidebar />
<Suspense fallback={<ContentSkeleton />}>
<LazyContent />
</Suspense>
<Footer />
The rule: wrap Suspense as close to the lazy component as possible. The rest of the page should remain interactive while the lazy chunk loads.
Route-Based Splitting in Next.js
Here's the good news: if you're using Next.js with the App Router, route-based code splitting is automatic. Every page.tsx in the app/ directory becomes its own chunk. You don't need to configure anything.
app/
├── page.tsx → chunk: main page
├── dashboard/
│ └── page.tsx → chunk: dashboard (loaded only when navigating here)
├── settings/
│ └── page.tsx → chunk: settings
└── admin/
└── page.tsx → chunk: admin
Next.js also does some smart things behind the scenes:
- Shared chunks: code imported by multiple routes is extracted into shared chunks so it's not duplicated
- Prefetching: visible
Linkcomponents trigger chunk prefetch on hover (or on viewport intersection for mobile), so navigations feel instant - Server Components: RSC payloads are streamed, and client components are hydrated with only the JS they need
Component-Level Splitting with next/dynamic
For within-route splitting, Next.js provides next/dynamic — a wrapper around React.lazy with extra features:
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false, // Skip server-rendering for client-only components
})
The ssr: false option is particularly useful for components that depend on browser APIs (window, document, canvas) — they won't run during server-side rendering, avoiding hydration mismatches.
When to Use next/dynamic vs React.lazy
In a Next.js App Router project, prefer next/dynamic because:
- It handles SSR correctly (with
ssr: falsewhen needed) - It supports a
loadingprop (simpler than wrapping inSuspense) - It works with Server Components (you can dynamically import client components from a server component)
React.lazy works fine in client components, but next/dynamic is the more complete solution in the Next.js ecosystem.
Tree Shaking: Removing Dead Code at Build Time
Tree shaking is the bundler's ability to analyze your import/export statements and eliminate code that's never used. The name comes from the mental image of shaking a tree — the dead leaves (unused exports) fall off, and only the living branches remain.
// math.js — exports 4 functions
export function add(a, b) { return a + b }
export function subtract(a, b) { return a - b }
export function multiply(a, b) { return a * b }
export function divide(a, b) { return a / b }
// app.js — only uses add
import { add } from './math'
console.log(add(2, 3))
After tree shaking, only add ends up in the final bundle. subtract, multiply, and divide are eliminated entirely — they never appear in the output.
Why It Only Works with ESM
Tree shaking relies on static analysis — the bundler must determine at build time which exports are used. This is only possible with ES modules because their import/export statements are statically analyzable:
- Imports/exports must be at the top level (not inside
ifblocks or functions) - Import specifiers are string literals (not variables)
- The dependency graph is known before any code runs
CommonJS (require) can't be tree-shaken because it's dynamic:
// CommonJS — impossible to tree shake
const lib = require('./math') // Entire module loaded
const fn = require(dynamicPath) // Path unknown at build time
if (condition) {
require('./conditionalModule') // Conditional loading
}
module.exports = { [dynamicKey]: fn } // Dynamic export keys
The bundler can't know which parts of a CommonJS module are used without running the code. So it includes everything.
ESM is like a restaurant menu where you circle exactly what you want — the kitchen only prepares those dishes. CommonJS is like an all-you-can-eat buffet — everything gets cooked regardless, and you take what you want at runtime. Tree shaking works at the menu (ESM) level because the order is known upfront. At the buffet (CJS), everything is already prepared.
The sideEffects Field: Telling the Bundler What's Safe to Drop
Even with ESM, the bundler can't always tree-shake aggressively. Consider this:
// polyfill.js
Array.prototype.customFlat = function() { /* ... */ }
// styles.js
import './global.css'
// analytics.js
export function track(event) { /* ... */ }
window.__analytics = { initialized: true } // Side effect!
These modules have side effects — code that runs on import and changes global state. If the bundler removes an unused export from analytics.js, it also removes the window.__analytics assignment. That might break other code that depends on it.
The sideEffects field in package.json tells the bundler which files are safe to prune entirely when their exports are unused:
{
"name": "my-library",
"sideEffects": false
}
"sideEffects": false means "every file in this package is pure — if its exports aren't imported, the entire file can be removed." This is the most aggressive setting and works for most utility libraries.
For packages with some files that have side effects (like CSS imports), you can be specific:
{
"name": "my-component-library",
"sideEffects": [
"*.css",
"./src/polyfills.js"
]
}
This tells the bundler: "CSS files and polyfills.js have side effects — never drop them. Everything else is pure."
If your library ships without "sideEffects": false in package.json, bundlers must assume every file has side effects. This means unused exports might still end up in the bundle because the bundler can't safely remove the file (what if importing it modifies a global?). If you maintain a library, adding this field is one of the highest-impact things you can do for your consumers' bundle sizes. But be careful — if your library genuinely has side effects (CSS imports, polyfills, global mutations), mark those specific files or you'll break consumers.
What Kills Tree Shaking
Even with ESM and sideEffects: false, certain patterns prevent tree shaking:
// Re-exporting everything — bundler may keep unused exports
export * from './utils'
// Object/namespace exports — bundler can't tree-shake object properties
export default {
add: (a, b) => a + b,
subtract: (a, b) => a - b,
}
// Consumer: import math from './math'; math.add(1, 2)
// Bundler can't remove subtract — it's a runtime property access
// Class methods — can't be individually shaken
export class MathUtils {
add(a, b) { return a + b }
subtract(a, b) { return a - b }
}
// Even if you only call add(), subtract stays — it's part of the prototype
The fix: prefer named exports of standalone functions over default object exports or classes with methods.
// Tree-shakeable: each export is independently removable
export function add(a, b) { return a + b }
export function subtract(a, b) { return a - b }
Analyzing Your Bundles
You can't optimize what you can't measure. Two tools you should know:
@next/bundle-analyzer
For Next.js projects, this wraps webpack-bundle-analyzer and generates interactive treemaps of your bundles:
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({
// your next config
})
Run with ANALYZE=true next build and you get a visual breakdown of every chunk — which modules are in each chunk, how big they are, and where the weight is.
source-map-explorer
Works with any project that generates source maps. It parses the source map to show exactly which source files contribute to each output file:
npx source-map-explorer dist/main.js
What to Look For
When analyzing bundles, hunt for:
- Duplicate dependencies: the same library appearing in multiple chunks (e.g., two versions of
lodash) - Unexpectedly large imports: importing
momentorlodashentirely when you only use one function - Client-side code that should be server-only: database drivers, API keys, server utilities in client bundles
- Polyfills you don't need: core-js polyfilling features all your target browsers already support
the import cost of common libraries
Some libraries are worse offenders than others when imported naively:
moment(entire library): ~290KB minified, ~72KB gzipped. Usedate-fns(tree-shakeable) ordayjs(~7KB) instead.lodash(entire library): ~530KB minified, ~72KB gzipped. Uselodash-esfor tree-shakeable imports, or import individual functions:import debounce from 'lodash/debounce'.chart.js(all chart types): ~200KB minified. Use tree-shakeable imports:import { LineController, LineElement } from 'chart.js'.@mui/material(barrel import): can pull 300KB+ if you import from the barrel. Use deep imports:import Button from '@mui/material/Button'.
A single careless import _ from 'lodash' can add more JS than your entire application logic. Always check the cost before adding a dependency.
Chunk Naming and Caching Strategies
When the bundler splits your code into chunks, the naming strategy directly affects caching. The goal: maximize cache hits across deployments.
// Webpack magic comments for explicit chunk names
const AdminPanel = lazy(() =>
import(/* webpackChunkName: "admin" */ './AdminPanel')
)
const Chart = lazy(() =>
import(/* webpackChunkName: "chart-lib" */ './ChartWrapper')
)
Content Hashing
Modern bundlers append content hashes to chunk filenames by default:
main.a1b2c3d4.js ← changes hash when code changes
vendor.e5f6g7h8.js ← stable hash if dependencies unchanged
admin.i9j0k1l2.js ← only re-downloaded when admin code changes
This means unchanged chunks are served from the browser cache on repeat visits. The key insight: separate vendor code from application code. Your dependencies change far less frequently than your app code, so the vendor chunk stays cached across most deployments.
Granular Chunking Strategy
Next.js 15 uses a sophisticated chunking strategy out of the box:
- Framework chunk: React, ReactDOM, Next.js runtime — shared across all pages
- Commons chunk: code imported by most pages — extracted to avoid duplication
- Page chunks: code unique to each route
- Dynamic chunks: code-split via
dynamic()orReact.lazy()
For custom webpack configs, the splitChunks configuration controls how chunks are created:
// webpack.config.js (conceptual — Next.js handles this automatically)
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|next)[\\/]/,
name: 'framework',
priority: 40,
},
lib: {
test: /[\\/]node_modules[\\/]/,
name: 'lib',
priority: 30,
minSize: 20000,
},
},
},
}
Putting It All Together: A Real-World Splitting Strategy
Here's how these pieces fit together in a production Next.js application:
// 1. Route splitting — automatic in Next.js (each page.tsx = separate chunk)
// 2. Component splitting — heavy components loaded on demand
import dynamic from 'next/dynamic'
const CodeEditor = dynamic(() => import('./CodeEditor'), {
loading: () => <EditorSkeleton />,
ssr: false,
})
const MarkdownPreview = dynamic(() => import('./MarkdownPreview'), {
loading: () => <PreviewSkeleton />,
})
// 3. Interaction-triggered splitting — load on user action
async function handleExport() {
const { exportToPDF } = await import('./pdfExporter')
await exportToPDF(document)
}
// 4. Conditional splitting — load based on feature flags or user role
function AdminTools({ isAdmin }) {
if (!isAdmin) return null
const AdminPanel = dynamic(() => import('./AdminPanel'))
return <AdminPanel />
}
Common Anti-Patterns and How to Fix Them
Barrel Files That Defeat Tree Shaking
A barrel file (index.ts) that re-exports everything from a folder can pull in the entire folder even if you import one thing:
// components/index.ts (barrel file)
export { Button } from './Button'
export { Modal } from './Modal'
export { DataTable } from './DataTable'
export { Chart } from './Chart' // 150KB
export { RichEditor } from './RichEditor' // 200KB
// app.tsx — you only want Button
import { Button } from './components'
// Depending on the bundler and sideEffects config,
// this might pull in Chart and RichEditor too
The fix: import directly from the source file when working with heavy components:
import { Button } from './components/Button'
Or configure sideEffects: false in your package to let the bundler safely drop unused re-exports.
Dynamic Imports in Render Path
Creating dynamic components inside render functions creates new components on every render:
// Bad: creates a new lazy component every render
function Page() {
const Heavy = dynamic(() => import('./Heavy')) // New reference each render!
return <Heavy />
}
// Good: define outside the component
const Heavy = dynamic(() => import('./Heavy'))
function Page() {
return <Heavy />
}
| What developers do | What they should do |
|---|---|
| Import entire libraries like lodash or moment Barrel imports of large libraries can add 100-500KB to your bundle. Tree-shakeable versions or direct file imports ensure only the functions you use are included. | Use tree-shakeable alternatives (lodash-es, date-fns, dayjs) or deep imports (lodash/debounce) |
| Define React.lazy or next/dynamic inside a component render function Defining inside render creates a new component reference on every render, causing unmount/remount cycles, losing state, and defeating React's reconciliation. | Always define lazy/dynamic components at module scope, outside any component |
| Code-split everything including tiny 2KB utilities Each chunk incurs HTTP request overhead, chunk loading runtime cost, and potential waterfall delays. For small modules, the overhead exceeds the savings. | Only split components and libraries over ~30KB gzipped, or conditionally loaded code |
| Ship CommonJS libraries and expect tree shaking to work CommonJS require() is dynamic and cannot be statically analyzed. Bundlers must include the entire module because they can't determine which exports are used at build time. | Use ESM versions of libraries and set sideEffects: false in package.json |
| Ignore the sideEffects field in package.json for your library Without this field, bundlers assume every file has side effects and cannot safely remove unused files, even when no exports are consumed. | Add sideEffects: false (or list specific files with side effects) to package.json |
- 1JavaScript costs 3x more than other assets byte-for-byte: download, parse, and execute all block the main thread.
- 2Dynamic import() is a signal to the bundler to create a separate chunk — use it for components over ~30KB, conditionally loaded code, and below-the-fold content.
- 3Tree shaking only works with ES modules (static import/export). CommonJS require() is dynamic and cannot be statically analyzed.
- 4Set sideEffects: false in package.json for pure libraries — without it, bundlers cannot safely remove unused files.
- 5Route-based splitting is automatic in Next.js — every page.tsx becomes its own chunk with no configuration needed.
- 6Content hashing ensures unchanged chunks stay cached across deployments. Separate vendor code from app code to maximize cache hits.
- 7Prefer named exports of standalone functions over default object exports or class methods — objects and classes can't be tree-shaken at the property level.
- 8Always analyze bundles before and after optimization using @next/bundle-analyzer or source-map-explorer. You can't optimize what you can't measure.