The bundle you didn't mean to ship
Nobody decides to send 400kB of JavaScript to a phone on a train. It arrives one reasonable-looking import at a time, and the only way to stop it is to make the cost visible.
- performance
- react
- tooling
Every oversized bundle I've been handed was assembled by careful people. Nobody opened a pull request titled ship 400kB of JavaScript to a phone on a train. It arrives one defensible import at a time, and each one is genuinely the fastest way to close the ticket in front of you.
The date library, because writing date maths by hand is a bad use of an afternoon. The icon set, because you needed four icons. The charting library, because the design has a sparkline in it. Individually, all correct. Collectively, a first load that takes four seconds on the connection your users actually have.
The cost is invisible at the moment you pay it
This is the whole problem. When you add an import, your dev server is already warm, your machine is fast, and the module is on disk. The cost lands somewhere else entirely — on a device you don't own, on a network you can't see, weeks later, as a number in an analytics dashboard nobody has opened.
So the fix isn't discipline. Asking engineers to remember that bundle size matters works for about a fortnight. The fix is to move the cost to the moment the decision is made.
Make the number fail a build
The single highest-leverage change I've made to a frontend codebase is a size budget in CI that can fail. Not a report. Not a dashboard. A failing check on the pull request that added the import.
// size-limit.config.js — budgets are per entry point, not for the whole app.
module.exports = [
{
name: "app shell",
path: ".next/static/chunks/main-*.js",
limit: "90 kB",
},
{
// The dashboard is allowed to be heavier: it is behind auth, it is a tool
// people use for an hour, and it genuinely needs the charting library.
name: "dashboard route",
path: ".next/static/chunks/app/dashboard/*.js",
limit: "180 kB",
},
];The per-route budgets matter more than the number. A single global limit turns into a tax on whoever happens to be working when the ceiling is hit, and they have no context on what the previous 380kB bought anyone. Per-route budgets put the conversation where it belongs: this route got heavier, is that worth it?
Read the treemap before you optimise
The instinct when a budget fails is to start deleting your own code. It is almost never your own code.
Run the analyser and look at what actually occupies the space. The pattern repeats across every project I've profiled:
- A date library where three formatting calls were needed.
Intl.DateTimeFormatis built into the platform and handles locales better anyway. - A whole icon package imported as a namespace, so tree-shaking gives up and ships all six thousand of them.
- A rich-text or charting library loaded on every route because it lives in a shared component barrel, even though one page renders it.
- Two copies of the same transitive dependency at incompatible versions, which nobody notices because both work.
That last one is worth checking first. It costs nothing to fix and is invisible without the treemap.
Barrel files are where tree-shaking goes to die
The index.ts that re-exports everything in a directory is a real ergonomic win
and a real bundling hazard. Importing one component from a barrel pulls the
module graph of everything the barrel touches, and whether your bundler can shake
it out depends on side effects it usually cannot prove are absent.
I don't ban them. I do keep the shared component barrel free of anything heavy — if a component pulls in a large dependency, it gets imported directly from its own path, and the barrel doesn't mention it.
Defer what isn't on the critical path
Once the obvious weight is gone, the remaining question is timing rather than size. A chart the user has to scroll to does not need to be in the first payload:
// Loads when the component renders, not when the route does.
const RevenueChart = dynamic(() => import("./revenue-chart"), {
loading: () => <ChartSkeleton />,
ssr: false, // it needs canvas measurements that only exist client-side
});The skeleton is not decoration. Without a placeholder of roughly the right size, deferring the import trades a slow load for a layout shift, and you have moved the problem rather than solved it.
What actually holds
Budgets in CI, per route. A treemap you look at when one fails. Heavy things kept out of shared barrels. Deferred loading for anything below the fold.
None of it is clever. The reason it works is that it makes the cost of an import visible at the moment somebody adds it — which is the only moment anyone is in a position to decide whether it's worth paying.