Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 045 / 100

Analyze your bundle: a practical React & Next.js guide

You cannot shrink what you cannot see.

Expona Admin7 min read

Analyze your bundle

You cannot shrink what you cannot see. I learned this lesson the hard way on day 45 of optimizing production Next.js applications—after spending weeks tweaking code splits, I realized my bundle was still bloated because I'd never actually looked inside it. Bundle analysis transforms this problem from guessing to seeing exactly what's shipping to your users, and once you see it, optimization becomes obvious.

Why bundle size matters in production

Before we dive into the tools, let's ground this in reality. A larger bundle means longer download times, slower parsing on slower devices, and higher bounce rates. I've watched mobile users in emerging markets abandon a platform because the initial JavaScript bundle took 8 seconds to download. Bundle size directly impacts your Core Web Vitals, which Google ranks, and ultimately affects your business metrics.

The issue is that JavaScript bloat happens incrementally. You add a UI library here, a utility there, and suddenly your main bundle is 400KB. Most developers don't see it happening because they're shipping from their development machine with gigabit internet. Your users aren't so lucky.

@next/bundle-analyzer shows what ships

The easiest way to start seeing your bundle is with @next/bundle-analyzer, an official Next.js package that visualizes your JavaScript, CSS, and asset sizes as an interactive treemap.

Install it first:

tsx
npm install --save-dev @next/bundle-analyzer

Then update your next.config.js:

tsx
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

module.exports = withBundleAnalyzer({
  reactStrictMode: true,
  // your other config
})

Now run your build with analysis enabled:

tsx
ANALYZE=true npm run build

This generates an HTML report that opens automatically in your browser. You'll see two treemaps: one for the client-side bundle and one for the server bundle. Each rectangle represents a module, sized proportionally to how much space it takes up. This visual representation is powerful—you immediately spot the culprits.

I opened the analyzer on a CRM dashboard I was building and saw that a single charting library was consuming 45KB of my main bundle. That one finding led to lazy-loading the chart component, which cut our Largest Contentful Paint by 600ms.

Look for duplicate and huge dependencies

Once you have the treemap open, look for two patterns: duplicates and unexpectedly large packages.

Duplicates happen more often than you'd think. You might have different major versions of React, or two UI libraries both shipping their own date picker. I once found two versions of lodash in a codebase because a junior developer didn't check what was already installed. The analyzer makes these visible immediately.

For huge dependencies, you're looking for libraries that seem disproportionately large. A 100KB date library is a red flag. A 50KB modal component is worth questioning. I saw a developer ship an entire PDF generation library just to preview one report—moving that to the server cut the bundle by 30KB.

Here's a practical workflow I use:

  1. Run the analyzer and take a screenshot
  2. Sort packages by size descending
  3. For the top 10 largest packages, ask: does this need to be in the browser?
  4. Check if there's a smaller alternative
  5. Measure the impact before and after changes

Use the webpack-bundle-analyzer package for more detailed inspection if needed. It shows module sizes including how much space they'd save if removed:

tsx
npm install --save-dev webpack-bundle-analyzer

You can integrate it into your Next.js config for a more granular view than @next/bundle-analyzer provides.

Replace moment with date-fns or Intl

Moment.js is the classic case study in bundle bloat. It's a 65KB library that most projects don't fully utilize. I've audited dozens of codebases where developers imported moment just to format one date string.

If you're still using moment, consider these alternatives:

date-fns: A modern, tree-shakeable library that's typically 5-10KB after tree-shaking. It has excellent TypeScript support and a functional API that composes well.

tsx
// Before: moment (65KB)
import moment from 'moment'
const formatted = moment(date).format('YYYY-MM-DD HH:mm')

// After: date-fns (2-5KB depending on what you use)
import { format } from 'date-fns'
const formatted = format(date, 'yyyy-MM-dd HH:mm')

Native Intl API: For simple formatting, the browser's built-in Intl object handles most cases without any library. It's free in terms of bundle size:

tsx
// No library needed
const formatted = new Intl.DateTimeFormat('en-US', {
  year: 'numeric',
  month: '2-digit',
  day: '2-digit',
  hour: '2-digit',
  minute: '2-digit',
}).format(date)

I migrated a SaaS dashboard off moment by replacing it with date-fns where we needed formatting and Intl for display-only dates. The bundle dropped by 60KB, and our bundle analyzer showed this change immediately.

Similarly, audit any large utility libraries. Do you really need lodash, or just a couple of functions? Import only what you need, or use native alternatives:

tsx
// Instead of lodash.debounce
function debounce(func, delay) {
  let timeoutId
  return (...args) => {
    clearTimeout(timeoutId)
    timeoutId = setTimeout(() => func(...args), delay)
  }
}

// Instead of lodash.clonedeep for simple objects
const clone = (obj) => JSON.parse(JSON.stringify(obj))

Move heavy libraries to the server

The most effective optimization isn't to shrink a dependency—it's to not ship it to the browser at all. Server components and server-side rendering let you move heavy processing out of your client bundle entirely.

A common scenario: generating PDFs, processing images, or running data transformations. These don't need to happen in the browser. Let me show you a real example.

I had a trade booking platform that was shipping a PDF generation library to every user just so they could download trade confirmations. Moving this to a server action cut the client bundle by 35KB:

tsx
// app/actions/generatePDF.ts (server-side)
'use server'

import PDFDocument from 'pdfkit' // 35KB, not shipped to browser

export async function generatePDF(tradeId: string) {
  const trade = await db.trade.findUnique({ where: { id: tradeId } })
  
  const doc = new PDFDocument()
  doc.text(`Trade Confirmation: ${trade.id}`)
  doc.text(`Amount: ${trade.amount}`)
  
  const buffer = await doc.getBuffer()
  return buffer
}
tsx
// app/trades/[id]/DownloadButton.tsx (client-side)
'use client'

import { generatePDF } from '@/app/actions/generatePDF'

export default function DownloadButton({ tradeId }) {
  const handleDownload = async () => {
    const pdf = await generatePDF(tradeId)
    const url = URL.createObjectURL(new Blob([pdf]))
    const link = document.createElement('a')
    link.href = url
    link.download = `trade-${tradeId}.pdf`
    link.click()
  }

  return Download PDF
}

Server components in Next.js 13+ make this pattern natural. Heavy libraries for data processing, authentication, image resizing, and report generation all belong on the server.

Another approach: dynamic imports with client-side code splitting. If a feature isn't critical on page load, import it dynamically:

tsx
import dynamic from 'next/dynamic'

const HeavyChart = dynamic(() => import('@/components/Chart'), {
  loading: () => <div>Loading...</div>,
  ssr: false, // Don't render on server if it's purely client-side
})

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
       {/* Only loads when component mounts */}
    </div>
  )
}

Common mistakes

Running analysis once and ignoring it: Make bundle analysis part of your CI/CD pipeline. Set a threshold—if your bundle grows beyond 300KB, your build fails. I've seen teams add 20KB of dependencies without realizing it. Automated alerts catch this early.

Confusing gzipped vs uncompressed sizes: The bundle analyzer shows uncompressed size by default, but gzip will compress it further (typically 60-70% reduction). Still, uncompressed size matters for parse time on the main thread. Don't dismiss a 50KB library just because it gzips to 15KB.

Trusting tree-shaking without verifying: Libraries claim to be tree-shakeable, but if you're importing the default export or using wildcard imports, you might pull in the whole thing anyway. The analyzer will reveal if tree-shaking isn't working as expected.

Optimizing the wrong thing: If your main bundle is fine but your vendor chunk is massive, that's a different problem. Use code splitting and route-based chunking. Optimize where users feel the pain—usually the above-the-fold initial load.

Key takeaways

  • Install and run @next/bundle-analyzer to visualize what's shipping to your users—you can't optimize blind.
  • Look for the top 10 largest dependencies and question whether each one needs to be in the browser at all.
  • Replace moment.js with date-fns or native Intl APIs to save 50-60KB in a single swap.
  • Move heavy processing to server actions and server components; PDF generation, image resizing, and data transformation don't belong in client bundles.
  • Use dynamic imports for features that aren't critical on initial load to defer bundle size to when they're actually needed.
  • Make bundle analysis automated and part of your deployment pipeline—don't let bloat creep in.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles