Notebook · Note 001 · Web performance

Where a static site hid the last 18 points

Images were compressed, JavaScript was three files, and there was no framework. Lighthouse still gave 82. The remaining points were in three places, all named by the tool but not explained.

Serhat Belen

Founder, BLN Global · · 7 min read

Short answer

With the images and JavaScript already in good shape, the remaining points came down to three issues. First, the check meant to skip unnecessary work was reading the layout and forcing the browser to recalculate it. Second, the stylesheet and external fonts delayed the first paint by 600 ms. We inlined the CSS and moved the fonts to our server. Third, 6 of the accessibility audit’s 24 contrast warnings were false positives. A full crawl then uncovered 13 pages that sampling had missed.

This site has no framework. Pages are plain HTML generated by Python scripts. Styles use one CSS file. JavaScript uses three small files. Images are generated as WebP in three sizes. So all the obvious performance work was already done.

The mobile score was stuck at 82.

When the obvious stuff was done, the remaining points were not in one big mistake. They sat in three separate places, one piece each. Finding all three was less about reading the tool's warning and more about the warning's what you mean required understanding.

First place: the check written to skip the work

Lighthouse's "forced reflow" warning says JavaScript made the browser calculate page layout too early. The browser batches style changes and applies them in one pass, but if a measurement read slips in, it has to flush the batch at once.

The warning pointed to the source line, but the line had no visible measurement. It took three rounds.

  • In the first round getComputedStyle calls were removed. The score did not change.
  • On the second pass, the real culprit showed up: a check written if (window.scrollY > 0) to skip unnecessary work. The check itself is a layout read. The line written to skip work made you do the work you wanted to skip.
  • On the third pass, the function was deleted. The initial state was already in CSS. JavaScript did not need to rebuild it.

One remaining pattern also void element.offsetWidth was. A common, accepted trick that forces layout on purpose to restart a transition. In its place, two consecutive requestAnimationFrame was added. It does the same job without forcing the layout.

function restart(fn) { /* If the tab is in the background, rAF never runs and the demo stays blank. */ if (document.hidden) { fn(); return; } requestAnimationFrame(function () { requestAnimationFrame(fn); }); }
Restart the transition without forcing layout. A fallback is required for background tabs: rAF never runs there.

The lesson was this: a performance warning often does not say "you do too much work", it says "you do the work at the wrong time".

Second place: the 600 ms before first paint

The second warning covered render-blocking resources. When the page opened, the browser drew nothing until it downloaded and processed the stylesheet, then the fonts. The measured delay was 600 milliseconds.

We inlined the style sheet

A separate CSS file comes from cache on the second visit. That helps. But it adds a round trip on the first visit, which is what visitors from search experience. We minify CSS at publish time and embed it in the page. We measured it: after Brotli compression, the embedded version was smaller than a separate file + second request.

We moved the fonts to our own server

The setup from Google Fonts connected to two separate origins: one for style fonts.googleapis.com, for files fonts.gstatic.com. Each new origin needs DNS resolution, a TLS handshake and a new connection. Removing that produced this measured change:

250 KB165 KB

Font downloaded by a Turkish page. 3 files from one origin instead of 6 files and 2 external origins.

How it was measured Google Fonts CSS was fetched with a real Chrome identity, unicode-range fields to isolate the subsets a Turkish page would actually download (latin + latin-ext), then measured every file. The counterpart in this repository assets/fonts real size of its files.

How the fonts shrank and where the cost landed while opening to ten languages became a separate note: ten languages, three font files.

Third place: where the tool gets it wrong

The accessibility audit gave 24 color contrast warnings. If we had accepted and fixed them, half the design would have broken. 6 warnings were not real.

Reason content-visibility:auto was enabled. This feature speeds up the first paint by delaying off-screen rendering. Exactly what we wanted. But the deferred section’s computed color never reached the audit tool, so it reported white text on a white background. Nothing like that appeared on screen.

Real ratios were measured one by one in the browser. 18 warnings were real and fixed. 6 were blind spots in the tool, content-visibility removed only from the two problem sections and kept elsewhere.

An audit tool warning is not proof of a bug to fix. A warning is a claim. You check it, then fix it. This lesson came up two more times on this site. One was in the language review note is described.

Then there is the wound we caused

To prevent layout shifts, all images get width and height attribute was added. Good move. The browser can reserve space before downloading the image. But the demo boxes broke. Some images rendered 58 pixels wide and 1500 pixels tall.

Reason is this: in HTML, width and height attributes are presentational hints, while those in CSS height fill in the value. In the design, those boxes only have aspect-ratio was defined, height was empty. The attribute landed there and crushed the ratio. One-line fix:

img{max-width:100%;height:auto;display:block}
Attributes stay in place and keep preventing layout shift. CSS takes back the height.

And where the crawl actually pays off

When the home page hit 100, it looked done. It wasn't. What we measured was a sample. The site's all 150 pages pages were run through Lighthouse one by one. Result: 137 pages scored 100 in all four categories, not 13 pages. Accessibility, Best Practices and SEO were 100 on every page. Only performance moved.

Eleven of the thirteen pages were in the same language: Arabic. The lowest score was 83.

/ar/safecoast/ 83 /ar/laya/ 84 /ar/localmind/ 84 /ar/adforge/ 85 /ar/garanti/ 85 /ar/gtin/ 86 /ar/visionguard/ 86 /ar/ 89 /ar/growthos/ 97 /ar/dream/ 99 /ar/twin/ 99
Arabic pages from the full crawl. The remaining two outliers (/pl/modelmesh/ and /ru/terms.html, both 98) are within the normal run-to-run range.

Lighthouse gave the exact reason: Web font loaded → ar.woff2. The Arabic font arrived late and laid out the page again. Its fallback had very different metrics from Noto Sans Arabic, so the shift was substantial. The fix was the same choice we had already made for Martian Mono: swap not optional.

0,2870

Layout shift (CLS) on Arabic pages

How it was measured After the change, we measured all 15 Arabic pages again. CLS was zero on every page. Performance scores rose from 83-89 to 98-100, with 9 of the 15 pages scoring a full 100. We ran the Lighthouse tests on the live site.

The main lesson here isn’t about performance. If you sample a few pages and say the site scores 100, you’re making claims about pages you didn’t measure. Fifteen pages sat at 83 for months because we never checked them.

The result’s limit

The score is a lab measurement. It assumes a fixed device and network model. A real visitor’s phone and connection may perform worse. Run the same page several times and the score moves between 98 and 100. A score of 100 does not mean the page is fast for everyone. It means the measurable blockers have been removed. Once enough real-user data comes in, Chrome’s field data will be the metric to watch.

These three fixes share one thing. None involved doing more. Each removed work done at the wrong time or work that should never happen.

FAQ

What does Lighthouse mean by forced reflow?

JavaScript forcing the browser to calculate queued style changes too early. The browser batches changes and applies them at once. If a measurement read gets in the middle, it has to flush the queue right away. offsetWidth, getBoundingClientRect, scrollY and getComputedStyle every read triggers it.

Is inlining CSS always the right choice?

No. A separate file comes from cache on the second visit, and that is an advantage. Inlining speeds up the first visit and slows repeat visits. Decide by site: if most visitors arrive from search results, inlining pays off. We measured it here. It did.

Should you stop using content-visibility?

No. It genuinely speeds up the first paint by delaying the rendering of off-screen sections. The feature isn't the problem. The audit tool can't read the color of a deferred section. You only need to remove it from sections that trigger false reports.

Does a score of 100 really mean the site is fast?

It is not. It is a lab measurement that assumes one device and one network. Measure the same page back to back and it moves between 98 and 100. The real version shows up in Chrome field data, and that takes time to fill.

Why measure every page instead of sampling?

In our case, 13 of 150 pages never appeared in the sample, and 11 were in the same language. The home page scored 100, while Arabic pages sat at 83. You cannot make a claim about a page you did not measure.

Sources

Related work