Blog

Part 58 · Performance Budgets for React Apps

A performance budget is a limit you agree on before you build. Measure a real Vite build, make CI fail when it grows too big, and watch real users’ speed.

Stage 3 of this series explained when React renders. Then it showed how to skip renders: composition, memo, useMemo and useCallback and the React Compiler. Stage 6 showed code splitting, virtualization and profiling. Each part fixed one slow thing, once.

But apps keep changing. Every week someone adds a library, a picture or a new screen. Each change is small. Then one day the app is slow, and nobody knows which change did it.

A performance budget stops that. It is a limit you agree on before you build, like “the JavaScript for the first screen stays under 100 kB”. Then a program checks the limit on every change.

This last part shows how to choose budgets and measure them in a real project. Then we make the build fail when a budget breaks, and we watch the speed of real users.

Try this first

Our small sales app, like the one in Part 34, is React plus a few lines of our own. Your team has agreed: at most 100 kB of JavaScript at the start. You want to add some libraries. The sizes below are real. We measured each one in a real build, which comes later in this part.

Read the code. Don’t press Run yet.

import { useState } from 'react'

// Sizes after gzip, in kB, measured in a real Vite build.
const BUDGET = 100
const OUR_APP = 67.94
const LIBRARIES = [
  { name: 'dates (dayjs)', kb: 3.17 },
  { name: 'helpers (lodash)', kb: 25.89 },
  { name: 'charts (chart.js)', kb: 68.44 },
]

export default function App() {
  const [added, setAdded] = useState<string[]>([])

  function toggle(name: string) {
    if (added.includes(name)) {
      setAdded(added.filter(n => n !== name))
    } else {
      setAdded([...added, name])
    }
  }

  let total = OUR_APP
  for (const library of LIBRARIES) {
    if (added.includes(library.name)) total += library.kb
  }

  return (
    <div>
      <p>Budget: {BUDGET} kB. React and our app: {OUR_APP} kB.</p>
      {LIBRARIES.map(library => (
        <button key={library.name} onClick={() => toggle(library.name)}>
          {added.includes(library.name) ? 'Remove' : 'Add'} {library.name}, {library.kb} kB
        </button>
      ))}
      <p>Total: {total.toFixed(2)} kB</p>
      <p>
        {total <= BUDGET
          ? 'Within budget.'
          : `Over budget by ${(total - BUDGET).toFixed(2)} kB.`}
      </p>
    </div>
  )
}

Make a guess. Can you add the chart library and stay within the budget? What about the other two together?

Now press Run and click the buttons.

The chart library alone breaks the budget: 136.38 kB, over by 36.38 kB. The date and helper libraries fit together, at 97.00 kB. Nobody said “no charts”. The budget only made the cost of each choice clear, before anything reached users. Later in this part we keep the chart and still meet the budget.

The playground just adds the numbers up. A real build packs everything together, so its total is a little different. We built the app with the date and helper libraries for real: 96.74 kB. With all three, it was 165.25 kB, and the playground says 165.44 kB.

What a performance budget is

Google’s web.dev guide “Performance budgets 101” gives this definition: “A performance budget is a set of limits imposed on metrics that affect site performance”. A metric is a thing you can measure with a number, like a file size or a time. Imposed means set on purpose, as a rule.

The guide’s examples include “the total size of a page” and “the number of HTTP requests that are sent”. It also says to set the budget early. Then you don’t have to go back and undo work later.

That guide is from 2018, and some of its metrics are old. Later in this part we use today’s metrics, the Core Web Vitals. The idea itself hasn’t changed.

An everyday example

A family plans a trip with 500 dollars. Before they leave, they agree on limits: 200 for the hotel and 150 for food. During the trip, they check every choice against the limits. A big dinner is fine, but then there is less food money for the next day. Without the limits, they only find out at the end that the money is gone.

The exact version

A money budget is spent once. A performance budget is checked again and again, on every change, by a program.

It is also not one number. An app usually has several budgets of different kinds. A page can be small and still slow. It can be fast on your laptop and slow on a cheap phone.

Choosing the number

How do you pick a limit? Start from what you have today. Measure your current build, and set the budget a little above it, so that normal small changes still pass. Then lower it over time, as you make the app faster.

The 100 kB in this part is only an example. The kata for this part suggests 100 KB per route as a starting point. We found no source for that number. Choose yours from your own measurements.

Four kinds of budget

Size: how much code you send

The first kind is easy to measure: the size of your JavaScript files. As Part 34 showed, the browser must download the code, then read and run it. More code costs time at every step.

One file can have three sizes. Here is the one JavaScript file of our small app, measured in our lab:

Size kB What it is
Raw 219.80 The file on disk, after the build made it smaller
gzip 67.94 Packed with gzip, a common way to pack files
brotli 58.41 Packed with brotli, a newer way that packs smaller

Here 1 kB means 1,000 bytes. The file had 219,803 bytes, and Vite printed 219.80 kB.

A web server can pack a file before it sends it. MDN’s page on the Content-Encoding header lists both gzip and br, the short name for brotli. The browser then turns it back into the full file. It must still read all 219.80 kB.

So which number goes in the budget? Both matter. The packed size decides how long the download takes. The raw size decides how much code the browser must read. Vite’s own size warning uses the raw size. Its docs say why: “as the JavaScript size itself is related to the execution time”.

In this part, our budget uses the gzip size. The more important rule: pick one way to measure, and never mix two.

Timing: how fast it feels

Size is not everything. What users feel is time.

Google’s Core Web Vitals are three numbers. The Web Vitals page on web.dev says they “apply to all web pages”. Each has a “good” limit. Those limits make good timing budgets.

  • LCP, Largest Contentful Paint, is about loading. It is the time until the biggest text or picture shows up. The page says “LCP should occur within 2.5 seconds of when the page first starts loading”.
  • INP, Interaction to Next Paint, is about answering the user. It is the time from a click, tap or key press until the screen changes. The page says “pages should have a INP of 200 milliseconds or less”.
  • CLS, Cumulative Layout Shift, is about things jumping around. It grows when parts of the page move after they have appeared. The CLS page says “Good CLS values are 0.1 or less”.

The LCP, INP and CLS pages also say what counts as poor. The 4-second limit for LCP comes from web.dev’s article on how the limits were chosen.

Metric Good Needs improvement Poor
LCP 2.5 seconds or less up to 4 seconds over 4 seconds
INP 200 ms or less up to 500 ms over 500 ms
CLS 0.1 or less up to 0.25 over 0.25

These limits are for “the 75th percentile of page loads”. Here is what that means. Put 100 visits in order, from best to worst. The 75th visit in that line is the 75th percentile. If that visit is good, then at least 75 of the 100 visits were good.

Count: how many requests

A budget can also count things, like the number of requests, or the number of outside scripts. Each request is one more trip to a server. Part 34 showed that every chunk is one more request, and a chain of chunks is slower still. In our lab, the small app needed 2 requests: the HTML file and one JavaScript file.

React: how long a render takes

The last kind is about React itself: how often components render, and how long a commit takes. Part 18 explained both words. Part 36 showed how to measure both with <Profiler>.

A good limit here is one frame. A screen usually shows a new picture 60 times a second. So, in the words of web.dev’s page on rendering, “the browser has 16.66 milliseconds to produce each frame”. The same page says the browser needs part of that time for itself. So your own work should fit in about 10 ms. It also says this matters most for animations. A single click is judged by INP instead, with its 200 ms limit.

This app checks every commit of a slow list against a 16 ms budget. Each row waits 2 ms on purpose, as in Part 36:

import { Profiler, useState, type ProfilerOnRenderCallback } from 'react'

const BUDGET_MS = 16
const fruits = ['Apple', 'Banana', 'Cherry', 'Grape', 'Lemon', 'Mango', 'Orange', 'Peach', 'Pear', 'Plum']

function Row({ name }: { name: string }) {
  // Wait 2 ms on purpose, to make this row slow. Real code never does this.
  const end = performance.now() + 2
  while (performance.now() < end) {
    // do nothing
  }
  return <li>{name}</li>
}

const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration) => {
  const result = actualDuration > BUDGET_MS ? 'over budget' : 'within budget'
  console.log(id, phase, result)
}

export default function App() {
  const [clicks, setClicks] = useState(0)
  return (
    <div>
      <button onClick={() => setClicks(clicks + 1)}>Clicks: {clicks}</button>
      <Profiler id="list" onRender={onRender}>
        <ul>
          {fruits.map(name => <Row key={name} name={name} />)}
        </ul>
      </Profiler>
    </div>
  )
}
list mount over budget
list update over budget

Run it and click the button once. Both commits are over budget. Ten rows of 2 ms each take 20 ms, more than 16. The playground takes even longer. Strict Mode renders each row twice, and Part 36 showed that actualDuration counts both. But onRender still runs once per commit, so you see one line for each.

Remember from Part 36 that <Profiler> is off in a normal production build. So you check this kind of budget in development, or in a profiling build. These give different times. In our lab, a click took about 40 ms with Strict Mode on, and Part 36 measured about 20 ms with it off. So set the limit for the build you measure in. Times also change from one computer to the next. Use a React budget to catch big jumps, like a list that suddenly takes twice as long.

Measure a real build

Budgets need real numbers. We made a new project the way Part 10 does, with create-vite 9.2.1 and Vite 8.3.3. As in Part 34, we deleted the starter’s CSS files and pictures. Then we wrote a small app:

import { useState } from 'react'

function App() {
  const [show, setShow] = useState(false)

  return (
    <div>
      <h1>Sales</h1>
      <button onClick={() => setShow(true)}>Show chart</button>
      {show && <p>Mon 3, Tue 5, Wed 2</p>}
    </div>
  )
}

export default App

npm run build ended with these lines:

dist/index.html                  0.38 kB │ gzip:  0.26 kB
dist/assets/index-BHcY8OBx.js  219.80 kB │ gzip: 68.68 kB

Vite’s gzip number is a little different from the one our own script gives later. We explain why below.

Now someone adds a real chart. We used Part 34’s Chart.tsx, which uses the chart.js library. We imported it the normal way:

import { useState } from 'react'
import Chart from './Chart'

function App() {
  const [show, setShow] = useState(false)

  return (
    <div>
      <h1>Sales</h1>
      <button onClick={() => setShow(true)}>Show chart</button>
      {show && <Chart />}
    </div>
  )
}

export default App
dist/index.html                  0.38 kB │ gzip:   0.26 kB
dist/assets/index-DM6rL_op.js  422.61 kB │ gzip: 137.81 kB

The file almost doubled. Every visitor now downloads the chart code, even one who never clicks the button. And the build finished with no error and no warning.

Vite’s own warning

Vite does have a size warning. Its docs describe the setting build.chunkSizeWarningLimit, with a default of 500 kB. Our file, at 422.61 kB, was under it.

Then we added all three libraries from “Try this first”. The file grew to 502.03 kB, and Vite printed this after the build:

(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.

But the build still worked. Its exit code was 0. An exit code is a number that a program gives back when it ends. 0 means “all went well”, and any other number means “something failed”.

A warning in a long build log is easy to miss. A budget needs a check that fails.

Make the build fail: a size check in CI

CI is short for continuous integration. It is a server that runs your project’s checks every time someone sends a change. If a check fails, the team sees it. A team can also mark a check as required. GitHub’s docs say that a change can’t be merged until all required checks pass.

We wrote a short Node.js script for this. First, the budget goes in a file named size-budget.json:

{
  "startKb": 100,
  "fileKb": 100
}

There are two limits, both in kB after gzip. startKb is for all the JavaScript the page loads at the start. fileKb is for any one file, so a chunk that loads later can’t grow forever either.

Then the script, check-size.mjs, goes in the project folder:

import fs from 'node:fs'
import zlib from 'node:zlib'

// The limits the team agreed on, in kB after gzip.
const budget = JSON.parse(fs.readFileSync('size-budget.json', 'utf8'))

// The JavaScript files that index.html asks for at the start.
const html = fs.readFileSync('dist/index.html', 'utf8')
const startFiles = [...html.matchAll(/"\/(assets\/[^"]+\.js)"/g)].map(m => m[1])

let startKb = 0
let failed = false

for (const name of fs.readdirSync('dist/assets')) {
  if (!name.endsWith('.js')) continue
  const file = 'assets/' + name
  const kb = zlib.gzipSync(fs.readFileSync('dist/' + file)).length / 1000
  const atStart = startFiles.includes(file)
  if (atStart) startKb += kb

  let note = atStart ? 'start' : 'later'
  if (kb > budget.fileKb) {
    note += ', OVER the ' + budget.fileKb + ' kB limit for one file'
    failed = true
  }
  console.log(file.padEnd(28) + kb.toFixed(2).padStart(8) + ' kB  ' + note)
}

console.log('Start of the page: ' + startKb.toFixed(2) + ' kB (budget ' + budget.startKb + ' kB)')
if (startKb > budget.startKb) failed = true

if (failed) {
  console.log('Over budget.')
  process.exit(1)
}
console.log('Within budget.')

Here is what it does, step by step:

  1. It reads the two limits from size-budget.json.
  2. It reads dist/index.html and finds the JavaScript files that the page asks for at the start.
  3. It packs every JavaScript file in dist/assets with gzip, and measures the result. Node’s own zlib module does the packing, at its default level.
  4. It prints each file, adds up the start files, and checks both limits.
  5. If anything is over, it ends with process.exit(1). That exit code is what makes CI fail.

Run it after npm run build. On the small app, it printed this, with exit code 0:

assets/index-BHcY8OBx.js       67.94 kB  start
Start of the page: 67.94 kB (budget 100 kB)
Within budget.

On the app with the chart, it printed this, with exit code 1:

assets/index-DM6rL_op.js      136.38 kB  start, OVER the 100 kB limit for one file
Start of the page: 136.38 kB (budget 100 kB)
Over budget.

The change that added the chart now fails.

In CI

A CI service reads the exit code of each command. GitHub Actions is one such service. Its docs say: “The runner will report the status of the step as fail/succeed based on this exit code”. So the CI job needs three commands:

npm ci
npm run build
node check-size.mjs

npm ci is the install command for CI. npm’s docs say it needs a package-lock.json, and it never changes that file. We ran the three commands one after another on the chart version, joined with &&. The chain ended with exit code 1. We didn’t set up a real CI service for this part.

Fix it, then check again

The team still wants the chart. It just doesn’t need to load at the start. That is the fix from Part 34: lazy and <Suspense>.

import { lazy, Suspense, useState } from 'react'

const Chart = lazy(() => import('./Chart'))

function App() {
  const [show, setShow] = useState(false)

  return (
    <div>
      <h1>Sales</h1>
      <button onClick={() => setShow(true)}>Show chart</button>
      {show && (
        <Suspense fallback={<p>Loading chart...</p>}>
          <Chart />
        </Suspense>
      )}
    </div>
  )
}

export default App

We ran the check again. It printed this, with exit code 0:

assets/Chart-DHqMMmaX.js       68.80 kB  later
assets/index-DQ0A3UE-.js       68.53 kB  start
Start of the page: 68.53 kB (budget 100 kB)
Within budget.

The chart’s code is now in its own chunk, marked later. That chunk is also under the 100 kB limit for one file.

1. The team agrees: at most 100 kB of JavaScript at the start. 2. Someone imports a chart library. The start file grows to 136.38 kB. 3. The size check in CI fails. The change can't go in yet. 4. The chart moves to its own chunk, with lazy. The start file: 68.53 kB. 5. The check passes. The chart's chunk loads later, only when needed. start file later chunk 0 kB 50 kB 100 kB 150 kB budget: 100 kB 67.94 kB 136.38 kB 68.53 kB Chart chunk, 68.80 kB CI check $ node check-size.mjs(runs on every change) $ node check-size.mjsStart of the page: 136.38 kB (budget 100 kB)Over budget.exit code 1: the change fails $ node check-size.mjsStart of the page: 68.53 kB (budget 100 kB)Within budget.exit code 0: the change can go in

A size budget in CI: the start file grows past the line, the check fails, and a split brings it back under. Press play, or step through it.

Here are the same steps in words.

  1. The team agrees on a budget: at most 100 kB of JavaScript at the start. The small app’s file is 67.94 kB.
  2. Someone imports the chart library the normal way. The start file grows to 136.38 kB.
  3. The size check runs in CI. It prints “Over budget.” and ends with exit code 1, so the change fails.
  4. The chart moves to its own chunk with lazy. The start file is back to 68.53 kB.
  5. The check passes with exit code 0. The chart’s chunk, 68.80 kB, loads only when someone asks for the chart.

Why our numbers differ from Vite’s

Look closely. For the chart build, Vite printed 137.81 kB. Our script, using Node’s zlib at its default level, printed 136.38 kB. Both used gzip. But gzip can pack the same file in slightly different ways, and different programs choose differently. Your web server might pack it a third way.

Here the difference is about 1%. It matters only when a file is very close to the limit. So the budget must name the tool that measures it. In this part, the tool is check-size.mjs.

Tools other people built

You don’t have to write the script yourself. size-limit calls itself “a performance budget tool for JavaScript”, and it fails when you go over. rollup-plugin-visualizer and source-map-explorer draw your build as boxes, so you can see what takes the space. The second one reads source maps: files that link the built code back to your own files. Vite makes them when you turn on build.sourcemap. We didn’t run these tools for this part.

What about Lighthouse budgets?

Lighthouse is Google’s tool that tests a page and gives it scores. Older guides, like a 2019 article on web.dev, give it a budget.json file with a --budget-path setting.

That is gone. Lighthouse’s changelog for version 12.0.0, from April 2024, lists “remove budgets” under “Breaking Changes”. We ran lighthouse --help from version 13.5.0, the newest on 7 October 2026. No line in it mentioned a budget.

Lighthouse CI is a separate tool that runs Lighthouse in CI. Its docs still describe a budgetsFile option for its lhci assert command. We didn’t try it.

Timing budgets in the lab

A size budget is easy to check on every change. But users feel time, not kB. So we timed the three builds too.

We served each build with vite preview and opened it in a headless Chromium. That is a browser with no window, run by a program. We slowed it down the way Part 34 did, to act like a slow phone:

  • every request waited an extra 150 ms;
  • the download speed was about 1.7 million bits per second;
  • the computer ran 4 times slower than normal.

Then we read the LCP with PerformanceObserver. It is a browser feature that tells your code each time the browser records a new timing. We opened each build 15 times, taking turns between the three builds. This part reads LCP from the browser’s own timeline. Part 34 timed something else, until the button was on the page, so its numbers are different.

Build JavaScript sent LCP: middle of 15 (and the range)
Small app 68,362 bytes 808 ms (756 to 1212 ms)
Chart imported the normal way 136,853 bytes 1208 ms (1116 to 1496 ms)
Chart with lazy 68,944 bytes 816 ms (752 to 936 ms)

The times moved during the run. The first few visits of every build were slower than the last few. That is why the builds took turns, and why the ranges are wide. So trust the big gap, not the small one. In the middle, the chart made the first screen about 400 ms slower, and the split took that back. The 8 ms between the small app and the split means nothing here.

All three are under 2.5 seconds, the “good” limit for LCP. The size budget caught the growth long before LCP reached its limit.

This is still a lab test: one computer, a pretend phone, and no real users. A lab test is the best way to catch a regression before users see it, the Web Vitals page says. A regression is something that worked before and got worse. But the same page says the lab “is not a substitute for field measurement”. The field means real users, on their own phones and networks.

Watch real users: the web-vitals library

To get field numbers, the page must measure itself on each user’s device. Then it sends the result to your server. The Web Vitals page on web.dev points to the web-vitals library for this. It is a small library from Google.

We installed version 6.2.3 in the project and added these lines to src/main.tsx:

import { onCLS, onINP, onLCP, type Metric } from 'web-vitals'

function sendToAnalytics(metric: Metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    id: metric.id,
    page: location.pathname,
  })
  navigator.sendBeacon('/analytics', body)
}

onCLS(sendToAnalytics)
onINP(sendToAnalytics)
onLCP(sendToAnalytics)

Each on... function calls sendToAnalytics when its number is ready. metric.rating is 'good', 'needs-improvement' or 'poor', from the limits in the table above.

navigator.sendBeacon sends a small message to your server. The README says it still works while the page is closing, so the message isn’t lost when the user leaves. /analytics is an address on your own server. You write the code there that saves each message.

The numbers don’t all come at once. The README warns that some come only after the user clicks, switches tabs or leaves the page. INP is sent when the page becomes hidden, for example when the user switches to another tab.

We tried it in headless Chromium. The app had a button that keeps the browser busy for 300 ms on purpose. Nothing came before the click. We clicked once, and within 1.5 seconds one message arrived:

{"name":"LCP","value":96,"rating":"good","id":"v6-1791380880747-6148276345325","page":"/"}

LCP is final once the user clicks: web.dev’s LCP page says the browser stops reporting new entries then. So it came at that moment. CLS and INP wait for the page to become hidden. In five runs, LCP was between 48 and 96 ms, and INP never came. Our headless browser never made the page hidden. Even with another page in front, document.visibilityState stayed "visible".

Then we passed { reportAllChanges: true } as a second argument to all three functions. The README says this “can be useful when debugging”, but production doesn’t need it. Now LCP and CLS arrived before the click, and INP right after it:

{"name":"INP","value":304,"rating":"needs-improvement","page":"/"}

Your id will be different: each page visit makes new ones. In five runs, INP was 304 or 312 ms. That is over 200 ms, so rating says "needs-improvement". Most of it is the 300 ms of busy work in the click handler.

The library itself is small. With it, the app’s file grew by 2.86 kB with gzip, and by 2.52 kB with brotli. The README says about 3 kB with brotli.

On your server, you collect these messages from many users. Keep only the last value for each id. The README explains why one visit can send more than one message. CLS and INP are sent again each time the page becomes hidden. A page can also come back from the browser’s back/forward cache. Then every metric is sent again, with a new id. The README counts that as a new visit.

Then, for each page, find the 75th percentile and compare it with your budget. Look at phones and big computers apart. The Web Vitals page says to split the numbers that way. That number tells you what most of your users really get.

See it in the page

The browser has its own way to time clicks, and web-vitals uses it for INP. Each click becomes a PerformanceEventTiming entry. Its duration is the time from the click until the screen could change.

This app shows every slow click. Its three buttons keep the browser busy for 0, 300 and 600 ms:

import { useEffect, useState } from 'react'

type Click = { label: string; ms: number }

function busy(ms: number) {
  // Keep the browser busy on purpose. Real code never does this.
  const end = performance.now() + ms
  while (performance.now() < end) {
    // do nothing
  }
}

function rate(ms: number) {
  if (ms <= 200) return 'good'
  if (ms <= 500) return 'needs improvement'
  return 'poor'
}

export default function App() {
  const [clicks, setClicks] = useState<Click[]>([])

  useEffect(() => {
    const observer = new PerformanceObserver(list => {
      for (const entry of list.getEntries() as PerformanceEventTiming[]) {
        if (entry.name !== 'click') continue
        const label = entry.target?.textContent ?? '?'
        setClicks(old => [...old, { label, ms: entry.duration }])
      }
    })
    observer.observe({ type: 'event' })
    return () => observer.disconnect()
  }, [])

  return (
    <div>
      <button onClick={() => busy(0)}>Fast</button>
      <button onClick={() => busy(300)}>Slow</button>
      <button onClick={() => busy(600)}>Very slow</button>
      <ul>
        {clicks.map((click, i) => (
          <li key={i}>{click.label}: {click.ms} ms, {rate(click.ms)}</li>
        ))}
      </ul>
    </div>
  )
}

Press Run and click each button once. In headless Chromium, the list showed Slow: 304 ms, needs improvement and Very slow: 600 ms, poor. We also ran it in Firefox and in WebKit, the engine behind Safari, three times each. Every run showed 304 ms for Slow and 600 ms for Very slow. We ran it in a sandboxed frame, the way the playground runs code. Your times will differ a little.

Fast never shows up. MDN explains why. Entries “are exposed when their duration is 104ms or greater”, unless you ask for a smaller limit. MDN also says the time is “rounded to the nearest 8ms”.

The Effect connects the observer, and its cleanup disconnects it, as in Part 12. Strict Mode runs that pair once more on mount, so one observer is left.

The rate function uses the INP limits from the table. But one click is not INP. INP looks at all the clicks, taps and key presses of a whole visit. It reports about the longest one, but leaves out a few very rare ones. For real numbers, use the library.

When a budget breaks

The check failed. Now what? The 2018 web.dev guide gives three choices: make something smaller, remove something, or don’t add the new thing. For a React app, work through this list:

  1. Find what grew. Read the build’s file list, or draw it with one of the tools above. Find the library or file that made the jump.
  2. Does it need to load at the start? If not, split it out with lazy, as in Part 34. Pages, rare screens and big libraries are the best places.
  3. Is there a smaller way? Maybe you use one function from a big library. Maybe the browser can already do it.
  4. Is a long list slow? Show only the rows that fit, with virtualization from Part 35.
  5. Are clicks slow, or commits over budget? Measure first, with Part 36. Then pick the fix that matches what you found: – composition (Part 19); – memo (Part 20); – useMemo and useCallback (Part 21); – the React Compiler (Part 22); – context selectors (Part 25), when one small change in context renders many components.
  6. Measure again. The fix must bring the number back under the limit. If it doesn’t, undo it.
  7. Still over, and the feature is worth it? Then raise the budget. Do it on purpose and in writing, as the next section says.

A budget is a team agreement

A budget only works if people agree with it. Write it down in the project, like size-budget.json, so every change to it can be seen. Let the team that builds a page own that page’s numbers. Raise a budget only with a reason that others can read. Look at the budgets again from time to time, because apps, phones and networks change. And count other people’s code too. The 2018 guide lists “Total number of external resources, such as third-party scripts”.

Start small. One size check in CI is already a budget. Add web-vitals when you have real users. Add React budgets when a screen gets slow.

Common mistakes

Measuring the development build

npm run dev doesn’t write any files to dist. And a build that uses React’s development version is much bigger. In Part 10, it was 427.79 kB, against 219.90 kB. Always run the size check on the output of npm run build.

Mixing two ways of measuring

For the same file, Vite printed 137.81 kB and our script printed 136.38 kB. Say the budget is 137 kB, and nobody says which tool. Then one person sees a pass, and another sees a fail. Name the tool in the budget, and use only that one.

A warning instead of a failure

import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  build: { chunkSizeWarningLimit: 400 },
})

A lower warning limit doesn’t make a budget. We built the chart app with this setting in vite.config.ts. Vite printed its warning, now about 400 kB. But the build still ended with exit code 0. A check that can’t fail will soon be ignored. Use a script that exits with 1.

Splitting a file that still loads at the start

Moving code into a chunk only helps if the chunk loads later. Part 34 showed one way this goes wrong. A normal import of the same file anywhere else pulls it back into the start file. Vite then prints INEFFECTIVE_DYNAMIC_IMPORT. Read the build output, and let the size check tell you which files load at the start.

Only checking in the lab

All three of our builds had a good LCP in the lab. Real users have slower phones, worse networks and other tabs open. Lab tools that only load the page, like Lighthouse, can’t measure INP, because nobody clicks. The Web Vitals page says such tools “cannot measure INP, as there is no user input”. Add web-vitals, so you see the field numbers too.

Raising the budget quietly

The check fails, and someone changes "startKb": 100 to "startKb": 140 in the same change. Now the budget just records whatever happened. Raise a budget in its own change, with a reason that others can read and agree with.

Practice

Press Edit on any example above and try these.

  1. In “Try this first”, change BUDGET to 140. Which libraries can you add now?
  2. In the React budget example, wrap Row in memo, as Part 20 did. Import memo from react. Then write const Row = memo(function Row({ name }: { name: string }) { ... }). Run it and click once. What does the Console show?
  3. In the same example, change BUDGET_MS to 100. What does the Console show now? Is the list faster?
  4. In the click-timing example, change busy(300) to busy(150). Click Slow. What rating do you see?
Answers
  1. The chart library fits on its own: 136.38 kB. The chart and the date library fit too: 139.55 kB. The chart and the helper library don’t: 162.27 kB, over by 22.27 kB.
  2. list mount over budget, then list update within budget. The first render still has to render every row. On the click, memo skips all ten rows, so the commit takes almost no time.
  3. list mount within budget and list update within budget. The list is just as slow as before. Only the limit moved. That is why a team changes a budget on purpose, not to make a check pass.
  4. A time a little over 150 ms, rated “good”, because it is 200 ms or less. We saw 152 ms every time, in all three browsers. The time is a multiple of 8, because the browser rounds it.

Interview questions

Try to answer each one out loud before you open the answer.

What is a performance budget?

It is a limit on a number that affects speed, agreed on before you build. In web.dev’s words, it is “a set of limits imposed on metrics that affect site performance”. Here are some examples. The start JavaScript stays under 100 kB after gzip. LCP is 2.5 seconds or less. A budget makes the cost of each change clear, while the change is still cheap to undo.

A strong answer adds that a budget needs a check. A number in a document that nobody checks soon stops being true.

What kinds of budgets would you set for a React app?

Size: kB of JavaScript at the start, and for each chunk. Timing: the Core Web Vitals (LCP, INP and CLS) at the 75th percentile of real users. Count: the number of requests or outside scripts. And budgets for React itself: how long a commit takes, or how often a component renders. You measure those with <Profiler> or the React Developer Tools.

A strong answer explains why there are several. Size is easy to check on every change, but users don’t feel kB. Field times are what users feel, but they arrive only after a release.

How do you make a size budget fail the build?

After npm run build, run a check that measures the files in dist and compares them with the limits. If anything is over, the check ends with an exit code that isn’t 0, like process.exit(1). CI treats that as a failed step. The change can’t go in until someone fixes it or raises the budget. The check is a few lines of Node.js, or a tool like size-limit.

A strong answer says why Vite’s chunkSizeWarningLimit isn’t enough. It only prints a warning, and the build still ends with exit code 0.

What is the difference between lab data and field data?

Lab data comes from a test you control: one machine, a fixed network, maybe a pretend phone. Repeat it many times and take the middle, and you get close results. So it catches a regression before release. Field data comes from real users, through a library like web-vitals. It shows what people really get, on all their different phones and networks. The Web Vitals page says the lab “is not a substitute for field measurement”.

A strong answer adds that INP needs clicks and key presses. Lab tools that only load the page, like Lighthouse, “cannot measure INP, as there is no user input”. The Web Vitals page names a lab number, Total Blocking Time, as a stand-in for INP.

The start file is over budget after someone added a chart library. What do you do?

First find what grew, with the build output or a tool that draws the build. Then ask if it must load at the start. A chart behind a button doesn’t, so load it with lazy and <Suspense>. In our lab that took the start file from 136.38 kB back to 68.53 kB. Then run the check again. If the code is needed at the start, look for a smaller library. If nothing works and the feature is worth it, raise the budget on purpose, with a written reason.

A strong answer names the traps of splitting. A normal import of the same file somewhere else breaks the split. And a chain of lazy chunks makes a waterfall.

Who should own a performance budget?

The team that builds the page. Their changes move the numbers, so they should see the check fail and decide what to do. A central team can build the tools and help. Write the budget down in the project, and change it only with a reason. Look at it again from time to time. Count outside scripts, like ads and chat windows, against the budget too.

Where to go next

This is the last part of the series. Here is the path you took, with the first part of each stage:

  1. Basics, from Part 1: JSX, components, props, state, events and lists.
  2. Hooks, from Part 11: Effects, refs, reducers and your own hooks.
  3. How React renders, from Part 18: renders, reconciliation and skipping work.
  4. Context and state, from Part 23: sharing data across the tree.
  5. Forms, data and errors, from Part 28: forms, Actions, Suspense, error boundaries and Server Components.
  6. Big apps, fast, from Part 34: splitting, long lists, profiling and routing.
  7. Patterns, from Part 38: ways to build components that others can use again.
  8. Accessibility and testing, from Part 46: apps that everyone can use, and tests that prove it.
  9. Build it yourself, from Part 51: five real components, built from nothing.
  10. Architecture, from Part 56: old code, design systems and budgets.

A good next step is to build something of your own, in a project like the one from Part 10. When you get stuck, go back to the part about that problem. Then read react.dev, the main source for every part of this series.

Sources

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.