NFA
Back to articles
case-study Jan 15, 2025 6 min read

How I Cut Bundle Size by 60% Without Breaking Anything

A detailed case study on optimizing a Next.js application from 2.3MB to 900KB while maintaining all functionality.

NFA

Nanda Fadhil Azman

Software Engineer

Our Next.js e-commerce dashboard had grown to a monstrous 2.3MB initial bundle. Lighthouse scores were suffering, and mobile users on 3G connections were experiencing 8+ second load times. Something had to change.

The scary part? We couldn't just hack away at dependencies. The app needed all its features: real-time charts, PDF exports, rich text editing, and complex data tables. The challenge was maintaining functionality while dramatically reducing bundle size.

Analysis & Discovery

First step: understand what's actually in the bundle. I ran the analyzer and discovered three major culprits:

  1. Charting libraries — Recharts and D3 were accounting for 400KB alone
  2. Moment.js — The infamous 290KB date library (we only used 5% of it)
  3. Lodash imports — Importing the full library instead of specific functions

Tools Used

ToolPurpose
@next/bundle-analyzerVisualize bundle composition
webpack-bundle-analyzerDetailed chunk analysis
compression-webpack-pluginGzip/Brotli compression
terser-webpack-pluginAdvanced minification
javascript
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

module.exports = withBundleAnalyzer({
  experimental: {
    optimizePackageImports: ['lodash', 'date-fns', '@mui/material'],
  },
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.optimization.splitChunks = {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            chunks: 'all',
          },
        },
      }
    }
    return config
  },
})

Solutions Implemented

1. Dynamic Imports with Loading States

The biggest wins came from lazy loading heavy components. Here's how we transformed our chart imports:

typescript
// Before - static import
import HeavyChart from './HeavyChart'

// After - dynamic import with SSR disabled
import dynamic from 'next/dynamic'

const HeavyChart = dynamic(
  () => import('./HeavyChart'),
  {
    ssr: false,
    loading: () => <ChartSkeleton />
  }
)

2. Tree-Shaking Dependencies

We audited all imports and switched to modular versions of libraries:

typescript
// ❌ Bad - imports entire library
import _ from 'lodash'
_.debounce(fn, 300)

// ✅ Good - import specific function
import debounce from 'lodash/debounce'
debounce(fn, 300)

// ✅ Better - use esm version
import { debounce } from 'lodash-es'

3. Components to Lazy Load

Here's our priority list for dynamic imports:

  • Heavy charting libraries (Recharts, D3)
  • Rich text editors (TipTap, Quill)
  • PDF viewers and generators
  • Video players and media components
  • Code editors (Monaco, CodeMirror)
  • Complex form builders

Results

The results exceeded expectations. Here's the before/after comparison:

FileBeforeAfterReduction
main.js1.2 MB380 KB-68%
vendor.js890 KB420 KB-53%
framework.js210 KB100 KB-52%

Total: From 2.3MB to 900KB (60% reduction). Lighthouse performance score jumped from 42 to 89.

Key Lessons

1. Measure First

Don't optimize blindly. Use bundle analyzers to identify actual bottlenecks. We were surprised by which dependencies were the heaviest.

2. Dynamic Imports Are Your Friend

Most heavy components don't need to be in the initial bundle. Good loading states make lazy loading invisible to users.

3. Tree-Shaking Requires Discipline

Establish linting rules to prevent full library imports. One careless import can undo weeks of optimization work.


Bundle optimization is an ongoing process, not a one-time fix. Set up monitoring to catch regressions early. Your users (and your hosting bill) will thank you.