Performance Budgets for JavaScript: Enforcing Bundle Limits in CI
Learn why JavaScript costs far more than its download time, how to define a realistic performance budget, and how to make webpack fail builds that exceed it.
This article is published in English.
Sources: The Cost of JavaScript — Performance budgets and why your bundle size is driving users away
A page that feels instant on a developer laptop can freeze for seconds on the phone of an actual customer. The gap is rarely about network speed alone: it comes from the CPU work every kilobyte of JavaScript demands once it reaches a modest device. This guide explains where that cost comes from, how to turn it into an explicit performance budget, and how to make your build pipeline reject changes that break the budget before they ever reach production.
The gap between the developer machine and the user's device
Most frontend teams build and test on hardware that is wildly unrepresentative: recent laptops, wired or fiber connections, and a dev server running on localhost. Under those conditions almost any bundle feels fast, so bundle size tends to be treated as something to clean up later rather than a design constraint.
Real traffic looks different. A large share of visitors arrive on mid-range Android phones, patchy 4G, or crowded public Wi-Fi. Consider a data-heavy dashboard that responds immediately in a local browser, yet during QA on older iPhones the interactive controls take around three seconds to become usable. Nothing is broken in the code; the device simply needs far longer to process the same script.
That delay has a business cost. Extra, unoptimized JavaScript steadily pushes back the moment a user can interact, and the effect is most painful on critical flows such as signing in or adding an item to a cart. When those steps feel sluggish, people abandon them, which is why an oversized bundle can quietly drive users away.
Why JavaScript costs more than its download time
For many years, network latency was the dominant bottleneck, and performance work focused on image weight and transport improvements such as HTTP/2. Those concerns still matter, but for JavaScript the bigger expense today is what happens after the bytes arrive.
A script is not usable when the download finishes. The browser must parse it, compile it, and execute it, and much of that happens on the main thread, the same thread responsible for handling taps, clicks, and rendering. It is entirely possible for a 400KB bundle to download quickly and still leave the page feeling unresponsive, because the device is busy chewing through code instead of reacting to input.
On a mid-range phone, this parse-compile-execute cycle can visibly hurt Interaction to Next Paint (INP), the metric that measures how quickly a page responds to user input. That feeds straight into your Core Web Vitals scores, and weak vitals tend to track with lower retention and weaker business results. Any serious effort to improve those metrics therefore has to include cutting the amount of JavaScript that mobile devices must parse and run, not just shrinking download sizes.
What a performance budget is and how to set one
A JavaScript performance budget is an explicit limit on how much script a page, route, or chunk may ship. Technically it is just a number, but its real value is organizational: it is an agreement the whole team signs up to. Instead of discovering regressions after release and scrambling to fix them, the budget acts as a guardrail that stops them from being merged in the first place.
Practical tip: A budget turns performance from something you react to into a constraint you design around, just like accessibility or security requirements.
Setting one starts with picking concrete, measurable limits. A widely used target is keeping the critical-path JavaScript of any page below roughly 170KB compressed, a figure chosen so that a median mobile device can reach Time to Interactive quickly. You should validate any such number against your own audience, but the principle holds: once the limit exists, every new dependency and feature has to justify its weight from day one.
A few practical points make budgets easier to live with:
- Budget per route or entry point, not just for the whole application, so a heavy admin area cannot eat the allowance of the landing page.
- Measure the same way every time (compressed versus raw size) so numbers remain comparable between builds.
- Revisit the limit when your audience or device mix changes, rather than silently raising it whenever a build fails.
Automating the guardrail in CI/CD
Budgets that depend on someone remembering to check them do not survive for long. A feature gets pushed, a reviewer focuses on logic, nobody looks at the bundle report, and a regression ships. The only approach that scales is to wire the check directly into the pipeline, so every change is measured automatically.
Note: With the check running in CI, each pull request is validated against the budget without anyone having to remember to look.
Build tools such as webpack can do this for you: configure them to treat oversized output as a build failure instead of a warning. The feedback then arrives right away, attached to the change that caused it. If a pull request introduces a library that pushes a chunk over its limit, the pipeline goes red and the author has to deal with it before merging.
The configuration below uses webpack's performance option. Setting hints to 'error' upgrades size warnings into errors that fail the build. maxAssetSize caps any individual emitted file (here 250 KiB), while maxEntrypointSize caps the combined assets loaded for a single entry point (here 500 KiB); both values are expressed in bytes. The assetFilter function decides which files are evaluated at all, in this case skipping source maps and JSON files so they do not trigger false alarms.
module.exports = {
// ... other webpack configuration ...
performance: {
hints: 'error',
// Emit an error whenever a bundle exceeds the size limits
maxAssetSize: 250 * 1024,
// Maximum size in bytes for any generated asset (e.g., 250 KiB)
maxEntrypointSize: 500 * 1024,
// Maximum size in bytes for an entrypoint (e.g., 500 KiB)
assetFilter: function(assetFilename) {
// Optionally exclude certain assets (e.g., source maps) from performance hints
return !/\.(map|json)$/.test(assetFilename);
}
}
};
Keep in mind that webpack compares these limits against the emitted file sizes, which are generally not the compressed sizes users download. If your budget is defined in compressed kilobytes, translate it into raw sizes for this check, or pair it with a separate step that measures gzip or Brotli output.
Strategies that keep bundles within budget
A budget and an automated check tell you when you are over the limit; staying under it takes deliberate engineering. The most effective techniques are well known, and they work best in combination.
Split code along routes and features
Code splitting breaks one monolithic bundle into smaller chunks that load on demand. A common case is a heavy admin dashboard inside an enterprise application: by loading it through dynamic import() statements, its code is fetched only when an administrator actually navigates to that route. Regular users never download it, which can remove hundreds of kilobytes from the initial payload.
Let tree shaking remove dead code
Tree shaking eliminates exports that are never used, so unused parts of your own modules and of libraries do not end up in the output. It works best with ES module syntax and dependencies that are published in a tree-shakable form, so it is worth checking how a package is built before relying on it.
Watch out: Pulling in a large library for a single helper function is one of the most common sources of bloat. Look for a lighter alternative, or import only the specific function you need.
Defer what the first screen does not need
Lazy loading components that are not critical to the initial view, such as dialogs, below-the-fold widgets, or rarely used panels, postpones their parse and execution cost until the moment they are required. Combined with code splitting and tree shaking, this keeps the main thread free during the first seconds when users are most likely to interact.
Wrapping up
- The expensive part of JavaScript on mobile is parsing, compiling, and executing it on the main thread, which directly affects INP and the other Core Web Vitals.
- A performance budget is a team agreement expressed as a number, ideally per route and measured consistently.
- Enforcing the budget in CI, for example with webpack's
performance.hints: 'error', catches regressions in the pull request that introduces them. - Code splitting, tree shaking, and lazy loading are how you stay under the budget once it exists; test on realistic devices to confirm that the numbers translate into a faster experience.