Skip to main content

Bundle Size Optimization: Tools and Techniques for 2026

·PkgPulse Team
0

Every kilobyte of JavaScript you ship costs your users time and your business money. A 100KB bundle takes 1-2 seconds to parse on a mid-range phone. For e-commerce, each extra second of load time reduces conversions by 7%.

Here's how to measure, analyze, and shrink your JavaScript bundles in 2026.

Why Bundle Size Matters

The Performance Tax

Bundle SizeParse Time (Mobile)Parse Time (Desktop)
100 KB200-300ms50-100ms
500 KB1-1.5s200-400ms
1 MB2-3s400-800ms
2 MB4-6s800ms-1.5s

This is just parse time — before the code even runs. Add execution time, and large bundles create a noticeably sluggish experience.

The SEO Impact

Google's Core Web Vitals directly factor in JavaScript performance:

  • Largest Contentful Paint (LCP) — Heavy bundles delay content rendering
  • Interaction to Next Paint (INP) — Large bundles block the main thread
  • Time to First Byte (TTFB) — Larger transfers take longer

Step 1: Measure What You Have

Before optimizing, measure. You can't improve what you don't understand.

Bundle Analysis Tools

webpack-bundle-analyzer

The classic. Generates an interactive treemap of your bundle contents.

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

# Add to webpack config
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
  plugins: [new BundleAnalyzerPlugin()]
};

source-map-explorer

Lighter weight than webpack-bundle-analyzer. Works with any build tool that generates source maps.

npx source-map-explorer dist/bundle.js

@next/bundle-analyzer

Purpose-built for Next.js projects:

npm install @next/bundle-analyzer

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

# Run
ANALYZE=true npm run build

bundlephobia.com

Check the size of any npm package before you install it. Shows minified size, gzipped size, download time, and composition.

Check package sizes on PkgPulse too — we show bundle size alongside health scores and download trends.

Step 2: Tree-Shaking

Tree-shaking removes unused code from your bundle. Modern bundlers (Vite, webpack 5, Rspack) do this automatically for ES modules.

Make Sure It Works

Tree-shaking only works with ES module syntax (import/export). CommonJS (require) can't be tree-shaken.

// ✅ Tree-shakeable — only 'debounce' is included in the bundle
import { debounce } from 'lodash-es';

// ❌ NOT tree-shakeable — entire lodash library is included
const { debounce } = require('lodash');

Choose Tree-Shakeable Packages

When comparing packages on PkgPulse, prefer those that ship ES modules. Look for "module" or "exports" fields in package.json.

Common swaps for smaller bundles:

Heavy PackageLighter AlternativeSize Reduction
lodash (72KB)lodash-es (tree-shakeable)60-90%
moment (72KB)date-fns (tree-shakeable)80-95%
axios (13KB)ky (3KB) or native fetch75-100%
uuid (3.5KB)crypto.randomUUID() (0KB)100%

Step 3: Code Splitting

Don't load everything upfront. Split your bundle into chunks that load on demand.

Route-Based Splitting (React)

import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

Component-Based Splitting

Lazy-load heavy components that aren't immediately visible:

// Only load the chart library when the user scrolls to the chart section
const Chart = lazy(() => import('./components/Chart'));

function Analytics() {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <h1>Analytics</h1>
      <button onClick={() => setShowChart(true)}>Show Chart</button>
      {showChart && (
        <Suspense fallback={<ChartSkeleton />}>
          <Chart />
        </Suspense>
      )}
    </div>
  );
}

Dynamic Imports for Heavy Libraries

// Don't import marked at the top level
// import { marked } from 'marked'; // ❌ 40KB added to main bundle

// Load it when needed
async function renderMarkdown(text) {
  const { marked } = await import('marked'); // ✅ Loaded on demand
  return marked(text);
}

Step 4: Replace Heavy Dependencies

The biggest wins often come from swapping out bloated packages.

Date Libraries

LibrarySize (min+gzip)Tree-Shakeable
Moment.js72KB❌ (deprecated)
date-fns2-10KB (per function)
Day.js2.9KBPartial
Temporal (native)0KBN/A (built-in)

UI Component Libraries

LibraryFull SizeWith Tree-Shaking
Ant Design340KB~80-150KB
Material UI300KB~60-120KB
Radix + Tailwind~20-40KB~15-30KB
shadcn/ui~15-30KB~10-25KB

Compare component libraries on PkgPulse to make informed choices.

Step 5: Compression

Gzip vs Brotli

CompressionTypical RatioBrowser Support
None1xAll
Gzip3-5x99%+
Brotli4-6x97%+

Brotli compresses 15-20% better than gzip. Most CDNs and hosting providers support it. Enable it:

// Next.js (next.config.js)
module.exports = {
  compress: true, // Gzip by default
};

// Vite (vite.config.js)
import viteCompression from 'vite-plugin-compression';
export default {
  plugins: [
    viteCompression({ algorithm: 'brotliCompress' }),
  ],
};

Step 6: Set a Budget

Define a bundle size budget and enforce it in CI:

// bundlesize config in package.json
{
  "bundlesize": [
    {
      "path": "./dist/main.*.js",
      "maxSize": "150 kB"
    },
    {
      "path": "./dist/vendor.*.js",
      "maxSize": "250 kB"
    }
  ]
}

Or use Lighthouse CI with performance budgets:

{
  "ci": {
    "assert": {
      "assertions": {
        "resource-summary:script:size": ["error", { "maxNumericValue": 300000 }]
      }
    }
  }
}

Optimization Checklist

  1. ✅ Analyze your bundle with a visualization tool
  2. ✅ Ensure tree-shaking works (ES modules, sideEffects field)
  3. ✅ Code-split routes and heavy components
  4. ✅ Replace heavy dependencies with lighter alternatives
  5. ✅ Enable Brotli compression
  6. ✅ Set and enforce a bundle size budget
  7. ✅ Lazy-load below-the-fold content
  8. ✅ Use dynamic imports for rarely-used features
  9. ✅ Remove unused CSS (PurgeCSS or Tailwind's built-in purge)
  10. ✅ Optimize images (next/image, sharp, AVIF format)

Common Mistakes That Inflate Bundle Size

Most bundle bloat is caused by a small set of recurring mistakes. If your bundles are larger than expected, check these first.

Importing Entire Libraries When You Need One Function

This is the most common and most preventable problem. Many developers import from CommonJS packages without realizing the entire library lands in the bundle.

// ❌ Ships all of lodash (72KB min+gzip)
import _ from 'lodash';
const unique = _.uniq(arr);

// ❌ Still ships all of lodash with CommonJS
import { uniq } from 'lodash';

// ✅ Ships only the uniq function (~1KB)
import { uniq } from 'lodash-es';

// ✅ Or use the native equivalent (0KB)
const unique = [...new Set(arr)];

The same problem affects icon libraries. react-icons and similar packages can add 100KB+ if you import from the top-level package instead of the specific icon subpath.

// ❌ Imports all react-icons/fa (hundreds of icons)
import { FaCheck } from 'react-icons/fa';

// ✅ Use @heroicons/react which is tree-shakeable by design
import { CheckIcon } from '@heroicons/react/24/solid';

Over-Eager Dynamic Imports

The flip side of the lazy-loading mistake: teams sometimes split too aggressively. Every import() creates a new network request. If a component is small (< 10KB) and renders on most page loads, lazy-loading it adds latency (waterfall request) without reducing initial load.

A practical rule: only split routes or components that are not shown on the initial render and are > 30KB in the bundle.

Forgetting sideEffects: false

Tree-shaking only eliminates code that the bundler can prove is unreachable. But many packages import files as side effects (for CSS, polyfills, or global state). Without sideEffects: false in package.json, bundlers treat every export as potentially having side effects and skip tree-shaking.

If you're publishing a library, add this to your package.json:

{
  "sideEffects": false
}

If specific files do have side effects (CSS imports, polyfill inits), list them explicitly:

{
  "sideEffects": ["*.css", "./src/polyfills.js"]
}

Not Auditing Transitive Dependencies

The package you install might be small, but its dependencies may not be. A 5KB utility that depends on lodash (CommonJS) and moment.js will balloon your bundle. Always check bundlephobia.com for the "Dependencies" section — it shows what the package pulls in transitively.


Advanced Techniques: Module Federation and Micro-Frontends

For large applications split across teams, Module Federation (introduced in Webpack 5, supported in Rspack) offers a fundamentally different approach to bundle size: sharing modules between separately deployed applications at runtime.

// webpack.config.js — app-shell exposes shared components
const { ModuleFederationPlugin } = require('@module-federation/enhanced');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'app_shell',
      filename: 'remoteEntry.js',
      exposes: {
        './Button': './src/components/Button',
        './UserContext': './src/contexts/UserContext',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^19.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
      },
    }),
  ],
};

With Module Federation, react and react-dom load once across all micro-frontends, and shared components don't get duplicated. For organizations with 5+ teams shipping parts of one product, this can reduce total JavaScript delivered by 40-60%.

The trade-off: significantly more operational complexity. Module Federation works best when you have dedicated platform engineering capacity to manage the runtime dependency graph.


Performance Budgets in Practice

Setting a bundle size budget is straightforward. Enforcing it without annoying your team is harder. Here's a practical budget strategy that balances strictness with developer experience.

Separate Budgets for Initial and Async Chunks

Your initial bundle (what loads before the user can interact) deserves a strict budget. Async chunks loaded on demand are less critical.

// package.json with bundlesize
{
  "bundlesize": [
    {
      "path": "./dist/static/chunks/main-*.js",
      "maxSize": "80 kB",
      "compression": "brotli"
    },
    {
      "path": "./dist/static/chunks/pages/index-*.js",
      "maxSize": "50 kB",
      "compression": "brotli"
    },
    {
      "path": "./dist/static/chunks/pages/**/*.js",
      "maxSize": "150 kB",
      "compression": "brotli"
    }
  ]
}

Tracking Budget Over Time

Point-in-time budget checks miss gradual creep. A page that is 78KB today might be 82KB in three months — still under budget individually, but the trend reveals a problem. Add bundle size tracking to your monitoring pipeline:

# In CI, output bundle stats to a file and store as an artifact
npm run build -- --json > bundle-stats.json

# Compare against main branch
npx bundlewatch --config bundlewatch.config.json

Tools like Bundlewatch can post bundle size diffs as GitHub PR comments, making growth visible before it merges.


Real-World Decision Framework: When to Optimize

Not every bundle needs optimization. Use this framework to decide where to spend time.

Step 1: Check real user data first. Use Google Search Console or your analytics to identify which pages have the highest traffic. A slow admin dashboard used by 5 people matters less than a 200KB product page seen by 100,000 users per day.

Step 2: Measure actual Core Web Vitals. PageSpeed Insights and Lighthouse reveal whether bundle size is the real bottleneck. Often, images (not JavaScript) are the biggest LCP problem. Don't optimize JavaScript when you should be optimizing images.

Step 3: Run the bundle analyzer on your highest-traffic routes. Look for unexpected packages, duplicate dependencies (the same library appearing twice in different versions), and large libraries used for small features.

Step 4: Apply the 80/20 rule. In most codebases, 3-5 packages account for 80% of bundle weight. Replacing or lazy-loading those specific packages gives more ROI than optimizing 20 small utilities.

Step 5: Set a budget before the next dependency. Once you've optimized, the most important thing is staying there. A bundle size check in CI prevents regression without requiring manual review.

The goal isn't the smallest possible bundle — it's a bundle small enough that users don't notice it. On modern broadband, 100-150KB of compressed JavaScript is invisible. Below 50KB is excellent. Above 500KB without lazy loading is where real user experience damage occurs.


Conclusion

Bundle size optimization isn't a one-time task — it's an ongoing discipline. Start by measuring, then make targeted improvements where the data shows the biggest impact. The tools and techniques in 2026 make it easier than ever to ship fast, lean JavaScript.

Use PkgPulse to compare package sizes before adding new dependencies — it's the easiest way to prevent bundle bloat before it starts.

Related: Farm vs Vite vs Turbopack: Next-Gen Bundlers 2026, Farm vs Vite vs Turbopack: Next-Gen Bundlers 2026, Rspack vs Webpack: Speed Benchmarks 2026.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.