tryhomies.app: three budgets and a check that was never green
tryhomies.app is the website for Homies, the household app I build privately on the side. It is a Next.js site in English and Hebrew: it takes waitlist signups for a closed beta, carries guides meant to be found from search, and hosts the pages the app stores require.
Its homepage has three performance budgets, each a script that exits 1 when the page goes over. From 17 to 20 September they ran inside a pull-request check that failed on every run it completed, so the evening Next 16 removed a file the bundle gate read, the one red that meant something looked like all the others. A check that is red every time is the same as no check.
The bundle gate's ceiling was raised, the hydration gate's was set from its own first reading, and the Lighthouse gate's held. The code was written in agent sessions and I merged it. On 24 September a pull request fixed the two failures every run had shipped with, and since then most runs have passed, so a red one is news again.
- 0 of 42pull-request checks that ended green
- 150 to 240KB, the bundle gate's ceiling before and after
- 0elements hydrated, and the hydration gate passed
Why a launch site has three budgets
The work started from PageSpeed Insights on the live site. I did not like the mobile score, and in July I asked for 100 on mobile and desktop. Two gates landed the next day.
The first, the Lighthouse gate, runs Lighthouse against a production build, both languages, phone and desktop, and fails unless every category scores 100 on the median of three runs. Its header says where the number came from and what to do about it: "when the gate is red the thing to fix is the page, not the number".
The second, the bundle gate, measures the JavaScript that belongs to the homepage alone, after the chunks every page shares are subtracted, and fails above 150 KB. It arrived in the commit that removed an animation library from the homepage, and its comment says the ceiling was chosen to reject the roughly 234 KB that commit had just got rid of. It runs inside the build command, so a build that goes over does not finish. The instructions the repository gives to agent sessions list it under rules that are not up for negotiation: "The homepage bundle budget is a real gate, not advice", and "Don't raise the constant to make a build pass."
The third, the hydration gate, came on 17 September. It loads the built homepage in a browser and counts the elements React has attached itself to, as a measure of how much of the page has to hydrate.
| Gate | What it measures | Ceiling | Where it runs | What happened |
|---|---|---|---|---|
| Lighthouse gate | Every Lighthouse category, both languages, phone and desktop | 100 in each, median of three | Nowhere: no workflow or schedule calls it | Held, and still exits 1 on performance |
| Bundle gate | The homepage's own JavaScript, shared chunks subtracted | 150 KB, then 240 KB | Inside the build command: CI and every production build | Crashed on Next 16, then was rewritten and raised |
| Hydration gate | Elements React hydrates on the built homepage | 1,000 elements | The pull-request check | Set just above its first reading of 931 |
A check that was never green
Also on 17 September, a workflow began running on every pull request: type check, translation parity, the build with the bundle gate inside it, then the Playwright browser test suite against a dev server. The hydration gate joined it that evening. Up to 20 September it ran on every pull request except dependency updates, which it skips, and failed 42 times out of 42.
In all but one, the failing step is the last, the browser suite, with between 2 and 13 of its roughly 245 tests failing. Three commit messages from those days call them known flakes of the dev server and move on. The bundle gate ran before that step and passed every time, and so did the hydration gate once it was added. So the budgets were being checked, and the result was delivered inside a check that was red every time.
37 pull requests were merged in those four days, each one over a red check. Nothing stops that: on the plan this account is on, GitHub offers no branch protection for a private repository, so a check cannot be made required. The repository's rule is that no session merges to main on its own and every merge is mine to approve. I approved them.
The one red that meant something
One of those runs is different. On 17 September it belonged to the pull request that moved the site to Next 16, and it is the only one that failed at the build step:
> tsx scripts/check-homepage-bundle.ts
Error: ENOENT: no such file or directory, open '.../.next/app-build-manifest.json'
Process completed with exit code 1.The bundle gate read the list of chunks for each route from a manifest that Next 15 wrote into the build directory. Next 16 builds with Turbopack and does not write it. The session that did the migration knew, and left an escape hatch: set one environment variable and the gate prints that it was skipped and exits 0. The hatch was a line inside the script. Nothing set the variable in the workflow, so in CI the gate did what a gate should do when it cannot measure, and failed.
| Time | What the record shows |
|---|---|
| 20:20 | An issue is filed for rewriting the gate. It describes the current state as "Gate exit code: 0 (skipped)" and rates the risk low because the escape hatch is in place. |
| 20:22 | CI on the migration's pull request: the build step exits 1 on the missing file. |
| 20:26 | The pull request is merged, 4 minutes after that line. |
| 20:28 | The production deployment of the merged commit is recorded as failed. |
| 22:19 | The rewritten gate is merged. A minute later its production deployment succeeds. |
Of the production deployments between 17 and 20 September, that is the only one marked failed. I have not read the host's log for it. The site deploys on Vercel with the default build command, the same command that had crashed in CI five minutes before, and the next deployment to succeed is the one that carries the rewritten gate, 112 minutes later. A failed deployment leaves the previous one serving, so the site stayed up on its Next 15 build while main could not ship.
The red mark on that pull request was the one that meant the change would not deploy. It looked the same as all the others. The place where the gate could not be merged past was the production build, and that is where it stopped the change.
The budget that moved
The rewrite reads the file Next 16 writes in the old one's place, a per-route list of first-load chunks under the build's diagnostics directory, and it removed the escape hatch.
The same commit raised the ceiling from 150 KB to 240 KB, because the new reading was 215,359 bytes and the old ceiling would have failed every build. A session chose the number and I merged it without weighing it. The issue had asked for a reading within five percent of the old one, or a threshold revised with measurement. It got the second. To see whether the growth was real or an effect of measuring differently, I built the commit before the migration and the rewrite itself.
| Build | Homepage only (budgeted) | Shared by every page (not budgeted) |
|---|---|---|
| Before the migration, Next 15 | 124,460 | 363,380 |
| The rewritten gate, Next 16 | 215,359 | 452,165 |
| The current code | 177,284 | 452,165 |
So the growth was real, in both columns. The commit's explanation is only half usable, though. It puts part of the increase on framework machinery in the shared bootstrap, and the shared bootstrap is exactly what the gate subtracts, so nothing that grew there could have moved the number the budget is about. The other half, that Turbopack splits code between shared and per-route chunks differently, fits what the builds show. The new ceiling is also above the 234 KB the gate was written in July to reject, with the caution that two bundlers draw that line in different places and the figures are not like for like.
The next morning a session moved the analytics scripts and the toast component behind an idle callback, and the gate's reading fell to about 172 KB. The ceiling stayed at 240. The rewrite's own comment says to tighten it deliberately when the number shrinks, and nobody had, so the homepage could gain about 67 KB before the gate noticed, while the instructions to sessions still said 150 KB and still said not to raise it.
The budget that was born at its own measurement
The hydration gate replaced an older target that counted every element on the page. A session showed that count could not move in the direction the work was going: turning a client component into a server component removes its hydration and leaves its elements in the HTML. The replacement counts only elements React has attached to, and the issue proposed a ceiling of 600.
The first reading was 931. The script sets the ceiling at 1,000 and its comment gives the reason: a provider component wraps the whole homepage, the work in hand could not bring the count under 600, and a gate that is red from its first day repeats the failure of the one it replaced. The same comment ends "Pending owner ratification", and that line stayed in the file.
A ceiling placed just above the first reading catches growth from that day on. It says nothing about whether 931 was acceptable, and nothing since has asked.
The budget that held
The Lighthouse gate's bar of 100 was never touched, and the work it asked for was done. On 19 September sessions made three changes aimed at the largest paint on a phone. The two display typefaces were switched to font-display: optional, so the heading commits to a face at first paint and does not repaint when the web font lands. The Hebrew font, which by its commit's account had been preloading 42 KB from Google's Hebrew and Latin files, became one self-hosted file of 25,080 bytes holding only the characters the Hebrew pages paint. And the global stylesheet was split into one copy per language, which its commit says was to keep the English font chunk off the Hebrew pages.
Measured before and after on different machines, so only roughly: the largest paint on a phone moved by about a second in Hebrew and did not move in English. The other three categories scored 100, and the gate still exited 1 on performance.
This is the only one of the three budgets that still means what it meant on the day it was written. It is also the only one nothing runs: no workflow and no schedule calls it.
What the gates cannot see
I did not take it from the scripts that the gates still bite. In a copy of the current code I imported 100 KB of strings into the homepage's hero component and built it, and the build exited 1. I put the old 150 KB ceiling back and the bundle gate failed the current code. I removed the file it reads and it crashed with exit 1, as it did on 17 September. I lowered the hydration ceiling below the current reading and that gate failed too.
Then I put 300 KB into the file that loads on every page. Next's own figure for the homepage's first load grew by that much. The bundle gate printed the same number as before and exited 0. It covers the part of the download that belongs to the homepage, which is under a third of it. The shared part, where React and the error reporter live, is about 442 KB that no gate reads.
The hydration gate has a ceiling and no floor. I moved the built JavaScript aside and ran it unchanged:
locale=en fibers=0 status=200
locale=he fibers=0 status=200
Hydration gate PASSED: en=0, he=0 ≤ 1000 per locale.A homepage on which no script ran passes. The script prints the response status and never checks it. Whether the page still works in that state is a question for the browser suite, and in those days the browser suite was the step that was red every time.
The Hebrew font file is kept correct by a sentence in its audit notes: run the audit again when the Hebrew copy changes. The copy changed several times in the two days after the file was cut, so I ran it again. Across the Hebrew pages, the only painted characters missing from the file are the two arrows, check mark and hyphen the notes already list as absent from the typeface itself. It holds, and nothing automated would say so if it stopped. The test that pins the font behaviour is excluded from the default suite and has its own configuration that no script or workflow runs.
The two stylesheets are copies, and nothing compares them. A change made to one language and forgotten in the other would show up as a visual difference, if someone looked.
The guard that held on 17 September is the plainest one. The bundle gate is part of the build command, the host runs the build command, and a deployment that fails stays failed whatever anyone merges.
What made red mean something again
On 24 September a pull request fixed the two failures every pull request had shipped with, the ones the commit messages called known flakes. Both were in the Playwright suite, e2e/funnel.spec.ts, run against next dev.
One was not a flake at all. The test #229 event-driven waitlist modal load held back the waitlist modal's code until a click, and it found that code by the chunk's name. Under the dev server, the chunk with that name is Turbopack's small loader, which ships with the homepage, so the page never finished loading. The fix finds the modal's chunk by what is inside it.
The other came and went for a reason. Next's development indicator can expand into a "Cache disabled" badge in the bottom-left corner, which in Hebrew is where the bug-report button sits, and when it did, it took the click. The test server now turns the indicator off.
Since that merge most runs of the check have passed, so a red one is news again. The browser suite still runs against a dev server.
What I would set up now
None of these exists. They are the gaps in what the gates cannot see, as I would propose closing them if I set these gates up again:
- A ceiling on the whole first load, shared chunks included, so the 442 KB where React and the error reporter live has a number too.
- A floor on the hydration gate, and a check of the response status it already prints, so a page on which no script ran fails.
- The Hebrew font audit as a test, with the font test in the default suite, so a copy change that paints a missing character goes red.
- A check that compares the two stylesheets, so a rule changed in one language and not the other fails before anyone has to spot it.