This site refuses to let me commit a layout change until three browser harnesses pass. The slowest of them was a composition audit: it loads every route of the site, eleven at the time, at six viewport widths in two browser engines and looks for text drawn on top of other text, tap targets under 24×24, contrast below AA, and clipped or absurdly narrow text. A hundred and thirty-two page renders, and it took 184 seconds.
I complained about it out loud, which is how I ended up measuring it.
The number that made it obvious
/usr/bin/time reports three figures, and the interesting part is the gap
between them.
real 184.04
user 19.92
sys 6.44
Twenty-six seconds of processor time inside a hundred and eighty-four seconds of wall clock. The check spent 86% of its life doing nothing at all. Not computing, not rendering, not painting. Waiting.
I did the obvious thing first, and the obvious thing was wrong.
Parallelizing idle time
The audit was written as three nested loops: for each engine, for each viewport, for each route. Nothing in that ordering was load-bearing. The probe only reads the DOM, and findings get de-duplicated centrally, so route six cannot care whether route five has finished.
So I gave it workers. Four pages per viewport pulling routes off a shared queue, and both engines running at once instead of one after the other: they’re separate browser processes sharing nothing but a read-only static server.
real 112.47 ← was 184.04
user 22.83
sys 7.24
A third faster. I’d expected better than that from going four-wide in two engines simultaneously, and for a minute I assumed I’d wired the concurrency wrong.
I hadn’t. The problem is that parallel idling is still idling. If eight workers each spend most of their time waiting, running them together buys you the overlap between the waits and nothing else. Concurrency multiplies work. It does not multiply nothing.
What it was actually waiting for
One argument, in the navigation call, that I’d copied from the other harness without reading it closely:
await page.goto(url, { waitUntil: 'networkidle' });
networkidle doesn’t mean “the page is ready.” It means Playwright has seen
500 milliseconds of network silence. That’s a real signal for a page that
kicks off requests after load: an app that fetches its content, a widget that
appears late. This is a static site, and every byte a page needs is in the HTML,
the stylesheet, and one font file, all of them already in flight before the
document has finished parsing.
So every single navigation arrived, went quiet, and then the harness sat there for half a second confirming that nothing else was coming. A hundred and thirty-two times, that’s over a minute of the run spent waiting to be sure it had already finished.
The correct signal is load, which fires once stylesheets and images are in,
which is precisely what decides layout. Not domcontentloaded: that one doesn’t
wait for stylesheets, and I’ve already been caught by it once: an unstyled page
reported a finding that existed only because the CSS hadn’t arrived, in WebKit
and nowhere else.
There’s one thing load doesn’t cover and this audit can’t do without:
await page.goto(url, { waitUntil: 'load' });
await page.evaluate(() => document.fonts.ready);
Overlap, clipping, and narrow-measure findings are all measured in glyphs. A fallback face measures differently from Inter, so checking before the font swaps means checking a layout that no reader will ever see.
real 13.87 ← was 184.04
user 16.86
sys 5.31
The shape of the win
| Wall | Processor | Idle | |
|---|---|---|---|
| Original | 184.0s | 26.4s | 86% |
| Concurrent | 112.5s | 30.1s | 73% |
Concurrent, load |
13.9s | 22.2s | n/a |
Wall time, one scale. The shaded part of each bar is time the check spent waiting on nothing. The last run has no idle figure because its work was spread across cores, so processor time (22.2s) exceeds the wall clock.
Thirteen times faster, and the processor time went down. That’s the tell that nothing was optimized in any real sense: it’s the same hundred and thirty-two renders doing the same work. I stopped asking it to wait.
Concurrency was worth 1.6×. The one-line change to the wait condition was worth 8.1× on top of that. I’d have gotten most of the win by reading the argument I’d pasted, and none of the credit.
The part I nearly skipped
A check that got thirteen times faster and stopped finding things is worse than a slow one, because now it passes quickly and says nothing. Zero findings before and zero findings after proves only that the fast version agrees with the slow version about a clean page, and says nothing whatsoever about whether it can still see a dirty one.
So I broke the site on purpose, one defect at a time, and made sure each one still got caught. Contrast fired. Clipping fired. Tap targets fired. Overlap, the one this audit exists for, after text was stamped over text on a page here and shipped, did not.
That took a minute to understand, and it was my test that was broken rather than
the check. I’d made an element position: absolute to shove it onto its
neighbor, which takes it out of the flow and lands it somewhere else entirely
instead of on top of anything. position: relative with a negative offset keeps
the box and moves the paint, which is what an overlap actually is:
[chrome 834] / overlap: h1 "We build and backsoftware companies."
⨯ p.dek "Actium builds products in-house, co-foun" (15234px²)
That missing space is real output, not a typo here: the headline has a <br />
in it, and textContent doesn’t invent whitespace where a line break was.
Both engines, exact element pair, overlap area in square pixels. It works.
If I’d trusted “0 findings, and much faster” I’d have shipped a harness that was mostly a stopwatch, and I wouldn’t have found out until something ugly reached production. The check needs its own check. That’s the whole reason the gates on this site open with the unit tests that test the gates.
Where I’d copied it from
The sentence I keep coming back to is “copied from the other harness without reading it closely,” because there are two other harnesses and I had pasted the same argument into both. The screenshot matrix waits on 420 navigations. The behavioral suite reloads the page to prove the light edition survives a refresh.
So the same word, twice more:
| Before | After | |
|---|---|---|
| Composition audit | 184.0s | 12.5s |
| Screenshot matrix | 122s | 82.7s |
| Behavioral suite | 29.0s | 13.6s |
The behavioral suite is the one that surprised me. It only visits a dozen pages, so I’d assumed its time was the interactions themselves: pressing the Konami code, dropping the 404’s digits, hovering a card and waiting out the transition. Less than half of it was. The rest was the same half-second, over and over.
The matrix improved least, and for a good reason: it writes 420 full-page PNGs, and encoding them is real work. Its processor time is now higher than its wall clock, which is what a genuinely busy check looks like.
Five and a half minutes of gate became one and three quarters on the site as it stood that week. The total creeps back up as pages get added, which is the honest cost of a matrix that covers everything: the waiting is gone for good, the work grows with the site.
The honest summary is that I optimized nothing. The composition audit’s processor time went down across the whole exercise, from 26.4 seconds to 22.2, the same hundred and thirty-two renders, the same probe, the same work. What changed is that it stopped standing around between the parts of it, and I only found that because I got annoyed enough to time it instead of assuming I already knew where it went.
A day later, on four cores
Everything above happened on my laptop. The same checks also run on GitHub’s machines: a workflow rebuilds the site there every night and screenshots whatever published that day, because a scheduled post goes live with nobody at a keyboard. This post was a day old, and that workflow two days old, when an unrelated failure email had me reading CI logs at one in the morning, and I finally looked at its timings.
The screenshot matrix that runs in 83 seconds here took 324 seconds there. Same commit, same script, same four workers per engine.
The machine is the entire difference. A GitHub runner is a rented computer with four processor cores, and I was putting eight pages on it at once: four per engine, both engines together, the arrangement I’d just finished bragging about. My laptop has cores to spare for that. On four, eight busy pages mostly wait for a turn: Chrome needed 323 seconds and WebKit 285, side by side on the one box. So the workflow now rents two boxes, one engine each. Same script, nothing clever:
| check:visual | one runner | one per engine |
|---|---|---|
| chrome | 323s | 86s |
| webkit | 285s | 131s |
I wrote above that concurrency multiplies work. That sentence needed a footnote: while there are idle cores to put the work on. Past that point, workers stop multiplying anything and start splitting it.
Two more changes made the run cheaper rather than faster. The matrix writes a screenshot for every page at every width in both engines, 448 of them by the time I wrote this section and climbing with every page the site grows, and I defended that cost above as what a genuinely busy check looks like. On a runner it’s busywork. Every green screenshot is deleted unseen when the machine is returned. So in CI a page now gets photographed only at the moment it fails, and a clean run keeps none at all. Nothing changed here at my desk: the commit gate still demands the full set, because I’m the one who looks at them.
And most pushes stopped starting browsers at all. GitHub charges for these machines by the minute (my plan includes 3,000 a month, and the account’s other repositories were already using half of them), and the two-day-old workflow had burned 261 more, on pace for the cap by mid-August. The default at the cap is that everything simply stops, deploys and the nightly publisher included. The expensive part was commits that changed documentation. A markdown edit can’t break a browser, so a push that touches no page now skips both machines.
The commit that recorded these numbers in the repo’s work log changed one markdown file. For the first time since the workflow existed, no browser started.