StackPractices
advanced By Mathias Paulenko

Bundle Size Optimization: A Practical Frontend Guide

A hands-on guide to measuring and reducing JavaScript bundle size with tree shaking, code splitting, dependency swaps, compression, and CI budgets.

Introduction

flowchart diagram: Analyze bundle

A bloated JavaScript bundle is one of the fastest ways to hurt Time to Interactive and your Core Web Vitals score. I’ve seen teams ship 2MB JS bundles to mobile users and wonder why their LCP is 6 seconds. The good news is that you can usually shrink it a lot by measuring first, then picking the right combination of tree shaking, code splitting, dependency swaps, compression, and CI budgets.

This guide’s for frontend developers and tech leads who own performance budgets. You should be comfortable with npm, a bundler (webpack, Vite, or Rollup), and basic CI. If you’re new to performance metrics, read our Core Web Vitals guide first — this guide assumes you know what LCP, CLS, and INP measure.

We cover webpack, Vite, and Rollup. Most techniques work across all three; where they differ, I’ll show the config for each. For debounce and throttle patterns that complement bundle optimization, see our debounce and throttle recipe.

When to Use

  • Initial page load feels slow and Lighthouse flags large JavaScript payloads.
  • A dependency audit reveals oversized or duplicate libraries.
  • You’ve got routes or below-the-fold components that can load later.
  • You want to enforce bundle budgets in CI so regressions fail the build.

When NOT to Use

  • The bundle is already small and the real bottleneck is the network or server response time.
  • You’re optimizing before measuring. Use a profiler or analyzer first.
  • Server-side rendering is the main delivery path and HTML weight matters more than JS bundle size.

Analyzing Bundle Size

Before changing anything, get a clear picture of what’s inside the bundle.

Webpack Bundle Analyzer

npm install -D webpack-bundle-analyzer
// webpack.config.js
const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: "static",
      openAnalyzer: false,
      reportFilename: "bundle-report.html",
    }),
  ],
};

Vite Bundle Visualizer

npm install -D rollup-plugin-visualizer
// vite.config.js
import { visualizer } from "rollup-plugin-visualizer";

export default {
  plugins: [
    visualizer({
      filename: "bundle-stats.html",
      gzipSize: true,
      brotliSize: true,
    }),
  ],
};

Source Map Explorer

npm install -D source-map-explorer
npx source-map-explorer dist/*.js

I reach for source-map-explorer when bundle-analyzer shows a big chunk but I can’t tell which source files contributed to it. It reads the source map and shows a tree of what ended up where — useful for tracking down transitive dependencies that snuck in via a sub-import.

Tree Shaking

Tree shaking removes unused exports, but it only works with ES modules and production builds. If you’re importing from CommonJS (require()), the bundler can’t statically analyze what you use, so it includes everything. This is why lodash (CommonJS) ships 70KB even if you only use chunk, while lodash-es (ESM) ships 1KB for the same function. Still wrapping code in IIFEs? The Module Pattern explains the migration to ES modules — the step that actually makes your own code tree-shakeable.

// Bad: imports all of lodash
import _ from "lodash";
const result = _.chunk([1, 2, 3, 4], 2);

// Good: import only the function
import { chunk } from "lodash-es";
const result = chunk([1, 2, 3, 4], 2);

Webpack

// webpack.config.js
module.exports = {
  mode: "production",
  optimization: {
    usedExports: true,
    sideEffects: false,
  },
};
// package.json
{
  "sideEffects": ["*.css", "./src/polyfills.js"]
}

Vite

// vite.config.js
export default {
  build: {
    rollupOptions: {
      treeshake: true,
    },
  },
};

Code Splitting

Code splitting breaks your bundle into smaller chunks that load on demand. The goal is to ship less JavaScript on the initial page load and fetch the rest when the user actually needs it.

Route-based splitting in React

import { lazy, Suspense } from "react";

const Home = lazy(() => import("./pages/Home"));
const About = lazy(() => import("./pages/About"));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  );
}

Component-based splitting

Not everything splits by route. Heavy components — charts, editors, maps — are better split individually so they only load when the user actually interacts with them.

import { lazy, Suspense, useState } from "react";

const Chart = lazy(() => import("./components/Chart"));

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

  return (
    <div>
      <button onClick={() => setShowChart(true)}>Show chart</button>
      {showChart && (
        <Suspense fallback={<div className="chart-skeleton" />}>
          <Chart data={chartData} />
        </Suspense>
      )}
    </div>
  );
}

Webpack splitChunks

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: "all",
      minSize: 20000,
      maxSize: 244000,
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: "vendors",
          chunks: "all",
          priority: 10,
        },
      },
    },
  },
};

Dynamic Imports

// Load module on demand
const module = await import("./heavy-module.js");
module.doSomething();

// Prefetch on hover
button.addEventListener("mouseenter", () => {
  import(/* webpackPrefetch: true */ "./Chart");
}, { once: true });

// Preload critical chunk in parallel
import(/* webpackPreload: true */ "./CriticalChart");

I use prefetch sparingly — it’s easy to over-prefetch and end up loading everything. A good rule: prefetch only the next likely route (e.g., the dashboard when the user is on the login page) and preload only assets the current page will definitely need. If you prefetch five routes, you’re back to shipping the whole bundle, just spread across idle time.

Dependency Replacement

Common swaps that reduce bundle size:

FromToSavings
moment.js (293KB)date-fns (13KB) or dayjs (2KB)large
lodashlodash-es or native methodslarge
axiosnative fetch13KB
uuidcrypto.randomUUID()7KB
// Replace lodash with native array methods
const result = array
  .map((x) => x * 2)
  .filter((x) => x > 10)
  .reduce((sum, x) => sum + x, 0);

// Replace uuid with native crypto
const id = crypto.randomUUID();

// Replace axios with fetch
const res = await fetch("/api/users");
const data = await res.json();

Check sizes on bundlephobia.com before installing a new package. I make this a rule on every PR review — no new dependency without a size check. It’s saved my team from pulling in 500KB libraries more than once.

Compression

Compression is the cheapest win in bundle optimization. Brotli and gzip reduce transfer size by 60-80% with zero code changes. The only question is whether to compress at build time or at the CDN — I prefer build time because it saves CPU on the edge and gives you explicit control.

Build-time compression

// Vite with vite-plugin-compression2
import { compression } from "vite-plugin-compression2";

export default {
  plugins: [
    compression({ algorithm: "gzip", threshold: 10240 }),
    compression({ algorithm: "brotliCompress", threshold: 10240 }),
  ],
};

Nginx static pre-compressed files

server {
  gzip_static on;
  brotli_static on;

  location /assets/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
}

Serve brotli first; gzip is the fallback for older browsers.

CDN-level compression

If you’re on Cloudflare, Fastly, or Vercel, the CDN can auto-compress responses. But pre-compressing at build time is better — it saves CPU on the edge and lets you control quality settings explicitly.

# vercel.json
{
  "functions": {},
  "routes": [
    {
      "src": "/assets/(.*).js",
      "headers": {
        "Content-Encoding": "br",
        "Cache-Control": "public, max-age=31536000, immutable"
      }
    }
  ]
}

Set Cache-Control: immutable for fingerprinted assets (files with hashes in the name). The browser never needs to revalidate them because the hash changes when the content does. I’ve seen teams skip this and pay for it with repeated 304 requests on every navigation.

Polyfill Management

Polyfills are a silent bundle killer. If you import core-js/stable globally, you ship every polyfill to every browser — including modern browsers that don’t need 90% of them. I’ve seen this add 80KB to a bundle that should have been 40KB.

// Bad: import every polyfill
import "core-js/stable";

// Good: import only what you need
import "core-js/stable/promise";
import "core-js/stable/array/flat";

// Better: let Babel inject usage-based polyfills
// babel.config.js
module.exports = {
  presets: [
    ["@babel/preset-env", { useBuiltIns: "usage", corejs: 3 }],
  ],
};

The usage setting scans your code and injects only the polyfills for APIs you actually use, targeted to the browsers in your .browserslistrc. Set your browser targets carefully — if you support IE11, you’ll pull in a lot more polyfills than if you target last 2 versions of modern browsers. For most projects in 2026, dropping IE11 support saves 40-60KB of polyfills alone.

Module Federation

Use module federation to share dependencies across micro-frontends at runtime. This is powerful for large teams with independent deployment cycles — each team ships their own remote entry, and shared dependencies like React are loaded once. But it adds runtime complexity: version negotiation, remote entry caching, and fallback handling. I’d only reach for it when you’ve got 3+ teams deploying independently and the coordination cost of a monorepo is too high.

// webpack.config.js: host
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "host",
      remotes: {
        remoteApp: "remoteApp@https://cdn.example.com/remoteEntry.js",
      },
      shared: {
        react: { singleton: true, requiredVersion: "^19.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^19.0.0" },
      },
    }),
  ],
};
// Host app
import { lazy, Suspense } from "react";

const RemoteWidget = lazy(() => import("remoteApp/Widget"));

function App() {
  return (
    <Suspense fallback={<div>Loading widget...</div>}>
      <RemoteWidget />
    </Suspense>
  );
}

Bundle Monitoring

Once you’ve optimized the bundle, you need to keep it small. Bundle budgets in CI catch regressions before they reach production. I set up budgets on every project — without them, dependencies creep in and the bundle grows 10-20% per quarter.

Size budgets in webpack

// webpack.config.js
module.exports = {
  performance: {
    hints: "warning",
    maxAssetSize: 244000,
    maxEntrypointSize: 244000,
    assetFilter: (filename) => !filename.endsWith(".map"),
  },
};

CI size check with bundlesize

// package.json
{
  "scripts": {
    "size-check": "bundlesize"
  },
  "bundlesize": [
    { "path": "dist/assets/*.js", "maxSize": "100KB" },
    { "path": "dist/assets/*.css", "maxSize": "20KB" }
  ]
}

Image Optimization

Images often outweigh JavaScript in total page weight. I’ve seen landing pages with 4MB of unoptimized PNGs sitting next to a 200KB JS bundle — the team was optimizing the wrong thing.

Modern formats

<picture>
  <source srcset="/img/hero.avif" type="image/avif" />
  <source srcset="/img/hero.webp" type="image/webp" />
  <img src="/img/hero.jpg" alt="Dashboard overview" width="1200" height="630" loading="lazy" />
</picture>

AVIF compresses 50% better than JPEG. WebP’s the fallback with 25-35% savings. Serve both with <picture> and a JPEG fallback for ancient browsers.

Responsive images

<img
  srcset="/img/hero-480.webp 480w, /img/hero-800.webp 800w, /img/hero-1200.webp 1200w"
  sizes="(max-width: 600px) 480px, (max-width: 900px) 800px, 1200px"
  src="/img/hero-800.webp"
  alt="Dashboard"
  loading="lazy"
  decoding="async"
/>

Don’t ship a 1200px image to a 375px phone. srcset lets the browser pick the right size. I learned this the hard way when Lighthouse flagged a 1.2MB hero image on mobile that was only displayed at 375px wide.

Build-time image optimization

npm install -D vite-imagetools
// vite.config.js
import { imagetools } from "vite-imagetools";

export default {
  plugins: [
    imagetools({
      defaultDirectives: (url) => ({
        format: "webp",
        quality: 80,
      }),
    }),
  ],
};

For webpack, use image-webpack-loader to auto-optimize images at build time. Set quality to 80 for photos and 90 for screenshots with text.

Font Loading

Fonts block text rendering if you don’t handle them carefully. A custom font that takes 2 seconds to load causes a flash of invisible text (FOIT) or a flash of unstyled text (FOUT).

font-display swap

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-var.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap;
}

font-display: swap shows fallback text immediately and swaps when the font loads. Use optional for non-critical fonts — the browser’ll skip loading them entirely on slow connections.

Preload critical fonts

<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin />

Preload the one font file that matters for above-the-fold text. Don’t preload every weight — I once saw a site preload 12 font files, adding 1.8MB to the critical path.

Subset fonts

npx fonttools subsetting --input inter.ttf --output inter-subset.woff2 --unicodes "U+0020-007F"

Subset to the characters you actually use. A full Inter variable font is 800KB. Subset to Latin and it drops to 60KB. For a Spanish site, include Latin Extended (U+00C0-00FF) for accented characters. Use woff2 — it’s 30% smaller than woff.

CSS Optimization

CSS is smaller than JS, but unused CSS still blocks rendering. A 200KB CSS file with 80% unused selectors slows down every page load.

Purge unused CSS

// vite.config.js with Tailwind v4
import { tailwindcss } from "@tailwindcss/vite";

export default {
  plugins: [tailwindcss()],
};

Tailwind v4 purges automatically based on your content files. For non-Tailwind projects, use PurgeCSS:

// postcss.config.js
const purgecss = require("@fullhuman/postcss-purgecss");

module.exports = {
  plugins: [
    purgecss({
      content: ["./src/**/*.html", "./src/**/*.tsx"],
      defaultExtractor: (content) => content.match(/[\w-/:]+(?<!:)/g) || [],
    }),
  ],
};

I’ve seen CSS bundles drop from 280KB to 12KB after purging. The catch: dynamic class names constructed at runtime get purged. Safelist them or use complete class names in your templates.

Critical CSS

Extract above-the-fold CSS and inline it in <head> to eliminate render-blocking:

<style>
  /* Critical CSS: hero, nav, typography */
  body { margin: 0; font-family: system-ui, sans-serif; }
  .hero { min-height: 60vh; }
</style>
<link rel="stylesheet" href="/css/full.css" media="print" onload="this.media='all'"" />

Use critters for Astro/Next.js or critical for static HTML. The async CSS load trick (media="print" + onload) defers the full stylesheet without blocking first paint.

Service Worker Caching

A service worker can cache assets locally, eliminating repeat downloads. This doesn’t reduce initial bundle size, but it dramatically improves repeat visit performance.

// sw.js
const CACHE = "app-v1";
const ASSETS = ["/", "/css/main.css", "/js/app.js"];

self.addEventListener("install", (event) => {
  event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(ASSETS)));
});

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => cached || fetch(event.request)),
  );
});

Use Workbox for production-grade caching strategies:

// sw.js with Workbox
import { registerRoute } from "workbox-routing";
import { CacheFirst, StaleWhileRevalidate } from "workbox-strategies";

registerRoute(
  ({ request }) => request.destination === "image",
  new CacheFirst({ cacheName: "images" }),
);

registerRoute(
  ({ request }) => request.destination === "script",
  new StaleWhileRevalidate({ cacheName: "scripts" }),
);

Cache images with CacheFirst (they rarely change) and scripts with StaleWhileRevalidate (serve cached, fetch updated in background). For more on event loop behavior that affects when service workers run, see our JavaScript event loop recipe.

Real-World Case Study: 2.4MB to 380KB

I worked on a React dashboard that shipped a 2.4MB JS bundle. Here’s what we did and the impact of each step:

StepTechniqueBundle sizeSavings
0Baseline2,400KB—
1Replace moment.js with dayjs2,120KB-280KB
2Replace lodash with lodash-es + tree shaking1,780KB-340KB
3Route-based code splitting (5 routes)1,100KB initial-680KB initial
4Component splitting for charts (Recharts)680KB initial-420KB initial
5Remove unused dependencies (npm prune)620KB-60KB
6Brotli compression380KB transferred-240KB transferred

The key insight: steps 1-2 were quick wins that took an afternoon. Step 3 was the biggest single win — most users only visited one route, so loading all five upfront was pure waste. Step 4 moved the charting library out of the initial bundle entirely. The charts only loaded when the user clicked the “Analytics” tab.

After these changes, LCP dropped from 4.2s to 1.1s on a mid-range Android phone with 3G. Total effort? About 2 days. The lesson: measure first, target the biggest items, and don’t over-engineer — we never needed module federation.

Best Practices

  • Measure first with a bundle analyzer. I wasted a day swapping dependencies before realizing 60% of the bundle was a single charting library I didn’t need to load upfront.
  • Prefer native APIs over libraries when the browser already supports what you need — fetch beats axios, crypto.randomUUID() beats uuid.
  • Split by route first, then by heavy below-the-fold components.
  • Review dependency diffs before bumping a package version — a minor update can double a package’s size.
  • Set bundle budgets in CI and fail builds that exceed them. Without enforcement, budgets are just suggestions.
  • Compress assets with both gzip and brotli, and serve .br when possible.

Common Mistakes

  • Guessing the bottleneck: I’ve done this — spent hours tree-shaking when the real issue was a 3MB image. Analyze first.
  • Importing full libraries: import _ from "lodash" pulls in the entire package. Use lodash-es with named imports instead — it’s a one-line fix.
  • Splitting too granularly: hundreds of tiny chunks hurt caching and add HTTP overhead. I aim for 5-15 route-level chunks, not 200 component chunks.
  • Ignoring compression: serving uncompressed JS wastes bandwidth. Brotli is free savings.
  • Forgetting polyfill scope: global polyfills bloat modern browsers. Use usage-based injection.
  • Over-engineering module federation: it adds complexity that small teams rarely need. I’ve seen teams adopt it for a 2-person project — it was overkill.

See Also

Frequently Asked Questions

What is tree shaking and how does it work?

Tree shaking removes unused exports from ES modules. It needs import/export syntax, a production build, and packages marked as side-effect free. Webpack requires mode: "production"; Vite and Rollup do it by default.

How is code splitting different from tree shaking?

Tree shaking removes dead code. Code splitting breaks the bundle into smaller chunks loaded on demand. Use both: tree shake first, then split by route or heavy component.

What is the difference between prefetch and preload?

Prefetch loads a resource during idle time for likely future use. Preload loads it immediately in parallel with the current page. Use webpackPrefetch for next routes and webpackPreload for critical current-page assets.

What tools identify which dependencies are bloating my bundle?

Use webpack-bundle-analyzer, rollup-plugin-visualizer, or source-map-explorer. Check sizes on bundlephobia.com before installing. Run npm ls to spot duplicate dependencies.

Should I use gzip or brotli compression?

Use both. Brotli compresses text 15-25% better. Serve .br first and .gz as fallback. Most CDNs and static hosts support brotli.

When should I consider module federation for bundle size?

Module federation lets several apps share dependencies at runtime, reducing duplicate code. It adds runtime overhead for loading remote entries. Use it for large teams with independent deployments, not small apps.