Skip to content
ReactField

TopicsIntermediate

Bundle Size Optimization: Ship Less JavaScript

Measure and shrink the JavaScript your React app ships: code splitting, tree shaking, lighter libraries, Server Components, third-party scripts and budgets.

Mohamed ElshahawyPublished Apr 2026Updated Oct 2026
11 min read

What you'll learn

  • Why a kilobyte of JavaScript costs more than a kilobyte of images
  • How to measure a bundle and which of its three sizes matters
  • Seven rules that cut first-load JavaScript, from code splitting to third-party scripts
  • How to stop the bundle from growing back after you have shrunk it

Part of the Performance & Optimization cluster.

Every kilobyte of JavaScript you ship is paid for twice. First on the network, and then on the user's device, where the browser has to parse, compile and run it before the page can respond to a tap. On a fast laptop the second cost is easy to miss. On a mid-range phone it is often the slowest part of loading the page.

Images do not work that way. Once an image is downloaded, the browser usually decodes it off the main thread, and nothing else has to wait. JavaScript runs on the main thread, so while it is being processed, clicks and scrolling wait too. The V8 team explains this in detail in The cost of JavaScript in 2019 (opens in a new tab).

Bundles rarely grow in one big commit. A feature adds a few kilobytes, a new dependency brings three more with it, and a year later the first load is twice what it was. Nobody notices until Lighthouse scores drop or users on slower phones start leaving.

A phone struggling under a huge backpack labelled JS, next to a happy phone walking easily with a small JS bag
The same app on the same phone. The only difference is how much JavaScript it has to carry before it can respond.

Impact: faster first load, less main-thread work, better Interaction to Next Paint

Measure before you cut

Guessing which code is heavy is usually wrong. Measure first, then fix the biggest thing.

A bundle has three sizes, and tools show all of them:

  • Minified (or "parsed") size: what the browser has to parse, compile and run. This is the number that hurts slow phones.
  • Compressed size (gzip or Brotli): what actually travels over the network. For minified JavaScript this is often 2 to 4 times smaller.
  • Source (or "stat") size: your files before minification. Useful for finding where code comes from, not for judging cost.

When you compare libraries, compare the same kind of size. Most library size websites quote minified plus gzipped.

Tools that show you what is inside

  • Next.js with Turbopack (16.1 and later): run npx next analyze for an interactive treemap with import chains.
  • Next.js with webpack: the @next/bundle-analyzer plugin.
  • Vite: rollup-plugin-visualizer.
  • Any app, in the browser: the Chrome DevTools Coverage tab shows how much of each downloaded file actually ran. Lighthouse flags the same thing as "Reduce unused JavaScript".

Setting up @next/bundle-analyzer takes three steps:

Terminal
npm install --save-dev @next/bundle-analyzer
JavaScript
// next.config.mjs
import bundleAnalyzer from '@next/bundle-analyzer'

const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true' })

/** @type {import('next').NextConfig} */
const nextConfig = {}

export default withBundleAnalyzer(nextConfig)
Terminal
ANALYZE=true npm run build

How to read the treemap

  • Each rectangle is a module. The bigger the rectangle, the more it costs.
  • Code in a shared chunk loads on many routes, so a saving there helps every page.
  • A route-specific chunk that is much bigger than its siblings usually hides one heavy import.

Look for three things: the same package bundled twice through different dependencies, a large library in a route that only uses one function from it, and something imported in a layout or root component that only one page needs.

Checkpoint

The analyzer shows a library at 80 KB parsed and 22 KB gzipped. Which number tells you how much work the phone has to do after the download?

Rule 1: Split by route and feature

Code splitting moves code out of the first download and into separate files, called chunks, that load only when they are needed. Routes are the natural place to split, because a user only sees one page at a time. Large features that are not visible at first render, such as editors, modals and charts, are the next place.

Incorrect (eager import)TSX
import HeavyEditor from '@/components/HeavyEditor'
export default function SettingsPage() {
return <HeavyEditor />
}
Correct (lazy + Suspense)TSX
import { lazy, Suspense } from 'react'
const HeavyEditor = lazy(() => import('@/components/HeavyEditor'))
function EditorSkeleton() {
return <div className="h-40 rounded-lg bg-zinc-100" aria-hidden />
}
export default function SettingsPage() {
return (
<Suspense fallback={<EditorSkeleton />}>
<HeavyEditor />
</Suspense>
)
}

In Next.js, pages are split automatically, and next/dynamic does the same job as lazy plus Suspense for components inside a page:

TSX
import dynamic from 'next/dynamic'

const HeavyEditor = dynamic(() => import('@/components/HeavyEditor'), {
  loading: () => <div className="h-40 rounded-lg bg-zinc-100" aria-hidden />,
})

Preload on intent

Splitting has a cost: the user now waits for the chunk when they open the page. You can hide most of that wait by starting the download when the user shows intent, for example when they hover over or focus the link. Calling the same import() twice does not download the file twice, so this is safe.

TSX
const loadSettings = () => import('./SettingsPage')
const SettingsPage = lazy(loadSettings)

<Link to="/settings" onMouseEnter={loadSettings} onFocus={loadSettings}>
  Settings
</Link>

The Next.js Link component already prefetches pages in production, so you only need this pattern for components loaded with next/dynamic or for apps without a framework.

Predict the output

A user opens /dashboard, then opens Settings, then goes back to Dashboard and opens Settings again. Which JavaScript files are downloaded?

TSX
import Dashboard from './Dashboard' // in main.js
const Settings = lazy(() => import('./Settings')) // settings.js

Lazy chunks can be requested long after the page loaded, sometimes hours later. If a deployment deletes them in the meantime, open tabs fail to load them. The Hidden Side of Frontend Deployments explains how to deploy without breaking those users.

Rule 2: Make tree shaking work for you

Tree shaking is the bundler removing exports that nothing imports. It happens at build time, and it only works when the bundler can prove that removing the code is safe. The deployment article walks through the basic idea. Here are the things that quietly stop it from working.

CommonJS packages. Tree shaking relies on ES module import and export, which can be analyzed before the code runs. A package published only as CommonJS (require and module.exports) usually ships whole, no matter how little of it you use. That is why lodash costs about 25 KB while lodash-es lets you pay roughly 1 KB for debounce.

Side effects. If a module does something just by being imported, such as adding a global or injecting CSS, the bundler has to keep it. Packages tell the bundler they are safe to prune with the sideEffects field in package.json. Your own shared packages can do the same:

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

This says: every file in this package can be dropped if it is unused, except CSS files.

Barrel files. A barrel is an index.ts that re-exports a whole folder. Importing one component through it can pull the whole folder into the graph, especially when some of those files have side effects or import heavy dependencies themselves.

Left: a truck loaded with a whole crate of boxes for a person who needs one orange box. Right: the person picks the single orange box straight from a shelf
Importing through a barrel can pull in the whole crate. Importing directly takes only what you need.
Risky (barrel import)TSX
import { Button, Modal, Toast } from '@/components'
Safer (direct import)TSX
import Button from '@/components/Button'
import Modal from '@/components/Modal'
import Toast from '@/components/Toast'

If you use Next.js, optimizePackageImports rewrites barrel imports from a package into direct imports for you. Popular libraries such as lucide-react, date-fns, lodash-es and @mui/material are already on its default list, so you only add your own or less common packages:

JavaScript
// next.config.mjs
const nextConfig = {
  experimental: {
    optimizePackageImports: ['@acme/ui'],
  },
}

Checkpoint

Which of these imports is most likely to ship code you never call?

Rule 3: Right-size your dependencies

Often the biggest single win is not a clever technique but a lighter library. Before you install a package, check its cost on Bundlephobia (opens in a new tab) or pkg-size.dev (opens in a new tab), and ask whether you need all of it.

A balance scale with a heavy grey package on one side and a light orange package with a feather on the other, with a swap arrow above
A lighter library that covers your use case can cut far more than any micro-optimization.
LibraryCost (min + gzip)Lighter optionCost of the lighter option
momentabout 20 KB, more if locale files get bundleddate-fns, importing only what you useabout 5 KB for format, under 1 KB for addDays
momentabout 20 KBdayjsabout 3 KB
lodashabout 25 KB, all or nothinglodash-es or native methodsabout 1 KB for debounce
axiosabout 18 KBnative fetch0 KB, built into the browser

Sizes are from Bundlephobia in October 2026 and change between versions, so check the current numbers before you decide. These swaps matter most in code that every visitor loads.

Find packages that are bundled twice

Two dependencies can depend on different versions of the same library, and both copies end up in the bundle. The treemap shows them as two rectangles with the same name. To find out who pulls each copy in:

Terminal
npm ls date-fns

Then update the dependency that is behind, or run npm dedupe to let npm share one version where the version ranges allow it.

Rule 4: Load rarely used code on demand

Some code is only needed after the user does something: exporting a PDF, opening a rich text editor, pasting a spreadsheet. There is no reason to ship it to everyone up front. Call import() inside the event handler instead:

TSX
async function handleExport() {
  const { exportToPdf } = await import('@/lib/export-pdf')
  await exportToPdf(report)
}

<button onClick={handleExport}>Export PDF</button>

The PDF library becomes its own chunk, and only users who press the button download it.

Browser-only libraries

Charts, maps and code editors often touch window or document when they load, which fails during server rendering. In Next.js, ssr: false skips the server render for that component and loads it only in the browser:

TSX
'use client'

import dynamic from 'next/dynamic'

const MonacoEditor = dynamic(() => import('@monaco-editor/react'), {
  ssr: false,
  loading: () => <div className="h-64 rounded-lg bg-zinc-100" aria-hidden />,
})

const MapView = dynamic(() => import('@/components/MapView'), {
  ssr: false,
  loading: () => <div className="h-72 rounded-lg bg-zinc-100" aria-hidden />,
})

In the App Router, ssr: false only works inside a Client Component. Using it in a Server Component is a build error, which is why the file above starts with 'use client'.

Rule 5: Move work to the server

With React Server Components, a component that only renders data never ships its code to the browser. The server runs it and sends the result. Any library it uses, such as a markdown parser or a syntax highlighter, stays on the server too.

Heavy machinery stays on the server side of a dashed line, and only a small orange button travels on a conveyor to the browser
Server Components keep the heavy work on the server. Only the interactive pieces travel to the browser.
Whole page is a Client ComponentTSX
'use client'
import { marked } from 'marked'
import { useState } from 'react'
export default function Post({ post }) {
const [liked, setLiked] = useState(false)
return (
<article>
<div dangerouslySetInnerHTML={{ __html: marked(post.body) }} />
<button onClick={() => setLiked(!liked)}>Like</button>
</article>
)
}
Only the button is a Client ComponentTSX
// Post.tsx (Server Component, no 'use client')
import { marked } from 'marked'
import { LikeButton } from './LikeButton'
export default function Post({ post }) {
return (
<article>
<div dangerouslySetInnerHTML={{ __html: marked(post.body) }} />
<LikeButton />
</article>
)
}
// LikeButton.tsx
'use client'
import { useState } from 'react'
export function LikeButton() {
const [liked, setLiked] = useState(false)
return <button onClick={() => setLiked(!liked)}>Like</button>
}

The rule of thumb is to push 'use client' down to the leaves of the tree. Everything a Client Component imports becomes client code, so marking a whole page as a Client Component because one button needs state ships much more than the button. React Rendering Strategies covers how Server Components fit next to SSR and static generation.

Rule 6: Tame third-party scripts

Analytics, tag managers, chat widgets, A/B testing tools and video embeds are often the heaviest JavaScript on a page, and they are the easiest to forget because they are not in your repository. They also compete with your own code for the main thread right when the page is starting up.

Three third-party guests holding a chart, a chat bubble and a video play button wait behind a rope outside the page until it is ready
Third-party scripts should wait until your page is ready, not arrive at the same time as it.

First, ask whether each script is still needed. Then decide when it should load. In Next.js, the Script component lets you choose:

TSX
import Script from 'next/script'

// Needed early, but not before the page is interactive (the default)
<Script src="https://analytics.example.com/script.js" strategy="afterInteractive" />

// Low priority: loads when the browser is idle, after everything else
<Script src="https://chat.example.com/widget.js" strategy="lazyOnload" />

Outside Next.js, adding async or defer to the script tag, or injecting it after the load event, achieves the same thing.

Use a facade for heavy widgets

A chat widget or a video player can weigh several hundred kilobytes, yet most visitors never use it. A facade is a lightweight look-alike, such as a static button or a thumbnail image, that loads the real thing only when the user clicks it:

TSX
function ChatLauncher() {
  const [open, setOpen] = useState(false)

  if (open) return <ChatWidget /> // loaded with lazy() or next/dynamic

  return <button onClick={() => setOpen(true)}>Chat with us</button>
}

Rule 7: Ship modern code

Bundlers can rewrite modern JavaScript into older syntax and add polyfills so it runs in old browsers. That extra code is pure weight for users on current browsers. Next.js and Vite already target modern browsers by default, so the usual problems come from somewhere else:

  • A broad browserslist in package.json that still includes very old browsers. Check what your users actually run before you keep them in the list.
  • Blanket polyfills, such as import 'core-js' at the top of the app, which add polyfills for features every current browser already supports.
  • No compression on the server. Check the content-encoding header in the Network tab. Most hosts and CDNs send Brotli (br) or gzip automatically, but custom servers sometimes send JavaScript uncompressed.

Try it yourself

The example app below starts with everything in its first load. Switch on one fix at a time and watch where each module ends up. Notice that some fixes only move code to later, while others remove it from the browser completely.

Try it: bundle simulator

An example app ships everything in its first load. Switch on one fix at a time and watch which code leaves the first load, which moves to later, and which disappears completely.

Fixes

First load JavaScript (gzipped)

327 KB of a 170 KB budget

budget
Slow 4G download
about 1.8 s
Moved to later
0 KB
Cut from first load
0 KB
  • react + react-dom 45 KB

    first load

    Needed on every page

  • App shell and router 20 KB

    first load

    Needed on every page

  • UI components you use 12 KB

    first load

    Needed on every page

  • Components pulled in by the barrel 30 KB

    first load

    Unused, but reachable from index.ts

  • moment 20 KB

    first load

    Whole library for two date calls

  • Rich text editor (Settings) 60 KB

    first load

    Shipped to every visitor

  • Chart library (Reports) 55 KB

    first load

    Shipped to every visitor

  • Markdown renderer for FAQ and pricing 40 KB

    first load

    Runs in the browser to render static text

  • Analytics and chat widget 45 KB

    first load

    Competes with your own code at startup

Sizes are illustrative, rounded from typical packages. The download estimate uses the Lighthouse Slow 4G profile and does not include the time a phone spends parsing and running the code, which is often longer.

Key takeaway

Moving code to later and removing code entirely are both wins, but they are different. Lazy code is still downloaded by the users who need it. Server-only and unused code is never downloaded by anyone.

Keep it small over time

Shrinking a bundle once is the easy part. Keeping it small while the team keeps shipping features needs a check that runs on every pull request.

A gauge whose needle sits in the safe grey range, before the orange limit zone, above a passing check
A budget turns bundle size from something you notice months later into a check that fails on the pull request that caused it.

size-limit (opens in a new tab) measures your built files and fails when they go over a limit. For a Vite app:

Terminal
npm install --save-dev size-limit @size-limit/file
JSON
// .size-limit.json
[
  {
    "name": "Main bundle",
    "path": "dist/assets/index-*.js",
    "limit": "170 kB"
  }
]

Then run npx size-limit in CI after the build. Pick a limit close to your current size and lower it as you make cuts. The point is not the exact number, it is that growth becomes a decision instead of an accident.

If your version of next build prints a "First Load JS" column, keep an eye on it in pull requests too. And give the largest shared chunk an owner, so someone notices when it grows.

Bundle health checklist

  • You know your first-load JavaScript size and where it comes from.
  • Heavy features are lazy-loaded at route or feature boundaries.
  • No broad barrel imports from large folders in code every page loads.
  • Date, utility and HTTP libraries are sized for what you actually use.
  • No package is bundled twice.
  • Code that only renders data runs in Server Components where your framework supports them.
  • Third-party scripts load after your page is ready, and heavy widgets use a facade.
  • A size budget runs in CI.

Further reading

Conclusion

A small bundle is not the result of one clever trick. It comes from a series of small decisions: measure before cutting, split where users naturally wait, let the bundler remove what you do not use, pick dependencies that fit the job, keep data-only rendering on the server, and make third-party scripts wait their turn.

None of these rules matter equally in every app. The treemap tells you which one matters in yours.

The key takeaway

Ship only the JavaScript a user needs for the screen in front of them, load the rest when they ask for it, and add a budget so the bundle cannot quietly grow back.

Check your understanding

Test yourself6 questionsQuestion 1 of 6

Why can 100 KB of JavaScript slow a page down more than a 100 KB image?

Recap

What to remember from this article

Sections read

0

Questions

0

none answered yet

Quiz

0

not taken yet

Up next: Deployments & ChunkLoadError · 14 min

Continue