A bundle diet, one commit at a time
I spent a few weeks on the payload of an app that ships to web and mobile from one codebase. Not a rewrite, not an architecture change — a sequence of small commits, each with a number attached. The pattern that emerged is worth more than any individual trick: almost every large win was removing something that had no business being there, and almost every small win was a genuine engineering trade.
The things that should not have been there
Development tooling in the production bundle. The single biggest win of the whole exercise, at around 25% of the JavaScript, was excluding dev tooling that was being bundled for users. Nobody adds that on purpose. It arrives as a default, or through a framework upgrade, and unless you look at the chunk list you will never know.
The analyser itself. A bundle analyser is a dependency. Once it has told you what you needed, it is another thing shipping. Removing it was worth mentioning in its own commit, which is a small joke that is also the point: tools you use to measure weight have weight.
Icon fonts. This is where cross-platform projects leak the most. A generic icon package pulls entire font families; the app used a handful of glyphs. Two moves: swap the umbrella package for the leaner platform-specific one, then replace one family's usage with inline SVG so the font disappeared from web entirely. A further step subset the remaining icon font down to the glyphs actually used.
Fonts at all. Serving TTF where WOFF2 works costs roughly half the bytes for nothing. Subsetting the text faces to the character ranges the app actually renders took another 150 kB off the first load.
The things that were real trades
Bundler change. Moving the web build to Turbopack improved tree-shaking, so the bundle shrank without anything being removed by hand. Worth doing, but it is a bet on tooling rather than a decision about your own code.
Platform-conditional imports. In a shared codebase it is easy for a mobile
bottom-sheet library, or a stack of Platform.OS branches, to end up in the
web bundle even though every branch is dead there. Making those imports
conditional is unglamorous, fiddly, and paid about 10% of the largest chunk.
Localisation resources on mobile. Shipping only the language resources the app supports, rather than everything a library offers, took nearly a megabyte off the installed size. Different platform, same principle.
What I would do differently
Look at the chunk list before touching any code. Every large win here was visible in that list from day one and invisible in the total number — the total tells you that you have a problem, the breakdown tells you whose problem it is.
And measure first load specifically, not bundle size in the abstract. Most of these changes did nothing for a returning user with a warm cache. They all mattered for someone opening the app for the first time on a phone, on mobile data, which for a lot of products is the only visit that decides anything.
The uncomfortable part
None of this is clever. There is no algorithm here, no data structure, no insight — just looking at what is actually in the file and asking why. It took about four weeks of small commits, and I suspect the reason work like this gets deferred is precisely that it does not feel like engineering while you are doing it. It just makes the product faster for everyone who ever opens it, which is a strange thing to keep at the bottom of a backlog.
- Performance
- Next.js
- React Native Web
- Bundle size