Type something to search...
Optimizing Bundle Size in React Applications

Optimizing Bundle Size in React Applications

Every kilobyte of JavaScript you ship has to be downloaded, parsed, compiled, and executed before your app becomes interactive. On a fast laptop with fiber internet, a 2 MB bundle feels fine. On a mid-range phone over a patchy mobile connection, it can mean five or more seconds of a blank or frozen screen.

Bundles rarely get big on purpose. They grow one import at a time: a date library for one formatting call, an icon pack where you use six icons, a chart library loaded on the login page. None of these decisions looks expensive alone, but together they add up.

This post is a practical workflow for shrinking a React bundle. You'll measure what's in it, fix imports that defeat tree-shaking, replace heavy dependencies, split code by route and by interaction, and set up a size budget so the bundle doesn't quietly grow back. The examples use Vite, but the ideas apply to any bundler.

Measure First

Don't start by removing things. Start by finding out what's actually there. Build your app and look at the output:

npm run build

Vite prints every output file with its raw and gzip size. That tells you how big the chunks are, but not what's inside them. For that, use a visualizer. With Vite, rollup-plugin-visualizer generates an interactive treemap:

npm install -D rollup-plugin-visualizer
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { visualizer } from "rollup-plugin-visualizer";

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

Run the build again and stats.html opens in your browser. Each rectangle is a module, sized by how much it contributes. Look for:

  • Large dependencies you didn't expect, like an entire icon set or a locale bundle.
  • Duplicates, such as two versions of the same library pulled in by different packages.
  • Code that belongs to one page sitting in the main entry chunk.

If you use a different bundler, source-map-explorer works on any build with source maps:

npx source-map-explorer "dist/assets/*.js"

Write down the size of your main chunk before you change anything. Gzipped or brotli size is what users download, but parsed size matters too, because the browser has to compile all of it.

Make Tree-Shaking Work

Tree-shaking is the bundler's ability to drop exports you never import. It only works when the code is written as ES modules (import and export) and the bundler can tell that unused code has no side effects.

Import Only What You Use

Some libraries are tree-shakable, some aren't. The classic example is lodash:

// Pulls in all of lodash (around 70 KB minified)
import _ from "lodash";
const debounced = _.debounce(save, 300);

// Still all of lodash in many setups (CommonJS package)
import { debounce } from "lodash";

Use the ES module build instead, or import the specific function:

// Tree-shakable ES module build
import { debounce } from "lodash-es";

// Or a single-function import
import debounce from "lodash/debounce";

Better still, check whether you need the library at all. Many lodash helpers have native equivalents now (structuredClone, Array.prototype.flat, Object.groupBy, optional chaining), and a debounce is a dozen lines, as shown in Debouncing and Throttling User Input in React.

Avoid Namespace Imports from Large Packages

// Can prevent tree-shaking in some bundlers and patterns
import * as Icons from "lucide-react";
const Icon = Icons[name];

Dynamic property access on a namespace means the bundler can't know which icons you use, so it keeps all of them. Import named icons directly and map them yourself:

import { Home, Settings, User, type LucideIcon } from "lucide-react";

const icons: Record<string, LucideIcon> = {
  home: Home,
  settings: Settings,
  user: User,
};

export function NavIcon({ name }: { name: string }) {
  const Icon = icons[name] ?? Home;
  return <Icon size={18} aria-hidden="true" />;
}

Mark Your Own Packages as Side-Effect Free

If you maintain a shared component library in a monorepo, add sideEffects to its package.json so bundlers can drop unused modules safely:

{
  "name": "@acme/ui",
  "type": "module",
  "sideEffects": ["**/*.css"]
}

This says "no file in this package does anything just by being imported, except the CSS files." Don't set it to false if any module registers globals or polyfills on import.

Replace Heavy Dependencies

Some libraries are simply large. The visualizer will show you which ones. Common swaps:

HeavyLighter alternativeNotes
momentdate-fns, dayjs, or Intl.DateTimeFormatMoment isn't tree-shakable and ships locales
lodashlodash-es or native methodsImport per function
axiosfetchNative in all modern browsers
uuidcrypto.randomUUID()Native in modern browsers on HTTPS pages
Full chart library on every pageLazy-load the chart componentSee code splitting below

Before adding a new dependency, check its size on Bundlephobia or pkg-size.dev. A few seconds of checking saves a lot of removing later.

The Intl APIs replace a surprising amount of formatting code:

const price = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
}).format(1299.5); // "$1,299.50"

const date = new Intl.DateTimeFormat("en-GB", {
  dateStyle: "medium",
}).format(new Date("2026-10-03")); // "3 Oct 2026"

const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(
  -1,
  "day"
); // "yesterday"

That's zero kilobytes of library code. For a broader look at fetch versus wrapper libraries, see Fetching Data in React: fetch, Axios, and Beyond.

Split Code by Route

Most users visit one or two pages per session. They shouldn't download every page's code up front. Route-based splitting creates a separate chunk per page that loads when the user navigates there.

With React Router v7 in data mode, use the lazy property on routes:

// router.tsx
import { createBrowserRouter } from "react-router";
import RootLayout from "./RootLayout";
import Home from "./pages/Home";

export const router = createBrowserRouter([
  {
    path: "/",
    Component: RootLayout,
    children: [
      { index: true, Component: Home },
      {
        path: "reports",
        lazy: async () => {
          const { default: Component } = await import("./pages/Reports");
          return { Component };
        },
      },
      {
        path: "settings",
        lazy: async () => {
          const { default: Component } = await import("./pages/Settings");
          return { Component };
        },
      },
    ],
  },
]);
// main.tsx
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router";
import { router } from "./router";

createRoot(document.getElementById("root")!).render(
  <RouterProvider router={router} />
);

Each import() call becomes its own chunk. The router loads it during navigation, before rendering the route. If you aren't using a data router, React.lazy with <Suspense> does the same job, as covered in Code Splitting in React with React.lazy and Suspense.

Split Code by Interaction

Route splitting handles pages. Within a page, there's often heavy code that only runs after a user action: a rich text editor in a modal, a PDF export, a date picker, a markdown renderer. Load those on demand.

Lazy Components Behind a Condition

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

const ChartPanel = lazy(() => import("./ChartPanel"));

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

  return (
    <section>
      <h2>Monthly revenue</h2>
      <button onClick={() => setShowChart(true)}>Show chart</button>
      {showChart && (
        <Suspense fallback={<p>Loading chart...</p>}>
          <ChartPanel />
        </Suspense>
      )}
    </section>
  );
}

The chart library is downloaded only when someone clicks the button.

Dynamic Import Inside an Event Handler

For non-component code, import the module inside the handler:

export function ExportButton({ rows }: { rows: Record<string, unknown>[] }) {
  async function handleExport() {
    const { utils, writeFile } = await import("xlsx");
    const sheet = utils.json_to_sheet(rows);
    const book = utils.book_new();
    utils.book_append_sheet(book, sheet, "Report");
    writeFile(book, "report.xlsx");
  }

  return <button onClick={handleExport}>Export to Excel</button>;
}

The spreadsheet library stays out of the bundle until someone actually exports.

Preload on Intent

On-demand loading adds a delay at the moment of the click. You can hide most of it by starting the download when the user shows intent, like hovering or focusing the button:

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

const loadEditor = () => import("./RichEditor");
const RichEditor = lazy(loadEditor);

export function CommentBox() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button
        onMouseEnter={loadEditor}
        onFocus={loadEditor}
        onClick={() => setOpen(true)}
      >
        Write a comment
      </button>
      {open && (
        <Suspense fallback={<p>Loading editor...</p>}>
          <RichEditor />
        </Suspense>
      )}
    </>
  );
}

Calling import() twice for the same module is safe. The browser and bundler cache the module, so the second call resolves from the same promise.

Let the Build Target Modern Browsers

Transpiling modern syntax down for old browsers adds code. Vite's default build target is a set of modern browsers, so most apps already avoid heavy polyfills. Check that you haven't lowered it without a reason:

// vite.config.ts
export default defineConfig({
  build: {
    target: "es2022",
  },
});

If you genuinely need legacy browser support, @vitejs/plugin-legacy builds a separate legacy bundle that only old browsers download, so modern users don't pay for it.

Ship Less in Production

A few settings make sure development-only code doesn't reach users:

  • Always build in production mode. React's development build includes warnings and checks and is several times larger. vite build uses production mode by default.
  • Guard dev-only code with import.meta.env.DEV. The bundler replaces it with false in production and removes the dead branch.
  • Check that devtools libraries are excluded. TanStack Query Devtools, for example, are only included in development builds by default, but custom debug panels should be gated by an env check.
import { lazy, Suspense } from "react";

const DebugPanel = import.meta.env.DEV
  ? lazy(() => import("./DebugPanel"))
  : () => null;

export function AppShell({ children }: { children: React.ReactNode }) {
  return (
    <>
      {children}
      <Suspense>
        <DebugPanel />
      </Suspense>
    </>
  );
}

Also make sure your server sends JavaScript with Brotli or gzip compression. Most hosting platforms do this automatically. If you serve files yourself, a missing compression setting can triple the transfer size.

Prevent Regressions with a Size Budget

All of this work disappears the moment someone adds a big dependency without noticing. A size budget in CI catches that. size-limit checks your output files against limits:

npm install -D size-limit @size-limit/file
{
  "scripts": {
    "build": "vite build",
    "size": "size-limit"
  },
  "size-limit": [
    {
      "path": "dist/assets/index-*.js",
      "limit": "150 kB"
    }
  ]
}

Run npm run build && npm run size in your CI pipeline. If the main chunk grows past 150 kB (gzipped by default), the job fails and the pull request shows exactly how much it grew.

Common Mistakes When Optimizing Bundle Size

  • Optimizing without a visualizer. You'll trim small things and miss the 300 KB library hiding in the entry chunk.
  • Importing a whole library for one function. Check whether a native API or a per-function import does the job.
  • Lazy-loading tiny components. Every chunk is an extra request. Split large, rarely used code, not every button.
  • Lazy-loading what's above the fold. Content the user sees immediately should be in the initial bundle, or you trade a smaller bundle for a slower first render.
  • Forgetting about duplicates. Two versions of the same library can sneak in through dependencies. Use npm ls <package> and dedupe where possible.
  • No budget in CI. Without a check, bundle size only goes up.

Frequently Asked Questions (FAQ) About Bundle Size in React

There's no single number, but a common target is keeping the JavaScript needed for the first page under about 150 to 200 KB compressed. React and React DOM alone are around 60 KB gzipped, so budget the rest carefully. Measure Time to Interactive on a throttled mobile device to see whether your number is good enough.

Both. Compressed size decides download time, which matters on slow networks. Uncompressed size decides parse and compile time, which matters on slow CPUs. A large but highly compressible file can still be slow on a low-end phone.

Not always. It reduces the initial download, but each split chunk adds a request and a short delay when it's needed. Split by routes and by heavy features that many users never open. Avoid splitting small components that are needed right away.

Common causes are CommonJS packages, namespace imports with dynamic property access, and modules that the bundler believes have side effects. Prefer ES module builds of libraries, import named exports directly, and check the library's documentation for a tree-shakable entry point.

Slightly. The compiler adds memoization code to each component it optimizes, so output files can grow a little. In most apps the difference is small compared to dependencies, and the runtime savings from fewer re-renders usually outweigh it.

Run npm ls followed by the package name to see the dependency chain. The visualizer treemap also groups modules by their path inside node_modules, which shows you whether a library is a direct dependency or came in through another package.

Conclusion

Bundle optimization is a loop: measure with a visualizer, fix the biggest problem, and measure again. The largest wins usually come from a short list of changes: tree-shakable imports, replacing heavy libraries with native APIs or lighter alternatives, splitting code by route, and loading rarely used features on demand.

Start by adding the visualizer to your build today and noting your main chunk size. Pick the largest unexpected rectangle and deal with it, then set up size-limit so the improvement sticks. If you're still on an older toolchain, moving to Vite gives you fast builds and good splitting defaults to build on.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading