Ship the Checks With the Code

After a lot of web projects, I keep doing quality checks after I push. Performance, SEO, image sizes and accessibility should be checked before a change lands, as a default part of every project, not an audit you remember to run later.

contents (6)
  1. Audits should be tests
  2. What belongs in the gate
  3. Pull request or git hook
  4. An example
  5. What a lab cannot tell you
  6. Why it should be the default

I have shipped a lot of web projects this year, and almost every one followed the same loop. Write the feature. Push. Wait for the deploy. Open PageSpeed Insights. Find out an image is three times larger than it needs to be, a page has no meta description, or a bundle doubled in size. Fix it. Push again.

The checks were never the problem. The timing was. I was doing quality assurance after the code was already live, which is the most expensive moment to do it.

Audits should be tests

We already accept that some things get checked before code lands. Tests run on every pull request. Linters run before a commit. A type error fails the build. Nobody thinks of these as extra steps anymore; they are just part of how a project works.

Performance, SEO, image weight and accessibility are still treated as audits: occasional, manual, and usually after something has gone wrong. I think that is backwards. Most of what PageSpeed Insights tells you could have been known before the change was merged.

None of this is new. Lighthouse CI has existed for years, and “performance budgets” are an old idea. What is missing is that it is not the default. A new project starts with a test runner and a linter. It almost never starts with a check that fails when the homepage gets too heavy.

What belongs in the gate

The mistake I see most often is gating on the wrong things. Lighthouse’s performance score moves a few points between two runs of the same page. Put “performance must be at least 90” in your CI, and it will fail at random. People learn to rerun it until it passes, and then they learn to ignore it. A gate that cries wolf is worse than no gate.

So I split checks into two kinds.

Block the merge on things that are deterministic. The same input always gives the same answer. The most reliable of these read the built files directly, with no browser involved:

  • The HTML has real content. If a page is an empty shell until JavaScript runs, search engines may index nothing. I have shipped exactly that and only found out months later, when Search Console still showed zero indexed pages.
  • Every page has a title and a description, plus a canonical URL where your site uses them.
  • Every image has alt text and dimensions, and is not wildly larger than it needs to be.
  • Page weight stays under a budget: bytes of JavaScript, CSS, images and HTML. Bytes do not fluctuate.
  • No broken internal links, and the sitemap and robots.txt exist.

Some checks are deterministic only under controlled conditions. Automated accessibility checks such as contrast, labels and landmarks run against a rendered page in a real browser. On a static build, with the same browser version and no third-party scripts, they give the same answer every time, and I block on them. Add fonts or widgets loaded from other servers, animations, or content that depends on timing, and the same check can pass on one run and fail on the next. If that happens, the honest fix is to pin the conditions: block requests to third parties during the test, pin the browser version, or move that check into the “report” list until it is stable.

Report, but do not block, on things that are measured. Lighthouse performance scores, Largest Contentful Paint, layout shift and total blocking time in a lab run. They are worth seeing on every pull request, especially as a trend. They are just too noisy to stop a merge.

Pull request or git hook

On a team, the right place is a required check on the pull request. Nobody merges a change that breaks the budget, and the reviewer sees the numbers next to the code.

On a solo project, there is often no pull request at all, so a git hook does the job. A pre-push hook fits better than pre-commit for this: building the site and running a browser check takes longer than you want on every commit, but is fine once before code leaves your machine.

An example

I already work this way on one project. afrigov, the open-source design system I maintain, fails its build if the core CSS grows past 20 KB, checks every colour pair for contrast at build time, and runs an automated accessibility audit on every docs page, in every theme, on every pull request. Its companion tools were designed for the same job: afrigov-audit has a --fail-under flag to gate CI on an accessibility score, and afrigov-images exits with an error when an image is over its weight limit.

For a typical static site, Lighthouse CI covers a lot of the list with one config file. It builds a set of pages from your output folder and asserts on the results:

{
"ci": {
"collect": { "staticDistDir": "./dist" },
"assert": {
"assertions": {
"resource-summary:script:size": ["error", { "maxNumericValue": 150000 }],
"resource-summary:image:size": ["error", { "maxNumericValue": 500000 }],
"resource-summary:total:size": ["error", { "maxNumericValue": 1000000 }],
"document-title": "error",
"meta-description": "error",
"is-crawlable": "error",
"image-alt": "error",
"html-has-lang": "error",
"color-contrast": "error",
"categories:performance": ["warn", { "minScore": 0.9 }]
}
}
}
}

The byte budgets and the SEO and accessibility audits fail the check. The performance score only warns. That one line is the whole philosophy: block on what is certain, report on what is measured.

Lighthouse only tests the pages you list, though, and a blog can have dozens. So I packaged that config with two small checks that cover every page in the build: real text in the HTML, titles, descriptions, canonical URLs, alt text, image dimensions and weight, and internal links that resolve. It is open source as web-quality-gate, on npm and as a GitHub Action:

- run: npm ci && npm run build
- uses: omoyolab/[email protected]
with:
dist: dist

For a solo project, the same thing runs from a pre-push hook with npx web-quality-gate dist.

This blog runs it on every push. So does my portfolio, where the first run found nine screenshots that no page used anymore, about 4 MB deployed on every build, and a lightbox that downloaded half-megabyte PNGs when a compressed version was already on the page. Neither would ever have shown up in a PageSpeed score.

What a lab cannot tell you

A check in CI runs on a simulated device on a fast machine. Your real users are on real phones, on real networks, in places your CI runner has never been. A green check does not mean the site is fast for them.

That is fine. The point of the gate is not to prove the site is good. It is to make sure nothing got worse without anyone noticing. Real-user data, like the Core Web Vitals report in Search Console or your analytics, answers the other question.

Why it should be the default

The real cost of doing quality checks after you push is not the fixing. It is the context switching: coming back to a feature you thought was finished, on a site that is already live, sometimes days later when a client or a search console report points it out.

When the checks ship with the code, that changes. The question at merge time is no longer “did I remember to check?” It is “did the checks pass?” And the time you used to spend on cleanup after release can go into the things a lab cannot measure: how the site actually feels to the people using it.