This site is a browser-side toolkit. You pick a file, it gets resized or converted, and nothing is uploaded anywhere.
That promise is the product. But it comes with a cost: it moves the work from my machine to yours. No server pays for the CPU, and no server pays for the download either. Whatever a tool needs has to arrive before the tool can be used.
That is easy to forget on a laptop on wifi. On a phone on mobile data, it is the difference between a tool and a spinner. And this is a site people use once: no account, no sign-up, no reason to come back and wait a second time. So the bytes a page needs before it works are the first impression.
I wanted that number written down, instead of estimated by feel. The plan for the site has the real budgets: 50 KB of first-paint JavaScript, 100 KB for HTML + CSS + JS together, and 500 KB for the full first load including fonts and images.
On top of those, I kept a stricter number for myself. "The limit you must not cross" and "the early warning that fires first" are two different jobs, and I wanted both. My early warning sat at 20 KB.
Where did 20 KB come from? I measured the tool pages, then added room for the tool I was about to build. The comment next to the constant said it left "about 108% headroom for new components," which sounded reasonable at the time. It took me one day to ask the only question that mattered: headroom above what?
1. Opening the box
Each tool on this site is an "island": a chunk of HTML that arrives already rendered, plus a small client script that later attaches behavior to it. The pattern is called islands — render everything up front, then add interactivity only where it is needed. The page is usable before any JavaScript runs.
The first version built those islands with Preact. Tool pages shipped up to 11.2 KB across five files — four of them framework, one of them mine:
| chunk (one file each) | gzip | size | share |
|---|---|---|---|
preact.module |
4,412 B | 4.3 KB | 38% |
client.* — Astro hydration client |
1,405 B | 1.4 KB | 12% |
hooks.module |
1,161 B | 1.1 KB | 10% |
jsxRuntime.module |
399 B | 0.4 KB | 4% |
| framework subtotal | 7,377 B | 7.2 KB | 64% |
Tool.* — my own island |
4,100 B | 4.0 KB | 36% |
| total | 11,477 B | 11.2 KB | 100% |
The two bolded rows are sums of the rows above them — the
gzipcolumn adds to 11,477 B once, andshareto 100%.
Read that table again. Two thirds of the first paint was not the product.
Worse, the part that could actually regress — the compress / convert / resize logic — was the smaller half. I was measuring my own code with the same ruler I used for 7.2 KB of code I had not written and could not fix.
That answers my own question. I had set the headroom on top of a baseline that was 64% framework. With four tools shipped, first paint was already 11.2 KB — past the halfway mark of a 20 KB ceiling, and two thirds of it still not mine. Adding a tool properly grows first paint by roughly 1.5 KB, so 20 KB would have kept passing while the framework quietly ate a budget I thought was mine.
2. Deleting the framework was the easy part
I removed the framework integration and split the same UI into two halves:
- build time renders the complete UI into HTML, so there is a working interface with zero JS, and something for a crawler to read;
- runtime attaches behavior to it — events, state, text.
The same pages went from 11.2 KB across five files to 1.2 KB in one. That was a one-day change, and it is the part everyone writes about.
The part that took longer was making the number honest. Start with one detail: both readings above came from a ruler that could not see the inline scripts on the page. The honest per-page figure today is 3.4 KB (section 4).
If you want the opposite direction — building small on purpose from the first commit — there is a good write-up here on DEV: I shipped a working landing page in 14 KB. Here is every byte. Mine was the other direction: bytes already in the file, wearing my name, that I had not written.
Bug #1: I was counting the wrong thing, twice
My first collector walked the HTML, grabbed every <script src="…">, and added them up. The number was wrong by roughly 3×.
Here is why. The entry point of a script is not always a src attribute. A bundler can emit a module script whose body imports the real chunk, and a scan that only looks at src sees none of it. Two hand-written inline scripts on the page — 2 KB gzipped between them — were invisible for the same reason.
I fixed that and thought I was done. Then I added a mobile nav toggle: about 0.3 KB typed straight into <head>. The reading did not move. The collector had the same blind spot in a second place: it only followed _astro/*.js files.
That is the failure mode I keep coming back to. A size guard that cannot see the code you write by hand does not protect your budget — it protects the framework's share of it. On a small site, the thing that grows fastest is exactly those small snippets you paste in while building features.
So the rule became: the guard must not be narrower than the thing being measured.
Trap #2: don't punish the correct answer
Here is the subtle one — and this one I did not walk into. Heavy code — an image decoder, an audio encoder — is loaded with a dynamic import(). That puts it in its own async chunk, which never touches the first paint. That is the whole point of the architecture.
A guard that counts every import would flag the one technique that keeps first paint small. You would have a robot failing the build for doing the right thing, and after the second false alarm someone turns the guard off.
So the collector follows static imports only. The regex matches from"…" and import"…" — both followed by a quote. import( is followed by a parenthesis, so dynamic imports never match. The difference is one character wide, and it decides whether the guard helps you or fights you.
I am not claiming this is airtight. A module can call import() at the top level: nothing in the static graph points to it, and a byte ceiling cannot see what is not in the graph. That gap is real. I would rather write it down than pretend otherwise.
3. Make "stricter" something the machine enforces
With the framework gone, the warning line came down from 20 KB to 4 KB. There are two numbers in the repo now:
const BUDGET_FIRST_JS = 50 * 1024; // the plan's hard limit
const FIRST_JS_CEILING = 4 * 1024; // the regression guard — stricter, on purpose
A stricter number only means something if it stays stricter. So the script refuses to run when the internal number has been loosened past the plan's budget:
if (FIRST_JS_CEILING > BUDGET_FIRST_JS) {
console.error('✗ the regression guard is looser than the plan budget');
process.exit(1);
}
Without that four-line check, "we are stricter than we need to be" is a sentence in a document. With it, it is a property of the build.
4. The number is also a proxy — that's why it's 4 KB
I also do not ship WebAssembly. Nothing needs it: image work goes through createImageBitmap, canvas, and OffscreenCanvas inside a worker.
The "no WASM" rule and the size ceiling turn out to be the same check. The smallest WASM image decoder worth using starts around 200 KB. libheif is closer to 1 MB, and the common HEIC wrappers land near 500 KB. Those are uncompressed numbers; even the smallest of them is still well over 10× a 4 KB ceiling once compressed. So there is no gray zone: if someone statically imports a decoder, the build breaks the same day.
One number enforcing both "the page stays light" and "no WASM sneaks into the critical path" is a better deal than two policies somebody has to remember.
And you can check it from the outside, which I like. On the deployed site, the only script the build controls has no static imports at all — five dynamic ones:
compress: () => import('../tools/image-compress-to-size/mount.ts'),
convert: () => import('../tools/image-convert/mount.ts'),
// …
The site has gone from one island to nine since then. The deployed build lags a couple of releases behind, which is why the script above lists five. Largest first-paint script, measured per page: still 3.4 KB.
One boundary on that number, since I am asking you to verify it. The guard counts what our build emits: the _astro chunks reachable from the import graph, plus the inline scripts we hand-write. It does not count the analytics beacon Cloudflare injects at the edge, because that is third-party code we do not bundle. I would rather name the edge of the measurement than imply the number covers the whole page.
5. What the 7.2 KB was buying
Deleting the framework was not free. The bill just does not arrive in kilobytes:
- skeleton and behavior are connected by a hand-written contract —
data-roleattributes and class names. Change one side and you must change the other. A runtime assertion throws the moment they disagree, and that assertion is the only reason this stays survivable. - there is no diffing. List updates are written by hand.
- the initial text in the skeleton must match the first render word for word, or the page visibly twitches the instant it attaches.
- adding a tool is one extra step compared to writing a component.
That is what 7.2 KB buys: a fair price for a site that adds new screens faster than it adds new pages.
I was not buying that. This site adds tools, which are mostly logic, and it reuses the same few interaction patterns. I was paying a framework tax every week for something I needed once — out of a budget I had told myself was mine.
If your first paint is mostly React and you are fine with that, this is not an argument with you. The lesson I would pass on is smaller and more annoying: a size budget is only as good as the thing it measures. Mine stayed green the whole time and told me nothing about the code I was writing.
The tools run at https://filecabin.app if you want to read the headers yourself — connect-src 'self', no 'wasm-unsafe-eval', and the same script quoted above.
If you have had a size guard turn out to be measuring the wrong thing, I would like to hear about it.
Top comments (0)