Next.js 16 Bundle — A Single import 'lodash' Added 70KB to Every Page Load
One unused barrel import from lodash added 70KB to every page in a Next.js 16 app.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- ✓Node.js 18+
- ✓Next.js 14+ project with routing
- ✓Basic understanding of JavaScript bundling concepts
- A single barrel import
import { debounce } from 'lodash'pulled the entire lodash library (70KB) into every page bundle - Tree shaking eliminates dead code but barrel files and CommonJS modules defeat it — your bundler can't see what's used
- @next/bundle-analyzer visualises your bundle composition — run it before every production deploy
- Dynamic imports with
next/dynamiccut initial bundle size by 40-60% for heavy components like charts and editors - Turbopack is 10x faster for dev builds but Webpack still produces smaller production bundles in complex apps
Imagine ordering a single screw from a hardware store, and the store delivers the entire warehouse — every tool, every nail, every piece of lumber — because the inventory system couldn't figure out which shelf that screw was on. That's what happens when JavaScript bundlers encounter barrel files or non-tree-shakable imports. A one-line import statement silently balloons your app with code you never run, and the only way to catch it is to inspect the delivery manifest before it ships.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Bundle bloat is the silent killer of Next.js performance. Unlike runtime bugs that crash immediately, an oversized bundle degrades every user's experience — slower page loads, higher bounce rates, worse Core Web Vitals — without throwing a single error. The worst part: most teams never realise they have a problem because they never look.
In a recent production Next.js 16 application, a single import statement — import { debounce } from 'lodash' — added 68KB gzipped to every page's JavaScript bundle. The developer intended to import only the debounce function. What they got was the entire lodash library. The application had 47 pages. That's 3.2MB of unnecessary JavaScript downloaded by every visitor on every page.
This article covers the complete bundle analysis workflow: configuring @next/bundle-analyzer, interpreting its output, understanding why tree shaking fails in common scenarios, using dynamic imports strategically, and comparing Turbopack vs Webpack for production builds. You will learn to audit your own bundles, identify bloat sources, and implement targeted fixes.
The goal is not zero JavaScript — that's unrealistic. The goal is knowing exactly what you ship and why. If you cannot justify every kilobyte in your critical bundle, you have a problem you haven't discovered yet.
Why Tree Shaking Fails More Often Than It Works
Tree shaking is a build-time optimisation where the bundler analyses import/export statements and removes 'dead' code — exports that are imported but never used. It sounds straightforward. In practice, it fails silently in three common scenarios.
First, CommonJS modules. Tree shaking requires static import/export syntax that ES modules provide. CommonJS modules use require() and module.exports, which are dynamic — you can conditionally require a module inside an if statement. The bundler cannot statically analyse dynamic code paths, so it bundles the entire module. This is why import { debounce } from 'lodash' bundles all of lodash: the main lodash package is CommonJS, and Babel/TypeScript compiles your named import into a CommonJS require.
Second, barrel files. A barrel file is an index.ts that re-exports everything from multiple modules. When you write import { Button } from '../components', the bundler sees a reference to the barrel file. To determine what Button actually depends on, it must trace through every re-export in the barrel — and in practice, most bundlers give up and include everything the barrel touches. Large barrel files are the single most common source of accidental bundle bloat in Next.js apps.
Third, side effects. Package.json has a sideEffects field that tells the bundler whether a module has side effects when imported. If a package doesn't declare sideEffects: false, the bundler assumes every import has side effects and refuses to tree-shake it. Many libraries omit this field, causing the bundler to err on the side of caution.
components/index.ts that re-exports 47 components forces the bundler to trace all 47 files every time any component is imported. This single pattern can add 100-200KB to your bundle. Audit your barrel files and replace with direct imports.utils/index.ts barrel re-exporting 23 utility modules. Every page imported import { formatCurrency } from '@/utils', which bundled all 23 modules into every page. Breaking barrel files into direct imports reduced the main bundle by 41%. Rule: never barrel-export utility functions or third-party wrappers.@next/bundle-analyzer — The Only Tool That Shows You What You Ship
Running bundle analysis without @next/bundle-analyzer is like debugging without reading the error message. You can guess, but you'll waste hours. The package wraps webpack-bundle-analyzer in a Next.js plugin that generates interactive treemaps of every page's JavaScript bundle.
Installation is straightforward: npm install @next/bundle-analyzer, add it to next.config.ts, and run ANALYZE=true next build. The output is an HTML file per page showing a treemap where each rectangle is a module — the larger the rectangle, the more bytes it contributes. Hovering shows the module path and exact size.
What to look for: third-party libraries that appear across multiple pages (they should be in a common chunk, not duplicated), large modules in the critical render path (candidates for dynamic import), and any dependence that seems suspiciously large for its purpose. Moment.js at 230KB is a classic red flag — replace it with date-fns or dayjs.
The treemap reveals the truth that bundle size reports hide: your total JS may be 200KB, but if that's spread across 10 modules and one of them is 150KB, the 150KB module determines your LCP. The treemap shows distribution, not just total.
ANALYZE=true next build to your CI pipeline and fail the build if any chunk exceeds your budget. Use the generated HTML files as CI artifacts for historical comparison.marked (a Markdown parser) was 84KB and appeared in 23 of 47 page bundles. The team was importing it in a shared layout. Moving it to a dynamic import in only the two pages that rendered Markdown cut 1.9MB from total bundle size. Run bundle analysis before every major deploy.Dynamic Imports — The Single Highest-Impact Optimisation
Dynamic imports split your JavaScript so that a module is loaded only when the component that uses it is actually rendered. In Next.js, next/dynamic wraps React.lazy with Next.js-specific features like SSR control and loading states. It is the single highest-impact bundle optimisation you can make.
When to use it: any component that is not visible on the initial page load. Modals, drawers, chart libraries, rich text editors, code highlighters, video players — these are heavy components that users interact with, not look at when the page first renders. Lazy-loading them cuts the initial bundle by 40-60% with zero user-facing impact.
The `ssr: false` option disables server-side rendering for a component. Use this for components that depend on browser APIs (localStorage, window, document) or for heavy third-party widgets that don't need to be in the initial HTML. A chart rendered on the server is invisible anyway — rendering it server-side wastes CPU and adds nothing.
One caveat: dynamic imports split your bundle into separate chunks, which means additional HTTP requests on initial load. This is fine — the tradeoff is between downloading 500KB in one chunk vs 200KB critical + 300KB deferred. The latter always wins for perceived performance.
() => import('./Chart'). For named exports, wrap: () => import('./Chart').then(m => ({ default: m.RevenueChart })).Editor component used by every page. Moving to next/dynamic with ssr: false on only the three pages cut the initial bundle from 340KB to 194KB. Lighthouse TBT dropped from 520ms to 210ms. Rule: any component not visible above the fold is a candidate for dynamic import..then(m => ({ default: m.NamedExport })) pattern.Turbopack vs Webpack — When Speed Comes at a Cost
Next.js 16 defaults to Turbopack for development. Turbopack is a Rust-based bundler from Vercel that claims 10x faster cold starts and 700x faster HMR compared to Webpack. In practice, the speed difference is undeniable — next dev with Turbopack starts in under a second for most projects, and HMR updates in milliseconds regardless of project size.
But here is the tradeoff: Turbopack is not yet a full Webpack replacement for production builds. As of Next.js 16, Turbopack production builds are enabled via --experimental-turbo but may produce different bundle sizes than Webpack. In complex applications — especially those with heavy custom webpack configuration, specific loaders, or non-standard module resolution — Webpack still produces smaller and more optimised production bundles.
The pragmatic approach: use Turbopack for development speed (the default in Next.js 15+). Keep Webpack for production builds. If you need custom webpack loaders (SVGR, raw-loader, GraphQL codegen), you must use Webpack — Turbopack does not support custom webpack plugins.
Bundle analysis with Turbopack is also limited. The ANALYZE=true environment variable works differently — Turbopack uses its own internal analysis that is less detailed than webpack-bundle-analyzer. For accurate production bundle debugging, always build with Webpack and run the full analyzer.
--experimental-turbo in production CI unless you have verified identical output.Impage Optimisation — The Hidden Bundle Tax You Don't See
Third-party libraries for dates, numbers, utility functions, and UI components are the most common source of unintentional bundle bloat. Each one seems small in isolation — a date formatter here, an icon there. But the aggregated cost of 10-15 small libraries can exceed 300KB gzipped, all of it added one import at a time.
The worst offenders: Moment.js (231KB), lodash (71KB), numeral (37KB), and full icon sets imported without tree shaking. Each of these has a smaller alternative: dayjs (2KB) for dates, native Intl API for number formatting, date-fns (300 bytes per function) for date utilities, and individual SVG icons imported as components.
But the pattern matters more than the specific library. If your team installs a library for a single function call, ask: can I write this in 10 lines of native code? For groupBy, debounce, formatDistance, capitalize — the answer is yes, and the native version is zero dependency overhead.
The production cost of a dependency is not just its download size. It's also parse time, compile time, and the opportunity cost of complexity. Every dependency is a risk of future breakage. Treat each one as a decision, not a default.
Critical Path Bundles — What Users Actually Wait For
The critical rendering path is the sequence of resources the browser must download and execute before it can render anything useful. In a Next.js app, the critical path includes: the HTML document (which Next.js may pre-render), CSS, the initial JavaScript chunk for the current page, and any synchronously-loaded dependencies.
Bundle analysis alone is insufficient — you also need to understand the criticality of each module. A 50KB chart library loaded lazily after interaction does not affect initial render. A 20KB utility imported in the root layout affects every page.
The Chrome DevTools Performance tab shows the critical path in the Network waterfall. Every JavaScript file above the First Contentful Paint (FCP) line is critical. Every file below it is deferred — and should be dynamically imported if possible.
Next.js automatically code-splits per route, but shared dependencies (imported in layout.tsx or _app.tsx) block the critical path for every page. Audit your root layout imports monthly. That import { Toaster } from 'react-hot-toast' in the root layout? It adds 28KB to every page's critical bundle. Move it to a client component that loads only on pages that need toasts.
layout.tsx or _app.tsx is critical bundle on every page. Audit these monthly. Move non-essential imports to page-specific layouts or dynamic client components.import 'chart.js' in the root layout for use on the single /analytics page. Every other page (login, settings, profile) shipped chart.js (136KB) for no reason. Moving it to the analytics page's layout cut the critical bundle for other pages by 40%. Rule: what's in your layout affects every route — keep it minimal.Webpack SplitChunks — Taking Control of Vendor Bundles
Webpack's SplitChunks plugin controls how shared dependencies are extracted into separate cache groups. Next.js has sensible defaults, but production apps with many third-party dependencies benefit from customisation.
The default behaviour: dependencies over 20KB used across 2+ pages are extracted into a shared chunk. This is generally good, but it can produce suboptimal results — lodash and moment.js might end up in the same shared chunk, meaning a page using only lodash still downloads moment.js.
Custom cache groups let you control the granularity. The most useful pattern is isolating large vendor libraries into their own chunks: react-vendor, ui-components, chart-libraries, utilities. Each cache group gets its own cacheable chunk that changes only when that library updates.
For apps with 50+ pages, the tradeoff shifts: more granular chunks = more HTTP requests. The sweet spot is 15-25 initial chunks per page. Beyond 25, HTTP/2 multiplexing compensates, but the overhead of parsing many small files adds up. Test both extremes with bundle analysis.
vendor.js containing react, lodash, d3, and moment together (380KB). Custom cache groups split these into 4 chunks: react (42KB), charts (94KB), utilities (68KB), other (51KB). A page that used only react saw a 42KB vendor chunk instead of 380KB. Rule: default SplitChunks is a starting point, not a production config.Font Optimisation — The Overlooked Performance Drag
Web fonts are the most overlooked cause of poor Core Web Vitals. A single variable font at 150KB blocks the critical path. In Next.js, next/font solves this by hosting fonts on the same origin (eliminating DNS and TLS costs), automatically subsetting font files to include only the characters you need, and adding font-display: swap by default.
Before next/font, the common pattern was @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700') in CSS. This caused a render-blocking network request to Google's servers, added 200-300ms of latency for the font stylesheet alone, and downloaded full character sets regardless of language.
With next/font, the font is downloaded as part of your CSS bundle during build time. There is no external request. The font file is automatically subset to the characters your app actually uses. And the font-display: swap means text renders immediately with a fallback font, then swaps to the custom font when it loads — no invisible text during font load.
The performance impact is measurable: switching from Google Fonts CDN to next/font reduced LCP by 400ms in a Next.js e-commerce app. The bundled font approach eliminates an entire network round trip from the critical path.
next/font for the smallest possible font payload.Bundle Budgets in CI — Make Bloat a Build Error
Manual bundle monitoring fails. Teams run bundle analysis once during the initial setup and never again. The only reliable approach is automated bundle budgets that fail CI when exceeded. If 200KB is your maximum acceptable JS per page, enforce it in code.
Next.js doesn't have a built-in budget checker, but bundlesize, size-limit, and webpack-bundle-analyzer with a CI script all work. The most reliable approach is a custom GitHub Action that runs ANALYZE=true next build, parses the generated report files, and compares each chunk's size against a threshold.
Set three separate budgets: total JS per page (180KB gzipped max), individual chunk size (50KB max for any single chunk on the critical path), and third-party library sizes (30KB max per library). When CI fails, the PR is blocked until the author investigates and fixes the bloat.
The budget should be a living document — start generous (300KB), then tighten over time. The goal is not to hit zero — it's to have a conversation every time a PR adds bytes. 'This PR adds 12KB for a new chart library — can we justify this?' becomes a routine code review question.
hidden: 'source-map' in your webpack config or filter them out in your budget check script to avoid false failures.Lighthouse CI — Performance Regressions as Build Errors
Bundle size is a proxy for performance. Lighthouse scores are the actual measurement. Lighthouse CI (LHCI) runs Lighthouse against your deployed app in a CI environment and asserts minimum scores for Performance, Accessibility, Best Practices, and SEO.
Unlike bundle budgets that catch size increases, Lighthouse CI catches performance regressions from any cause: render-blocking resources, layout shifts, font loading issues, image optimisation problems. A PR that keeps the bundle under budget but adds a render-blocking third-party script will fail Lighthouse CI.
Set the assertion thresholds: Performance >= 90, Accessibility >= 90, Best Practices >= 90, SEO >= 90. Run Lighthouse CI on every PR preview deployment. When a PR drops performance below 90, it cannot merge.
The combination is powerful: bundle budgets catch the easily-preventable (large scripts) while Lighthouse CI catches everything else (third-party embeds, slow API responses, rendering issues). Both together give you confidence that performance won't regress.
<script> tag with async instead of defer. Bundle size was unchanged, but LCP increased by 500ms. Lighthouse CI caught it: Performance dropped from 92 to 78. Rule: bundle budgets catch size, Lighthouse CI catches everything else. Use both.70KB from a Single Import — The Day Lodash Broke Our Lighthouse Budget
import { debounce } from 'lodash') would tree-shake to only the debounce function. They had read that ES module imports enable tree shaking and assumed lodash supported it.require() calls that bundlers cannot analyse statically. The import { debounce } from 'lodash' syntax is sugar — Babel/TypeScript compiled it to const lodash = require('lodash'); lodash.debounce(...) which bundled the entire library. The fix was replacing lodash with lodash-es (the ES module version) or using individual lodash.debounce package. Post-fix, the bundle dropped from 238KB to 170KB — exactly back within budget.import { debounce } from 'lodash' with import debounce from 'lodash-es/debounce'. Removed two other barrel imports from date-fns and replaced with direct path imports. Added @next/bundle-analyzer to the CI pipeline with a size budget that fails the build if any page exceeds 180KB gzipped. Ran ANALYZE=true npm run build and verified lodash no longer appeared in any page bundle.- Named imports from CommonJS packages do NOT tree-shake — the entire module is bundled regardless of what you import
- Barrel files (index.js that re-export other modules) defeat tree shaking even in ES module codebases — the bundler cannot see through the re-export chain
- Run @next/bundle-analyzer before every production deploy — visualise what you ship, don't guess
- Set a JavaScript bundle budget in CI — 200KB gzipped per page max — and fail the build if exceeded
ANALYZE=true next build to generate bundle analysis. Look for large third-party libraries appearing across multiple pages — these may be imported via a shared layout or _app.tsx when they should be dynamically importedANALYZE=true next build with a DIFF against the previous build's analysis. The @next/bundle-analyzer generates treemap HTML files — compare file sizes before and after the changenext/dynamic or lazy loadingnpm ls <package> to see why a package was installed. Use madge --circular src/ to detect circular imports that prevent tree shaking. Check package.json for unnecessary dependenciesANALYZE=true next buildls -la .next/analyze/*.html| File | Command / Code | Purpose |
|---|---|---|
| next.config.ts | const nextConfig: NextConfig = { | @next/bundle-analyzer |
| components | 'use client' | Dynamic Imports |
| utils | export function debounce | Impage Optimisation |
| app | export default function RootLayout({ | Critical Path Bundles |
| app | const inter = Inter({ | Font Optimisation |
| .github | name: Bundle Size Check | Bundle Budgets in CI |
| .github | name: Lighthouse CI | Lighthouse CI |
Key takeaways
Interview Questions on This Topic
A developer on your team adds `import { debounce } from 'lodash'` to a shared utility file. The bundle size increases by 70KB. Why does tree shaking not remove the unused parts of lodash, and how would you fix it?
import { debounce } from 'lodash' are syntactic sugar — TypeScript/Babel compiles them to const _ = require('lodash'); _.debounce(...) which pulls the entire module. Tree shaking requires ES module syntax (static import/export) that the bundler can analyse at build time. The fix: replace with import debounce from 'lodash/debounce' which imports only the specific file for debounce. Better: replace with lodash-es (the ESM version) and verify tree shaking works by running ANALYZE=true next build. Best: write a 10-line debounce function and remove the lodash dependency entirely. After the fix, run bundle analysis to confirm lodash no longer appears in any page chunk. For CI enforcement, add a webpack rule that warns or fails when any file imports from the main 'lodash' entry point instead of individual paths.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's Next.js. Mark it forged?
8 min read · try the examples if you haven't