Bytes are the cheap part
A 200 KB image and 200 KB of JavaScript cost the same to download and nothing alike after that. The image is decoded off the main thread and drawn. The JavaScript has to be parsed, compiled, executed, and then it usually allocates objects, builds a DOM and attaches listeners — all on the one thread that also has to answer the user.
This is why the same site feels instant on a laptop and broken on a phone. The download differs by a factor of two; the CPU differs by a factor of eight.
A rough exchange rate
These are order-of-magnitude figures from published measurements, not a law — but they are close enough to reason with, and they make the point immediately: 500 KB of boot JavaScript is about half a second of frozen main thread on a mid-range phone, before your code has done anything useful.
The honest version of the lesson: measure on a real device, or in DevTools with CPU throttling at 4× or 6×. Never judge JavaScript cost on the machine you wrote it on.
| Device | Parse + compile + execute |
|---|---|
| Desktop / recent laptop | ≈ 0.25 ms per KB |
| Mid-range phone | ≈ 1 ms per KB |
| Low-end phone | ≈ 2 ms per KB |
The question to ask of every module
Not "is this library big?" but "does this have to run before the first screen?". Most of a bundle is code for things the user has not done yet: a checkout flow, a chart, a date picker, an analytics SDK.
/* on demand — the import happens when the route is reached */
const Charts = lazy(() => import("./charts.js"));
/* when the browser is idle */
requestIdleCallback(() => import("./analytics.js"));
/* on the first interaction that needs it */
button.addEventListener("click", async () => {
const { openEditor } = await import("./editor.js");
openEditor();
}, { once: true });
The cheapest dependency is none
Tree shaking helps only if the library is written for it — a CommonJS build, a side-effecting import, or a barrel file that re-exports everything will hand you the whole package. Check the built bundle, not the import statement.
| Instead of | Use |
|---|---|
| A date library for one format call | Intl.DateTimeFormat |
A utility library for groupBy / uniq | Object.groupBy, new Set(), Array.prototype.at |
| A carousel framework | scroll-snap-type in CSS |
| A validation library on the client | Constraint validation attributes, plus the server check you needed anyway |
Third parties are someone else’s main thread
A tag manager, a chat widget, a consent script and two analytics SDKs will out-weigh your application, and you control none of them. The rules: load them async or defer, never in the critical path, never synchronously in the head; give them a facade (a static image of the chat button that loads the real thing on click); and put a budget on them that fails the build.
And measure what they cost with them removed — the honest number, not the vendor’s.