ResourcesWeb PerformanceReducing JS Bundle Size: The Tools and Tricks That Work
Web PerformanceReducing JS Bundle Size: The Tools and Tricks That Work7 min

Reducing JS Bundle Size: The Tools and Tricks That Work

A practical guide to finding and eliminating the JavaScript that's slowing down your app, with real tools and real numbers.

📅February 2, 2026TechTwitter.ioweb-performancejavascriptbundlingoptimization

Why Bundle Size Matters

JavaScript is the most expensive resource on the web — expensive to download, parse, and execute. A 1MB JS bundle on a mid-range phone can take 8-10 seconds to parse before any JavaScript executes.

The goal isn't minimalism for its own sake — it's getting interactive faster.


Step 1: Measure What You Have

You can't optimize what you don't measure.

Bundle Analyzer

# For Next.js
npm install -D @next/bundle-analyzer

# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({})

# Run analysis
ANALYZE=true next build

This opens a treemap showing exactly what's in your bundle and how big each piece is. Usually you'll find one or two packages that account for 40-60% of the total.

Bundlephobia

Before installing a package, check its cost at bundlephobia.com. It shows gzipped size and load time estimates.


Step 2: Kill the Big Offenders

Moment.js → date-fns or Temporal

Moment.js is 280KB minified, 67KB gzipped. date-fns lets you import only what you use:

// Before: 67KB gzipped
import moment from 'moment'
const formatted = moment(date).format('YYYY-MM-DD')

// After: ~2KB for these specific functions
import { format } from 'date-fns'
const formatted = format(date, 'yyyy-MM-dd')

Lodash → native or lodash-es

// Before: imports entire lodash (70KB)
import _ from 'lodash'
_.debounce(fn, 300)

// After option 1: named import (still pulls in lodash internals)
import { debounce } from 'lodash'

// After option 2: direct function import (~2KB)
import debounce from 'lodash/debounce'

// After option 3: native (zero KB)
const debounce = (fn, ms) => {
  let timeout
  return (...args) => {
    clearTimeout(timeout)
    timeout = setTimeout(() => fn(...args), ms)
  }
}

Icon Libraries

// Before: pulls in every icon (2MB+)
import { FiArrowRight } from 'react-icons/fi'

// After: direct import (~1KB)
import FiArrowRight from 'react-icons/fi/FiArrowRight'

Step 3: Code Split Aggressively

Split your bundle so users only download what they need for the current page.

Dynamic Imports

// Before: everything loads upfront
import { HeavyEditor } from './Editor'
import { ReportGenerator } from './Reports'

// After: load when needed
const HeavyEditor = lazy(() => import('./Editor'))
const ReportGenerator = lazy(() => import('./Reports'))

// In component:
<Suspense fallback={<Skeleton />}>
  {showEditor && <HeavyEditor />}
</Suspense>

Route-based Splitting (Next.js)

Next.js does this automatically — each page is a separate chunk. Ensure you're not importing heavy things at the root layout level.


Step 4: Tree Shake Properly

Tree shaking removes unused exports, but only works with ES modules:

// Good: tree-shakeable (ES module named export)
export function add(a, b) { return a + b }
export function subtract(a, b) { return a - b }

// Bad: not tree-shakeable (CommonJS)
module.exports = { add, subtract }

Check your dependencies: some packages ship only CommonJS (require) which can't be tree-shaken. Look for packages with "module" field in package.json or sideEffects: false.


Step 5: Defer Non-Critical JavaScript

<!-- Blocks HTML parsing — don't do this -->
<script src="app.js"></script>

<!-- Parses HTML, executes after DOM ready -->
<script src="app.js" defer></script>

<!-- Downloads in parallel, executes immediately when done -->
<script src="analytics.js" async></script>

Use defer for your app bundle. Use async for truly independent scripts like analytics.


Step 6: Measure the Real Impact

Bundle size is a proxy metric. Measure what users actually experience:

// Measure Time to Interactive in the console
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'first-contentful-paint') {
      console.log('FCP:', entry.startTime)
    }
  }
}).observe({ type: 'paint', buffered: true })

Use Lighthouse to get TTI (Time to Interactive) — this is the real metric that bundle size affects.


Quick Wins Checklist

ActionExpected Saving
Replace Moment.js with date-fns~65KB
Replace full lodash with per-function imports~50KB
Fix icon library imports~100KB+
Add bundle analyzer and auditFind your specific culprits
Lazy load below-fold components20-40% reduction in initial bundle
Enable gzip/brotli on server60-80% transfer size reduction

Key Takeaways

  • Use @next/bundle-analyzer to visualize what's in your bundle
  • The biggest wins are almost always one or two large packages (Moment, Lodash, icon sets)
  • Code split with dynamic imports — load code when it's needed, not upfront
  • Tree shaking requires ES modules — check your dependencies
  • Gzip/Brotli compression on the server is free and cuts transfer size by 60-80%