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.

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 analyzefor an interactive treemap with import chains. - Next.js with webpack: the
@next/bundle-analyzerplugin. - 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:
npm install --save-dev @next/bundle-analyzer// 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)ANALYZE=true npm run buildHow 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?
Pick an answer, or press A, B, C on your keyboard.
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.
In Next.js, pages are split automatically, and next/dynamic does the same job as lazy plus Suspense for components inside a page:
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.
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?
import Dashboard from './Dashboard' // in main.js
const Settings = lazy(() => import('./Settings')) // settings.jsPick an answer, or press A, B, C, D on your keyboard.
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:
{
"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.

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:
// next.config.mjs
const nextConfig = {
experimental: {
optimizePackageImports: ['@acme/ui'],
},
}Checkpoint
Which of these imports is most likely to ship code you never call?
Pick an answer, or press A, B, C on your keyboard.
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.

| Library | Cost (min + gzip) | Lighter option | Cost of the lighter option |
|---|---|---|---|
moment | about 20 KB, more if locale files get bundled | date-fns, importing only what you use | about 5 KB for format, under 1 KB for addDays |
moment | about 20 KB | dayjs | about 3 KB |
lodash | about 25 KB, all or nothing | lodash-es or native methods | about 1 KB for debounce |
axios | about 18 KB | native fetch | 0 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:
npm ls date-fnsThen 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:
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:
'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.

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.

First, ask whether each script is still needed. Then decide when it should load. In Next.js, the Script component lets you choose:
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:
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
browserslistinpackage.jsonthat 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-encodingheader 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.
First load JavaScript (gzipped)
327 KB of a 170 KB 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 loadNeeded on every page
App shell and router 20 KB
first loadNeeded on every page
UI components you use 12 KB
first loadNeeded on every page
Components pulled in by the barrel 30 KB
first loadUnused, but reachable from index.ts
moment 20 KB
first loadWhole library for two date calls
Rich text editor (Settings) 60 KB
first loadShipped to every visitor
Chart library (Reports) 55 KB
first loadShipped to every visitor
Markdown renderer for FAQ and pricing 40 KB
first loadRuns in the browser to render static text
Analytics and chat widget 45 KB
first loadCompetes 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.

size-limit (opens in a new tab) measures your built files and fails when they go over a limit. For a Vite app:
npm install --save-dev size-limit @size-limit/file// .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
- React Performance Optimization
- JavaScript Performance Patterns
- Eliminating Async Waterfalls
- The Hidden Side of Frontend Deployments
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
Why can 100 KB of JavaScript slow a page down more than a 100 KB image?
Pick an answer, or press A, B, C on your keyboard.
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