Skip to content
All case studies

Case study

Building this site

A portfolio that had to be its own proof. Every performance claim on it is a number measured on the deployed artifact, and the gate fails the build when one regresses.

5 min read

Client
Self-directed
Role
Design, engineering, and infrastructure
Stack
Next.js 16, React 19, TypeScript 7, Tailwind CSS v4, Supabase, Playwright, GitHub Actions, Vercel
First Load JSbrotli, measured on the home route of the build that is serving you
152 KB
Fonts on /down from 207 KB, two variable families
138 KB
CSSbrotli, full design system
9 KB
Third-party requestsenforced by the gate, not by policy
0

Most portfolio sites for engineers are an assertion. This one had to be evidence, which meant deciding up front that any number it published would be one I could reproduce on demand, on the artifact that actually ships.

That is a harder constraint than it sounds, and it changed several decisions.

Budgets first, features second

The performance budgets were written before the first component. That ordering is the entire point: a budget agreed after the code exists is a description, and a description never fails a build.

BudgetFailMeasured
First Load JS on /≤160 KB>175 KB152 KB
CSS total≤25 KB>35 KB9 KB
Fonts on /≤145 KB>160 KB138 KB
Third-party requests on /0any0

Two of those numbers moved after measurement, and it is worth being clear that they moved upward. The JS budget was originally 130 KB, set during research without measuring anything. The empty shell — every route a Server Component, not one "use client" in the tree — measured 149 KB. The chunks were checked for dev markers and are minified production output, so that is the framework floor, not something my code added. A budget the framework cannot meet is not a discipline, it is a number that gets ignored on week two.

The 152 KB above is that floor plus the analytics beacon, measured on the same route today. It is three kilobytes higher than the number this paragraph was originally written around, and finding that out was not a matter of noticing: the gate now reads these published figures out of this file and fails the build when they stop matching what it just measured. A site whose argument is that its numbers are real cannot have a number in it that quietly went stale.

The font budget moved for a different reason: 90 KB was written for "at most two families above the fold," which is not achievable with two variable families, because one of them costs 88 KB by itself. What it cost to get from 207 KB to 138 KB was two deletions:

  • Archivo italic, on a display and UI face where italic is used essentially nowhere. 187 KB → 88 KB.
  • The opsz axis on Source Serif 4, 69 KB for optical sizing on a face rendered at two sizes. That is 46% of the whole JS budget spent on a refinement almost nobody perceives.

Measuring the artifact, not the dev server

Turbopack is the default builder in Next 16, and it prints no bundle-size table. The planned approach — parse the build log — could not work, which was discovered by trying it.

What replaced it reads the built HTML, extracts the /_next/ assets the page actually requests, and brotli-compresses each at maximum quality:

const html = await readFile(".next/server/app/index.html", "utf8");
const assets = [...html.matchAll(/(?:src|href)="(\/_next\/[^"]+)"/g)];
const total = sum(assets.map((a) => brotliSize(a)));

That measures first load specifically. The on-disk total for the same page is 562 KB uncompressed, a number that is true and means nothing.

The end-to-end suite runs against a real deployment, never next dev. A budget assertion against a dev server measures the dev server. This was not a theoretical concern: the first production build passed locally and broke on Vercel twice — once because framework detection had not been set on the project, and once because an environment variable stored as sensitive is returned to the build as the literal string [SENSITIVE], which reached a new URL() call and crashed it. Neither is reproducible locally by construction.

Breaking the tests on purpose

A gate nobody has seen fail is a gate nobody knows works. So after the suite went green, I broke things deliberately to watch it go red.

One check did not fire. The test for unsized images — the main defence against layout shift — looked like this:

const bad = images.filter((img) => !img.width || !img.height);

It never catches anything. img.width returns the rendered width, which is non-zero the moment the image loads, whether or not the attribute was ever written. The check had been passing on every run since it was added, and it was decorative.

const hasAttrs = img.hasAttribute("width") && img.hasAttribute("height");
const ar = getComputedStyle(img).aspectRatio;
return !hasAttrs && !(!!ar && ar !== "auto");

The corrected version was re-run against the same deliberate regression and failed it, which is the only evidence that counts.

Later the gate caught a change of mine that I would have shipped. Adding a site-wide noindex before launch broke a smoke test asserting that robots.txt always contains a Sitemap: line — which had quietly stopped being true. The fix was not to relax the assertion but to replace it with the one that actually matters: robots.txt and the robots meta tag must agree, because the dangerous state is the two disagreeing, in either direction.

The pipeline

Four jobs, and the shape is load-bearing:

verify → deploy → e2e (2 shards) → ready-to-promote

The obvious design is one job with an environment: gate on it. That does not work — the approval prompt fires before the build, so a reviewer is asked to approve a deployment that does not exist yet. Splitting deploy from promote is what lets the tests run against the exact artifact that will be promoted, with --skip-domain so nothing reaches the production alias until it has passed.

Two failures in that pipeline came from the same bug in different clothes, and they are written up separately in set -e killed the error handler I wrote.

What I would tell a client from this

Three things generalised past this project.

Set the number before you build the thing. Every budget here that moved, moved because measurement contradicted an estimate — and every one of those estimates would have been quietly abandoned instead if it had been written after the code.

Test the artifact you ship. Every bug that reached production had one property in common: it was not reproducible on a laptop. Different builder, different environment variables, different DNS.

Break your own checks. Two of the ones here were wrong. One was silently passing on every run since the day it was written, which is the failure mode that costs the most, because it looks exactly like coverage.