Engineering · 4 min read
A budget that only warns is not a budget
We wrote performance and accessibility budgets into an architecture document. Then we made them fail the build, and they immediately found something we had missed.
Most teams have performance budgets. Almost none of them have budgets that stop anything.
The budget lives in a document. Someone runs Lighthouse before a launch, notes that the score has slipped, and files it as technical debt. The number gets worse every quarter and nobody can point at the change that did it, because no single change was responsible.
We wrote our budgets into an architecture document too. Then we made them fail the build, and the difference showed up immediately.
What we enforce
Seven things, all of which will stop a merge:
- Client JavaScript under 130KB gzipped. We are at 35.6KB.
- Largest Contentful Paint under 2.5 seconds.
- Cumulative Layout Shift under 0.1.
- The HTML document under 120KB.
- Fewer than 25 requests on first load.
- No third-party origins on the homepage.
- No WCAG 2.2 AA violations across four routes.
The last one is the interesting one.
Automation catches about a third of it
Accessibility tooling is good at what it can see: missing labels, unlabelled buttons, contrast ratios on rendered elements. It cannot see whether a keyboard user can escape your form, whether your focus order matches your visual order, or whether your error message is announced when it appears.
So we check three ways.
The first is axe against the rendered routes, tagged for WCAG 2.2 AA. The second is a keyboard pass that tabs through the form and asserts every visible focusable control is actually reached — a focus trap makes that number smaller, and nothing else in the suite would notice. The third is a contrast assertion over the design tokens themselves, in both light and dark themes, which runs as a unit test and does not need a browser at all.
That third one is worth explaining, because it is where we found something.
Gold cannot carry text on a light background
Our palette carries a tan-gold accent. It is the only warm colour in an otherwise near-monochrome system, and it does a lot of work.
Measured against the light page surface, it scores **2.31:1**. WCAG asks for 4.5:1 for body text and 3:1 for large text. Gold fails both. Darkened far enough to pass, it stops being gold and becomes brown, which is a different brand.
Against the dark surface it scores 7.89:1 — AAA.
That arithmetic made a design decision for us. The identity is dark-first, not because dark was fashionable but because it is the only configuration where the accent is usable for anything a reader has to read. On light surfaces gold survives as a rule, a border and an underline, and never as a word.
We could have written that down as a guideline. Guidelines are broken by the third person who joins, or by the same person on a Friday. Instead the test parses the stylesheet, finds every token named `--fg-*`, and asserts it meets 4.5:1 against its own theme's page surface. Any future foreground token that fails is caught by a rule nobody has to remember.
We verified the guard bites by breaking it deliberately — setting the light-mode accent to gold produced exactly the two failures we expected, and reverting passed.
What it found the first time it ran
`link-in-text-block`, severity serious.
Inline links were distinguished from surrounding text by colour alone. Gold on muted grey does not meet the 3:1 non-colour requirement, which means a reader with a colour vision deficiency could not tell a link from a sentence.
The fix took one line — underline inline links — and it is what our own design direction had specified in the first place. We had written it down and not implemented it. The document said the right thing; the stylesheet did not; and only the test noticed.
That is the whole argument for enforcement in one example. We were not careless. We had the correct decision recorded. It still did not make it into the code.
The cost
Budgets in CI make some pull requests fail for reasons the author did not cause. Someone adds a feature, the bundle crosses a threshold that three earlier changes brought it close to, and they inherit the problem.
That is a real cost and we accept it, because the alternative is that nobody inherits it and the number just gets worse. A build that fails names a specific change and a specific person who has the context to fix it. A quarterly Lighthouse audit names nobody.
Two of the failures on our first run were our own tests being wrong rather than the code — one expected a honeypot field to be reachable by keyboard when it is deliberately outside the tab order, another compared a duration to `0ms` when browsers compute `0s`. Bad assertions are part of the price. They are also cheap to fix, and they get fixed once.