I ran PageSpeed Insights on my own site this evening. On mobile.

The first run, at 11:16 AM IST:

CategoryScore
Performance99
Accessibility91
Best Practices92
SEO100

I shipped a fix for one of the findings, ran it again twenty-five minutes later, and got:

CategoryFirstSecond
Performance99100
Accessibility9191
Best Practices92100
SEO100100

Three out of four are now 100. Most of that is honest. Some of it is not. The interesting part is which.

The Best Practices win was real

The first scan flagged a console error on every page load:

Loading the script 'https://static.cloudflareinsights.com/beacon.min.js/...'
violates the following Content Security Policy directive: "script-src 'self'".

Cloudflare auto-injects a Web Analytics beacon. My CSP was strict enough to block it. The fix was two short additions to the Content-Security-Policy header:

script-src  'self' https://static.cloudflareinsights.com;
connect-src 'self' https://cloudflareinsights.com;

Shipped in a small PR. Re-deployed. Re-scanned. The beacon now loads, the console is clean, the Web Analytics dashboard started receiving pageviews, and Best Practices went from 92 to 100. That score jump corresponds to a thing I actually fixed.

Best Practices still has three “additional manual checks” listed: ensure CSP is effective against XSS, ensure proper origin isolation with COOP, and mitigate DOM-based XSS with Trusted Types. None of those block the score. They are recommendations. COOP is one header line and worth doing. Trusted Types is a much bigger commitment that I do not need on a static blog.

The Performance win was mostly noise

Here is the awkward part.

The Performance metrics from the two runs:

MetricFirstSecond
First Contentful Paint1.0 s0.8 s
Largest Contentful Paint1.5 s1.5 s
Total Blocking Time30 ms0 ms
Cumulative Layout Shift00
Speed Index3.7 s0.8 s

Speed Index moved from 3.7 s to 0.8 s. That is the single biggest swing, and it is what pushed the rolled-up score over the line from 99 to 100.

I did not touch the rendering path between the two runs. I did not preload anything, defer anything, inline anything, or remove anything. The render-blocking diagnostic flagged the same two files in both runs:

  • /js/theme-init.js (1.3 KiB, 470 ms on the wire in the second run)
  • /_astro/SiteLayout.UCeTl8Hf.css (2.9 KiB, 160 ms)

Estimated savings: 320 ms in the first run, 400 ms in the second. The forced reflow at home.js:37 is still flagged. The “no preconnects” finding is still flagged.

What changed is that Lighthouse runs in a lab environment, on synthetic throttling, and lab metrics have run-to-run variance. The first run was unlucky. The second run was lucky. The underlying site is the same.

I think the honest reading of “Performance went from 99 to 100” is: the score moved one point, the work to actually be faster is still on my list. The diagnostics tell you that even when the headline number does not.

There is a small genuine improvement under the noise. The CSP fix means the browser no longer logs an error and the Insights beacon now loads cleanly, which probably saved a few milliseconds of console-handler work. But that is not where the 99 came from.

The Accessibility 91 was honest both times

This is the part I cannot wave away with variance.

The first scan flagged contrast issues on the active nav link, the “Get in touch” button, the hero word “AI”, and the “Read” link on each blog card. It also flagged tap-target size on the theme toggle () and the nav links.

I did not fix any of that. The second scan flagged exactly the same things, and the score stayed at 91.

This is the audit doing its job. The Performance score moved because of variance in a lab environment. The Accessibility score did not move because nothing changed. For a sighted developer testing on a laptop, these issues look like nothing. For a user with low vision or limited motor control on a phone, they are real.

Bumping accent and muted colours one step in theme.css would fix contrast. Bumping padding on .nav-list a and .theme-toggle for mobile breakpoints would fix tap targets. Both are small changes. I parked them because I was writing this post first.

SEO 100 stayed at 100

Pleasantly boring. The semantic-HTML pass I did a week ago helped here.

What I am taking from this

A few things, in order of size.

A Lighthouse score is a snapshot, not a state. The Performance score moved a point because the lab run was less noisy this time, not because the site got faster. If I want the site to actually be faster, I have to fix the render-blocking files and the forced reflow, which the diagnostics keep telling me about regardless of the headline number.

Some score jumps are deterministic and some are not. The Best Practices jump from 92 to 100 was deterministic. I changed a header, the violation cleared, the score moved. The Performance jump from 99 to 100 was not. The fix list did not get shorter between runs. Watch the diagnostics, not the headline.

The category that did not move is the one I should be most embarrassed about. Accessibility stayed at 91 because contrast and tap targets are still wrong, and they will stay wrong until I do the work. Performance and Best Practices got attention because they are easier to demo and show off. Accessibility is harder to demo and easier to ignore, and that is exactly why a sighted developer’s site usually fails it.

What I shipped that evening

After writing the section above, I worked through the rest of the list. Two more PRs.

The first PR bundled four small things:

  • The Cross-Origin-Opener-Policy header. One line in _headers.
  • The light-mode --accent token, darkened from #da4b2f to #b73a1a. That moves the contrast against white from about 4.07:1 to about 5.6:1, which clears WCAG AA for the active nav link, the Get-in-touch button, the hero “AI” word, and the blog Read links. Dark-mode --accent already passed and was left alone.
  • Mobile tap targets on .nav-list a, .theme-toggle, and .contact-toggle: padding: 12px 14px and min-height: 44px in the mobile media query, so each target meets the 24x24 floor with a comfortable margin. The theme toggle also got min-width: 44px and inline-flex centering, since the character on its own had been about 16 pixels wide.
  • The forced reflow at home.js:37: wrapped the mobile hero scroll handler in requestAnimationFrame so consecutive scroll events coalesce into one layout pass per frame instead of one per event.

The second PR was the awkward one. Inlining theme-init.js saves the render-blocking request, but my CSP did not allow 'unsafe-inline' on script-src, so a naive inline tag would have been blocked. The fix was to pin the inline body with a sha256 hash:

script-src 'self' 'sha256-...=' https://static.cloudflareinsights.com;

The hash matches the exact bytes of the inline script as it appears in the built HTML. If the script ever changes, the hash has to be regenerated. That is a small operational tax, paid in exchange for keeping the rest of the policy strict.

While I was in there, I added one more behaviour: the inline script now also honors prefers-color-scheme: dark for first-time visitors. Order of precedence is: a saved choice in localStorage.theme wins, otherwise the system setting decides, otherwise default light. So a user on dark mode at the OS level now gets dark mode on first load instead of a flash of light before they hit the toggle.

The list, in its final state:

  • Allow Cloudflare Insights in CSP
  • Inline theme-init.js to actually drop the render-blocking request
  • Refactor home.js carousel logic to avoid forced reflow + long tasks
  • Bump accent / muted contrast in theme.css for the failing elements
  • Bigger tap targets on mobile nav and the theme toggle
  • Add Cross-Origin-Opener-Policy: same-origin header

I have not run a third scan yet. I am genuinely curious what it will say. The performance changes (inline script + rAF batching) are deterministic enough that I expect the render-blocking warning to drop and the long-task count to go to zero. The accessibility changes are the ones I most want to see move the headline number, and unlike the Performance jump from the first to second scan, that one will be earned.

If the next scan does not move Accessibility, I will know exactly why: there is a contrast or tap-target case I missed, and the audit will tell me which element. That is the audit doing its job, again.