Home JavaScript Next.js 16 Bundle — A Single import 'lodash' Added 70KB to Every Page Load
Advanced 8 min · July 12, 2026

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.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
Before you start⏱ 25 min
  • Node.js 18+
  • Next.js 14+ project with routing
  • Basic understanding of JavaScript bundling concepts
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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/dynamic cut 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
✦ Definition~90s read
What is Bundle Analysis and Performance Optimization in Next.js 16?

Bundle analysis in Next.js is the process of inspecting the JavaScript bundles that your app ships to the browser. It answers the fundamental question: what code are my users actually downloading, and how much of it is necessary?

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.

The primary tool is @next/bundle-analyzer, a Webpack/Turbopack plugin that generates interactive treemap visualisations of every page's JavaScript bundle. Each rectangle in the treemap represents a module — the larger the rectangle, the more bytes that module contributes.

This visual format reveals bloat patterns that raw file sizes hide: you can instantly see whether lodash appears in every page's bundle, whether a 200KB chart library is unnecessarily included in the critical path, and whether barrel files are silently duplicating code across chunks.

Beyond analysis, bundle optimisation involves three techniques: tree shaking (removing unused exports), dynamic imports (loading code on demand), and code splitting (separating bundles by page). Tree shaking requires ES module syntax — CommonJS modules bundle entirely regardless of what you import.

Dynamic imports use next/dynamic to defer heavy components until they are needed. Code splitting separates your application into per-page bundles that load only when the user visits that page.

The production impact is measurable: every 100KB of JavaScript adds roughly 0.5-1 second to Time to Interactive on a mid-range mobile device. Optimising your bundle from 400KB to 150KB gzipped can cut TTI from 8 seconds to 3 seconds — the difference between a user staying and bouncing.

Plain-English First

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.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

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.

The Barrel File Trap
A barrel file at 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.
Production Insight
A FinTech app had a 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.
Key Takeaway
Tree shaking fails on CommonJS modules, barrel files, and packages without sideEffects: false. Direct path imports are the only reliable pattern.
CommonJS named imports bundle the entire module — prefer ESM packages or direct path imports.
Audit barrel files aggressively — they are the #1 source of accidental bundle bloat.
nextjs-bundle-analysis-performance THECODEFORGE.IO Next.js 16 Bundle Architecture Layers How vendor bundles, dynamic imports, and critical path bundles interact User Facing Page Components | Dynamic Imports | Critical CSS Vendor Bundle lodash (70KB) | React | Other Libraries Optimization Layer @next/bundle-analyzer | Webpack SplitChunks | Turbopack Asset Optimization Image Optimization | Font Optimization | Code Splitting Delivery Critical Path Bundles | Lazy Loading | Caching THECODEFORGE.IO
thecodeforge.io
Nextjs Bundle Analysis Performance

@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.

next.config.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  webpack: (
    config,
    { webpack }
  ) => {
    if (process.env.ANALYZE === 'true') {
      const { BundleAnalyzerPlugin } =
        require('webpack-bundle-analyzer')
      config.plugins.push(
        new BundleAnalyzerPlugin({
          analyzerMode: 'static',
          reportFilename: '[name].html',
          openAnalyzer: false,
        })
      )
    }
    return config
  },
}

export default nextConfig
Try it live
CI Integration
Add 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.
Production Insight
Running ANALYZE=true on a production build revealed that 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.
Key Takeaway
@next/bundle-analyzer generates interactive treemaps — use them to identify large modules that appear across multiple pages.
Look for third-party libraries in shared layouts that should be dynamically imported.
Set a per-page JS budget (180-200KB gzipped) and fail CI when exceeded.

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.

components/DynamicChart.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
'use client'

import dynamic from 'next/dynamic'
import { Suspense } from 'react'

// Heavy chart library — only loaded when RevenueChart is rendered
// Without dynamic: recharts adds 97KB to every page's initial bundle
// With dynamic: 0KB until user navigates to /dashboard/revenue
const RevenueChart = dynamic(
  () => import('./RevenueChart'),
  {
    ssr: false,          // Charts don't need SSR
    loading: () => (
      <div className="h-96 animate-pulse bg-gray-100" />
    ),
  }
)

export default function DashboardPage() {
  return (
    <div>
      <h1>Revenue Dashboard</h1>
      <Suspense fallback={<div>Loading chart...</div>}>
        <RevenueChart />
      </Suspense>
    </div>
  )
}
Try it live
Named Exports with Dynamic Imports
Dynamic import default export only: () => import('./Chart'). For named exports, wrap: () => import('./Chart').then(m => ({ default: m.RevenueChart })).
Production Insight
A SaaS app used react-quill (146KB) on three of 12 pages. The team imported it in a shared 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.
Key Takeaway
next/dynamic with ssr: false is the highest-impact bundle optimisation — lazy load modals, charts, editors, and video players.
Dynamic imports split bundles into separate chunks — the critical initial bundle is smaller, deferred code loads on interaction.
Use named exports via .then(m => ({ default: m.NamedExport })) pattern.
Turbopack vs Webpack for Next.js 16 Speed vs bundle control trade-offs in bundle optimization Turbopack Webpack Build Speed Faster (Rust-based) Slower (JavaScript-based) Tree Shaking Reliability Less mature, may miss side effects Mature, but fails with CommonJS Bundle Analyzer Integration Limited support Full @next/bundle-analyzer support SplitChunks Control Less configurable Granular vendor bundle splitting Dynamic Import Handling Good, but fewer optimizations Excellent with code splitting Hidden Bundle Tax (Images/Fonts) May miss some optimizations Better detection and reduction THECODEFORGE.IO
thecodeforge.io
Nextjs Bundle Analysis Performance

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.

next.config.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  // Force Webpack for production builds
  // when Turbopack analysis is insufficient
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.optimization.splitChunks = {
        chunks: 'all',
        maxInitialRequests: 25,
        minSize: 20000,
        cacheGroups: {
          react: {
            test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
            name: 'react-vendor',
            chunks: 'all',
          },
        },
      }
    }
    return config
  },
}

export default nextConfig
Try it live
Dev Speed vs Production Optimisation
Turbopack is optimised for developer experience — fast startup, instant HMR. Webpack is optimised for production output — better code splitting, more mature tree shaking. Use Turbopack in dev, Webpack in prod. Do not use --experimental-turbo in production CI unless you have verified identical output.
Production Insight
A project migrated from Webpack to Turbopack for production builds and saw a 12% increase in bundle size (1.4MB to 1.57MB). The root cause: Turbopack's code splitting algorithm grouped shared dependencies differently, creating fewer but larger chunks. Reverting to Webpack for production restored the original bundle size. Always verify production bundle sizes when switching bundlers.
Key Takeaway
Turbopack is 10x faster for development but may produce larger production bundles than Webpack in complex apps.
Use Turbopack for dev (default in Next.js 16), Webpack for production builds.
Custom webpack loaders and plugins do not work with Turbopack — check compatibility before migrating.

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.

utils/debounce.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 10 lines. Zero dependencies. Same API as lodash.debounce.
export function debounce<T extends (...args: any[]) => any>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout> | null = null

  return (...args: Parameters<T>) => {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      fn(...args)
      timer = null
    }, delay)
  }
}
Try it live
The 10-Line Rule
Before installing a library, ask: 'Can I write this in 10 lines of TypeScript or less?' If yes, write it. You save bundle size, eliminate a dependency, and own the maintenance. This applies to debounce, throttle, groupBy, pick, omit, capitalize, and most utility functions.
Production Insight
A project replaced Moment.js (231KB) with Intl.DateTimeFormat (native, 0KB), lodash.groupBy with a 5-line reduce function, and numeral with Intl.NumberFormat. The total saving was 289KB gzipped from the vendor chunk. TBT dropped from 400ms to 180ms. The 10-Line Rule eliminated 60% of their dependency weight. Rule: every dependency must justify its bytes.
Key Takeaway
Third-party libraries are the largest source of unintentionally bloat — Moment.js (231KB) should be Intl API, lodash should be 10-line functions.
The 10-Line Rule: if you can write it in 10 lines of native code, do not install a dependency.
Every dependency adds parse time, compile time, and maintenance risk — not just download size.

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.

app/layout.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// BEFORE: root layout imports expensive toast library
// Every page pays the 28KB cost even if it never shows a toast
import { Toaster } from 'react-hot-toast'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      {/* Toaster loads in critical path for EVERY page */}
      <Toaster position="top-right" />
      {children}
    </html>
  )
}

// AFTER: Lazy-load Toaster only when needed
// 0KB impact on pages that don't trigger toasts
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <LazyToaster />
      {children}
    </html>
  )
}
Try it live
Root Layout Audit
Every import in your root 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.
Production Insight
An analytics dashboard had 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.
Key Takeaway
Imports in root layout.tsx affect the critical render path of EVERY page — audit them monthly.
Dynamic imports defer non-critical code — use them for modals, dashboards, and third-party widgets.
Chrome DevTools Performance tab shows the critical path waterfall — every JS above FCP is critical.

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.

next.config.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const nextConfig: NextConfig = {
  webpack: (config) => {
    config.optimization.splitChunks = {
      chunks: 'all',
      maxInitialRequests: 25,
      minSize: 10000,
      cacheGroups: {
        react: {
          test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
          name: 'vendor-react',
          chunks: 'all',
          priority: 20,
        },
        ui: {
          test: /[\\/]node_modules[\\/](@radix-ui|@headlessui)[\\/]/,
          name: 'vendor-ui',
          chunks: 'all',
          priority: 15,
        },
        charts: {
          test: /[\\/]node_modules[\\/](recharts|d3|visx)[\\/]/,
          name: 'vendor-charts',
          chunks: 'all',
          priority: 10,
        },
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor-other',
          chunks: 'all',
          priority: 1,
        },
      },
    }
    return config
  },
}

export default nextConfig
Try it live
Granularity vs HTTP Requests
More cache groups = more isolated vendor chunks = better caching (one library update doesn't invalidate others). But more chunks = more HTTP requests. Test with your app: start with granular groups, measure with bundle analyzer, merge groups if requests > 25 per page.
Production Insight
An enterprise app with 120 pages used the default SplitChunks config. Every page shipped a 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.
Key Takeaway
Default SplitChunks works but is suboptimal for apps with 10+ third-party dependencies — use custom cache groups.
Isolate large, slow-changing libraries into separate chunks for better caching invalidation.
The sweet spot is 15-25 initial chunks — test with your app's specific dependency graph.

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.

app/layout.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { Inter } from 'next/font/google'

// next/font automatically:
// - Hosts font on same origin (no extra DNS/TLS)
// - Subsets to Latin characters only
// - Adds font-display: swap
// - Inlines the @font-face declaration in CSS
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  // Variable font support:
  // weight: 'variable'
})

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  )
}
Try it live
Variable Fonts Save 50-70% in File Size
A variable font like Inter Variable (2KB per axis) replaces separate files for each weight (Inter Regular 50KB + Inter Bold 50KB + Inter SemiBold 50KB = 150KB total). Use variable fonts with next/font for the smallest possible font payload.
Production Insight
A content site loaded three Google Font weights (Regular, SemiBold, Bold) at 45KB each — 135KB total, with external DNS lookup adding ~80ms. Switching to next/font with Inter Variable (2KB) hosted on-origin reduced font download to 2KB and eliminated the DNS round trip. LCP dropped from 3.2s to 2.6s. Rule: host fonts on your own origin. Never use external font CDNs.
Key Takeaway
next/font eliminates external font requests — fonts bundle with your CSS, no DNS/TLS overhead.
Font subsetting (Latin-only) and variable fonts (single file for all weights) dramatically reduce font payload.
font-display: swap prevents invisible text during font load — improves FCP and LCP.

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.

.github/workflows/bundle-check.ymlBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
name: Bundle Size Check
on: [pull_request]

jobs:
  bundle:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: ANALYZE=true npm run build
      - name: Check bundle sizes
        run: |
          FAILED=0
          for file in .next/analyze/*.html; do
            SIZE=$(grep -oP '"size":\K[0-9]+' "$file" | awk '{s+=$1} END {print s}')
            if [ "$SIZE" -gt 180000 ]; then
              echo "FAIL: $(basename $file) is $(($SIZE / 1000))KB (limit: 180KB)"
              FAILED=1
            fi
          done
          if [ "$FAILED" -eq 1 ]; then
            echo "Bundle budget exceeded. Optimise before merging."
            exit 1
          fi
          echo "All bundles within budget ✓"
False Positives from Source Maps
Bundle analyzer includes source maps in size calculations by default. Source maps can double the reported size. Set hidden: 'source-map' in your webpack config or filter them out in your budget check script to avoid false failures.
Production Insight
A team added bundle budgets to CI after a 300KB bloat incident. The first three weeks saw 12 PRs blocked for exceeding budgets — most were accidental imports of heavy libraries in shared components. Budget enforcement caught every one before it reached production. The team's bundle size decreased by 35% in the first month. Rule: what gets measured gets managed. Enforce budgets in CI.
Key Takeaway
Manual bundle monitoring fails — enforce budgets in CI with automated checks per PR.
Set three budgets: total JS per page (180KB), single chunk (50KB), third-party lib (30KB).
Budget enforcement catches bloat before it reaches production — teams reduce bundle size by 30-40% in the first month.

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.

.github/workflows/lighthouse.ymlBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
name: Lighthouse CI
on: [pull_request]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build and deploy preview
        run: |
          npm ci
          npm run build
          npm start &
          sleep 5
      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v10
        with:
          urls: |
            http://localhost:3000/
            http://localhost:3000/products
            http://localhost:3000/about
          uploadArtifacts: true
          temporaryPublicStorage: true
          configPath: './lighthouserc.json'
Assert All Four Categories
Don't just assert Performance. Assert Accessibility (>=90), Best Practices (>=90), and SEO (>=90) too. A PR that passes Performance but drops Accessibility from 95 to 72 should still fail — you shipped a regression.
Production Insight
A team relying solely on bundle budgets missed a critical regression: a third-party analytics script added as a <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.
Key Takeaway
Lighthouse CI catches performance regressions that bundle budgets miss — third-party scripts, render blocking resources, layout shifts.
Set minimum scores (>=90) for Performance, Accessibility, Best Practices, and SEO.
Bundle budgets + Lighthouse CI = comprehensive performance governance in CI.
● Production incidentPOST-MORTEMseverity: high

70KB from a Single Import — The Day Lodash Broke Our Lighthouse Budget

Symptom
Lighthouse Performance score dropped from 94 to 67 on mobile. Total blocking time increased from 180ms to 920ms. The LCP jumped from 1.8s to 4.2s. Bundle analysis revealed lodash at 68KB gzipped appearing in every page's JS chunk.
Assumption
The team assumed that named imports from lodash (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.
Root cause
lodash (the main package) is published as CommonJS, not ES modules. Tree shaking requires ES module syntax (import/export) to statically analyse which exports are used. CommonJS modules have dynamic 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.
Fix
Replaced 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.
Key lesson
  • 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
Production debug guideDiagnose and fix bundle bloat in Next.js applications4 entries
Symptom · 01
Total blocking time (TBT) exceeds 300ms on desktop Lighthouse
Fix
Run 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 imported
Symptom · 02
A specific page's JS bundle grew after a code change, but you don't know what caused it
Fix
Use ANALYZE=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 change
Symptom · 03
Lighthouse reports 'JavaScript execution time' above 5 seconds
Fix
Open the bundle analyzer treemap. Identify modules that are both large AND on the critical path. These are prime candidates for dynamic imports with next/dynamic or lazy loading
Symptom · 04
node_modules is unexpectedly large in the analyzer output
Fix
Run npm 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 dependencies
★ Bundle Analysis Quick Debug ReferenceFast commands for diagnosing Next.js bundle size issues
Largest bundle chunk exceeds 300KB gzipped
Immediate action
Generate bundle analysis treemap
Commands
ANALYZE=true next build
ls -la .next/analyze/*.html
Fix now
Find the largest module in the treemap and replace it with a lazy-loaded dynamic import or a smaller alternative
A third-party library appears in every page's JS chunk+
Immediate action
Check if the import is in _app.tsx, layout.tsx, or a shared component
Commands
grep -rn "import.*from.*library-name" app/ pages/ --include='*.tsx' --include='*.ts'
grep -rn "import.*from.*library-name" components/ --include='*.tsx' --include='*.ts'
Fix now
Move the import to the specific page/component that uses it. If used by many pages, use next/dynamic with ssr: false to load it only when needed
Tree shaking not working for a specific ES module library+
Immediate action
Check if the package is actually ESM and not CommonJS
Commands
node -e "const pkg = require('./node_modules/library-name/package.json'); console.log(pkg.type, pkg.main, pkg.module)"
cat node_modules/library-name/index.js | head -20
Fix now
Replace with a library that publishes ES modules (check for 'module' field in package.json). For lodash specifically, use lodash-es
⚙ Quick Reference
7 commands from this guide
FileCommand / CodePurpose
next.config.tsconst nextConfig: NextConfig = {@next/bundle-analyzer
componentsDynamicChart.tsx'use client'Dynamic Imports
utilsdebounce.tsexport function debounce any>(Impage Optimisation
applayout.tsxexport default function RootLayout({Critical Path Bundles
applayout.tsxconst inter = Inter({Font Optimisation
.githubworkflowsbundle-check.ymlname: Bundle Size CheckBundle Budgets in CI
.githubworkflowslighthouse.ymlname: Lighthouse CILighthouse CI

Key takeaways

1
Tree shaking fails with CommonJS modules, barrel files, and packages missing sideEffects
false — always verify with @next/bundle-analyzer
2
A single barrel or CommonJS import can silently add 70KB+ to your bundle
the bundle analyzer is the only reliable detection tool
3
Dynamic imports with next/dynamic cut initial bundle size by 40-60% for charts, editors, modals, and other non-critical components
4
Turbopack is faster in development but Webpack still produces smaller production bundles for complex applications
5
Enforce bundle budgets in CI (180KB gzipped per page max)
manual monitoring fails because no one runs it consistently
6
The 10-Line Rule
before installing a utility library, ask if you can write it in 10 lines of native code
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
A developer on your team adds `import { debounce } from 'lodash'` to a s...
Q02SENIOR
Walk me through the complete process of investigating a suddenly-large b...
Q03SENIOR
When should you use dynamic imports in Next.js, and when should you avoi...
Q04SENIOR
Explain how the webpack SplitChunks plugin works in a Next.js app. What ...
Q05SENIOR
What is the relationship between bundle size, Time to Interactive (TTI),...
Q01 of 05SENIOR

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?

ANSWER
Tree shaking fails because lodash is distributed as CommonJS. Named imports like 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.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Does tree shaking work with TypeScript?
02
How do I check if a package supports tree shaking?
03
What is the difference between next/dynamic and React.lazy?
04
Should I use Turbopack for production builds?
05
How do I debug a sudden bundle size increase in CI?
06
What module formats defeat tree shaking completely?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
🔥

That's Next.js. Mark it forged?

8 min read · try the examples if you haven't

Previous
Security in Next.js 16: CSP, Taint API, and Server Actions Security
45 / 56 · Next.js
Next
Custom Cache Handlers: Redis, Memcached, and Distributed Caching