Writing/AI/Agentic performance test harness · part 1

Debugging a slow Zola blog with an agentic performance test harness

flag 030Oct 15, 2026ai#ai#agents#performance#cloudflare#engineering16 min read
Contents11

This is Part 1 of a series on building an agentic performance test harness.


I noticed a couple of odd things while using my own blog. On my phone, the header and footer sometimes appeared late, as if the page was waiting for something on the backend. On my laptop, hovering over the “All writing” link sometimes left the cursor as an arrow before it changed to a pointer.

Neither made sense for a static site. Zola generates plain HTML at build time and Cloudflare Pages serves those files directly, with no application server or database behind them. I wanted to find out what was happening, so I pointed my agentic performance test harness at it.

The harness

I’ve been building this harness for a while, packaging different testing and debugging techniques into reusable skills that coding agents can orchestrate. The agent runs each technique as a script that drives a real Chrome browser:

TechniqueWhat it does
Visual Time MachineReplays the load as a visitor, a frame every 100 ms
Network Waterfall MappingMaps every download on a timeline
Tap-in-Flight TestingLink loading propagation
Main-Thread Black BoxTraces blocking tasks to the source
Build-to-Edge DiffDeployed HTML against what visitors receive
Twin-Origin BenchmarkingIsolates the cost of custom domain settings
Real-User RadarChecking lab results against real visitors’ reports
Pixel-Parity GatePixel-by-pixel visual diff

For each symptom, the agent finds the cause, fixes it, measures again and checks that nothing else changed.

The first load

To reproduce the problem, the harness loaded the live site on an emulated phone with Lighthouse-style throttling: a CPU four times slower than my laptop and a 1.6 Mbit/s connection with 150 ms round trips. Most tests used Running a coding model on RunPod, one of the longer posts on the site.

The page took about four seconds to reach DOMContentLoaded, the point where the browser has parsed the HTML and run the scripts that hold it up. Everything finished loading after about five seconds.

EventTime
First paint0.68 s
Page ready (DOMContentLoaded)4.04 s
Fully loaded5.10 s

The browser drew the text in system fonts at 0.68 s. It then fetched the web fonts, loaded Mermaid though the post has no diagrams and downloaded four diagram images further down the page. A script that Cloudflare injects also blocked the main thread for 297 ms. Fixing what the harness found in my theme and in Zola brought page ready on this post down to 0.86 s and cut its downloads from 899 KB to 227 KB. Two issues on Cloudflare’s side are still open.

One phone load of a post on the live site, before any changeText appears at 0.68 s in system fonts; the web fonts finish at 3.70 s; page ready at 4.04 s after the Mermaid library; the diagram images download until 5.08 s; the Cloudflare bot check then blocks the page for 297 ms.0 s1 s2 s3 s4 s5 sfirst paint 0.68 spage ready 4.04 sfully loaded 5.10 sOn screenblank until 0.68 stext in system fonts from 0.68 s to 3.70 stext in system fontsfinal fonts1web fonts, from Google173 KB downloadedweb fonts, from Google: 0.51 s to 3.63 s, 173 KB2Mermaid library81 KB downloadedMermaid library: 0.71 s to 3.89 s, 81 KB3four diagram images625 KB downloadedfour diagram images: 0.22 s to 5.08 s, 625 KB4Cloudflare bot check11 KB downloadedCloudflare bot check: 4.03 s to 5.20 s, 11 KBmain thread blocked for 297 ms from 4.57 spage frozen 297 msOne phone load of a post on the live site, before any changeText appears at 0.68 s in system fonts; the web fonts finish at 3.70 s; page ready at 4.04 s after the Mermaid library; the diagram images download until 5.08 s; the Cloudflare bot check then blocks the page for 297 ms.0 s1 s2 s3 s4 s5 sfirst paint 0.68 spage ready 4.04 sfully loaded 5.10 sblank until 0.68 stext in system fonts from 0.68 s to 3.70 stext in system fontsfinal fonts1web fonts, from Google 173 KBweb fonts, from Google: 0.51 s to 3.63 s, 173 KB2Mermaid library 81 KBMermaid library: 0.71 s to 3.89 s, 81 KB3four diagram images 625 KBfour diagram images: 0.22 s to 5.08 s, 625 KB4Cloudflare bot check 11 KBCloudflare bot check: 4.03 s to 5.20 s, 11 KBmain thread blocked for 297 ms from 4.57 spage frozen 297 ms
The RunPod post on runtimenotes.com before any of the changes in this post, on an emulated phone on slow 4G (150 ms round trips at 1.6 Mbit/s, CPU slowed four times). Bars run from the first request to the last byte. One of three loads, which agreed to within 80 ms on every mark.
The numbers behind this chart
PartWhatFromToSize
On screenblank0.00 s0.68 s
On screentext in system fonts0.68 s3.70 s
1 web fonts, from Googledownloading0.51 s3.63 s173 KB
2 Mermaid librarydownloading0.71 s3.89 s81 KB
3 four diagram imagesdownloading0.22 s5.08 s625 KB
4 Cloudflare bot checkdownloading4.03 s5.20 s11 KB
4 Cloudflare bot checkmain thread blocked4.57 s4.87 s

Visual Time Machine: Google Fonts draws text twice

The filmstrip showed the browser drawing the page in system fonts first. When Google Fonts finished loading, the text changed shape: the headline rewrapped and the logo appeared last. On the home page the body font arrived 0.9 s after first paint and the logo font later still. That logo font was 73 KB for a wordmark with ten different letters.

My theme loaded Google Fonts the usual way. The page requests a stylesheet from fonts.googleapis.com, which points to the font files on fonts.gstatic.com. The browser can’t request a font until that stylesheet arrives. The stylesheet also sets font-display: swap, which shows text in a system font straight away and swaps in the web font when it lands.

Hosting and preloading the fonts

I moved the font files into the site and preloaded the faces used at the top of the page. A preload starts the download before the browser finds the font in the stylesheet:

<link rel="preload" href="/fonts/caveat-wordmark.woff2"
      as="font" type="font/woff2" crossorigin>

For the logo, Google Fonts’ text parameter returns a font with only the characters you ask for. For “Runtime Notes” that cut the Caveat font from 73 KB to 6 KB:

https://fonts.googleapis.com/css2?family=Caveat:wght@700&text=Runtime%20Notes

Self-hosting also changed the caching. Google lets browsers cache its font files for a year, while Cloudflare gave my static files four hours. Cloudflare Pages reads a _headers file, so I added a rule for the fonts:

/fonts/*
  Cache-Control: public, max-age=31536000, immutable

If a font ever changes, a new file name keeps the year-long cache safe.

What changed

This is how long each page waited for its web fonts after first paint, on slow and regular 4G:

Time with stand-in fonts on screen, before and afterFor each page and network, a line from load start to first paint, then an orange bar for as long as system fonts stand in for the real ones.0 s1 s2 s3 s4 sfirst paintstand-in fonts on screenfonts settledHome page, slow 4Gbeforeblank screen until first paint at 0.54 sbefore: stand-in fonts on screen from 0.54 s to 1.79 sfirst paint 0.54 sfonts settled 1.79 s1.25 safterblank screen until first paint at 0.74 safter: stand-in fonts on screen from 0.74 s to 0.84 sfirst paint 0.74 sfonts settled 0.84 s0.09 sA post, slow 4Gbeforeblank screen until first paint at 0.64 sbefore: stand-in fonts on screen from 0.64 s to 3.63 sfirst paint 0.64 sfonts settled 3.63 s2.99 safterblank screen until first paint at 0.80 safter: stand-in fonts on screen from 0.80 s to 1.25 sfirst paint 0.80 sfonts settled 1.25 s0.46 sHome page, 4Gbeforeblank screen until first paint at 0.29 sbefore: stand-in fonts on screen from 0.29 s to 0.55 sfirst paint 0.29 sfonts settled 0.55 s0.26 safterblank screen until first paint at 0.33 sfirst paint 0.33 sfonts settled 0.33 sready at first paintA post, 4Gbeforeblank screen until first paint at 0.38 sbefore: stand-in fonts on screen from 0.38 s to 0.91 sfirst paint 0.38 sfonts settled 0.91 s0.54 safterblank screen until first paint at 0.50 safter: stand-in fonts on screen from 0.50 s to 0.57 sfirst paint 0.50 sfonts settled 0.57 s0.08 s
Median of three loads each, emulated phone (390 px wide, CPU slowed 4×). Slow 4G is 150 ms round trips at 1.6 Mbit/s; 4G is 40 ms at 9 Mbit/s. “Fonts settled” is when the browser reports the last web font on the page loaded. On posts that is the italic face, which is not preloaded.
The numbers behind this chart
PageFirst paintFonts settledStand-in fonts
Home page, slow 4Gbefore0.54 s1.79 s1.25 s
Home page, slow 4Gafter0.74 s0.84 s0.09 s
A post, slow 4Gbefore0.64 s3.63 s2.99 s
A post, slow 4Gafter0.80 s1.25 s0.46 s
Home page, 4Gbefore0.29 s0.55 s0.26 s
Home page, 4Gafter0.33 s0.32 s0.00 s
A post, 4Gbefore0.38 s0.91 s0.54 s
A post, 4Gafter0.50 s0.57 s0.08 s

On the RunPod post the fonts settled about 2.4 s sooner. On the home page, the system fonts used to appear at 0.7 s, the text reflowed at 1.6 s and the logo arrived at 1.9 s. Now the page stays blank until about 1.0 s, then appears with its final fonts and doesn’t reflow:

Filmstrip of the top of the home page. Before: system fonts at 0.7 s, a reflow at 1.6 s and the logo at 1.9 s. After: blank until 1.0 s, then the final page with no further change.
Top of the home page, emulated phone on slow 4G. Before is the old code, after is the new one.

The cost is a slightly later first paint: 0.1 to 0.2 s on slow 4G and 40 to 120 ms on regular 4G. Preloading isn’t always worth it either. The new design’s home page is drawn on a canvas. Preloading its fonts there moved first paint from 0.62 s to 0.85 s without improving what appeared, so that page skips the preload.

Network Waterfall Mapping: Mermaid loads on every page

The network waterfall showed Mermaid, the library that turns text into diagrams, loading on every page. Zola has no built-in Mermaid support, so my theme imported it with a module script whether the page had a diagram or not. That cost an 81 KB download and 335 KB of JavaScript to parse.

Module scripts also delay DOMContentLoaded until they finish. On the RunPod post, which has no diagram, Mermaid was still downloading at 3.9 s and held up most of the four-second wait.

Loading Mermaid only on pages with a diagram

A first version looked for diagrams in the browser and used a dynamic import() only when it found one. That alone brought page ready on the RunPod post from about 4 s to 0.8 s. Later I moved the check into the build, where a Zola template can see the rendered post. Here it is with the rendering code left out:

{% if page.content is containing("language-mermaid") %}
<script type="module">
  import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
  // Turn each pre code.language-mermaid block into a diagram.
</script>
{% endif %}

The numbers come from the browser-side check. The build-time check also saves sending that small script.

With the fonts and Mermaid fixed, the waterfall pointed at the images. The RunPod post’s four diagrams total 625 KB. All four started downloading with the page, though none was on the first screen, so they competed with the fonts and stylesheet for a 1.6 Mbit/s connection.

Lazy loading should help: loading="lazy" tells the browser to wait until an image is near the screen. Zola can add it to every Markdown image:

lazy_async_image = true

Before Zola 0.23, though, lazy_async_image could blank the id of every heading after the first image, which breaks table-of-contents links and shared #section links. It’s Zola issue #2679, reported on 0.19.2 and still present in 0.21 and 0.22. Cloudflare Pages builds with whichever Zola version your build command installs, so check yours.

Zola’s Markdown images also have no width or height, so the browser doesn’t know how much room an image needs until it arrives. When it does, everything below it moves. To measure that, the harness loaded the RunPod post on slow 4G, waited two seconds and tapped the contents link for section 6. The heading should land just below the 46 px section bar. Without sizes it ended up 375 px too low, because the browser scrolled to it before the images above it arrived and pushed it down.

Where a heading lands after tapping its contents linkThree phone screens. With lazy images that have sizes, the heading lands on the dashed line under the section bar. With lazy images and no sizes it ends 375 px lower, below the end of the previous section. Without lazy loading and no sizes it ends 32 px higher, under the bar.Lazy, with sizes: heading ends 60 px from the topLazy, with sizeslands where it shouldLazy, no sizes: heading ends 435 px from the topend of #5Lazy, no sizes375 px too lowNot lazy, no sizes: heading ends 28 px from the topNot lazy, no sizes32 px too high, under the barwhere the heading should landsection barthe heading
The RunPod post on an emulated phone on slow 4G. Two seconds into the load I tap the contents link for section 6 and wait for the scroll to finish. The dashed line is where the page asks the heading to land, just under the 46 px section bar. Each result repeated in three runs.
The numbers behind this chart
ImagesHeading ends atOff byLayout shift after the tap
Lazy, with sizes60 px+0 px0.001
Lazy, no sizes435 px+375 px0.112
Not lazy, no sizes28 px-32 px0.002

Adding image sizes at build time

Zola’s get_image_metadata reads an image’s dimensions during the build. A macro in Tera, Zola’s template language, uses it to add width, height, loading="lazy" and decoding="async" to every image tag in the rendered post. A simplified version:

{%- set parts = html | split(pat='<img src="') -%}
{%- set_global out = parts | first -%}
{%- for part in parts | slice(start=1) -%}
  {%- set src = part | split(pat='"') | first -%}
  {%- set meta = get_image_metadata(path=src | trim_start_matches(pat="/"), allow_missing=true) -%}
  {%- if meta -%}
    {%- set_global out = out ~ '<img width="' ~ meta.width ~ '" height="' ~ meta.height
        ~ '" loading="lazy" decoding="async" src="' ~ part -%}
  {%- else -%}
    {%- set_global out = out ~ '<img src="' ~ part -%}
  {%- endif -%}
{%- endfor -%}
{{ out | safe }}

The full version also skips images that already have a width. With sizes set, section 6 landed in place in all three runs. With the font and Mermaid changes already in, lazy loading cut downloads in the first eight seconds from 755 KB to 227 KB and the fully loaded time from 4.18 s to 1.57 s.

After the first three changes

Page ready moved from 3.97 s to 0.80 s. The fonts now load alongside the stylesheet, Mermaid is gone from posts without diagrams and three of the four images wait until the reader scrolls near them:

Network requests while loading a post on slow 4G, before and after0 s1 s2 s3 s4 s5 sfontsmermaiddiagram imageseverything elseBeforeHTML pageHTML page: 0.00 s to 0.21 s, 11 KBstyle.cssstyle.css: 0.18 s to 0.44 s, 5 KBGoogle Fonts CSSGoogle Fonts CSS: 0.19 s to 0.42 s, 1 KBlogo fontlogo font: 0.46 s to 3.59 s, 73 KB73 KBbody fontbody font: 0.46 s to 2.38 s, 34 KB34 KBmono fontmono font: 0.46 s to 2.24 s, 31 KB31 KBitalic fontitalic font: 0.46 s to 2.39 s, 35 KB35 KBmermaidmermaid: 0.66 s to 0.91 s, 81 KBmermaid: 0.91 s to 3.83 s, 81 KB81 KBdiagram 1diagram 1: 0.19 s to 3.50 s, 93 KB93 KBdiagram 2diagram 2: 0.19 s to 4.57 s, 157 KB157 KBdiagram 3diagram 3: 0.24 s to 4.71 s, 166 KB166 KBdiagram 4diagram 4: 0.24 s to 4.93 s, 207 KB207 KBanalyticsanalytics: 0.19 s to 0.53 s, 3 KBfirst paint 0.64 spage ready 3.97 sAfterHTML pageHTML page: 0.00 s to 0.23 s, 15 KBstyle.cssstyle.css: 0.19 s to 0.54 s, 8 KBGoogle Fonts CSSnot requestedlogo fontlogo font: 0.19 s to 0.49 s, 6 KB6 KBbody fontbody font: 0.19 s to 0.80 s, 34 KB34 KBmono fontmono font: 0.19 s to 0.78 s, 31 KB31 KBitalic fontitalic font: 0.58 s to 1.21 s, 35 KB35 KBmermaidnot requesteddiagram 1diagram 1: 0.78 s to 1.55 s, 93 KB93 KBdiagram 2waits until you scroll near itdiagram 3waits until you scroll near itdiagram 4waits until you scroll near itanalyticsanalytics: 0.19 s to 0.52 s, 3 KBfirst paint and page ready 0.80 s
One load of the RunPod post on emulated slow 4G, before and after. Bars run from request to last byte; sizes are what crossed the network. “Page ready” is DOMContentLoaded. After the change the fonts start with the stylesheet, mermaid is not requested and three of the four diagram images wait until the reader scrolls near them.
The numbers behind this chart
LoadRequestStartEndSize
BeforeHTML page0.00 s0.21 s11 KB
Beforestyle.css0.18 s0.44 s5 KB
BeforeGoogle Fonts CSS0.19 s0.42 s1 KB
Beforelogo font0.46 s3.59 s73 KB
Beforebody font0.46 s2.38 s34 KB
Beforemono font0.46 s2.24 s31 KB
Beforeitalic font0.46 s2.39 s35 KB
Beforemermaid0.66 s3.83 s81 KB
Beforediagram 10.19 s3.50 s93 KB
Beforediagram 20.19 s4.57 s157 KB
Beforediagram 30.24 s4.71 s166 KB
Beforediagram 40.24 s4.93 s207 KB
Beforeanalytics0.19 s0.53 s3 KB
AfterHTML page0.00 s0.23 s15 KB
Afterstyle.css0.19 s0.54 s8 KB
AfterGoogle Fonts CSSnot requested
Afterlogo font0.19 s0.49 s6 KB
Afterbody font0.19 s0.80 s34 KB
Aftermono font0.19 s0.78 s31 KB
Afteritalic font0.58 s1.21 s35 KB
Aftermermaidnot requested
Afterdiagram 10.78 s1.55 s93 KB
Afterdiagram 2waits until you scroll near it
Afterdiagram 3waits until you scroll near it
Afterdiagram 4waits until you scroll near it
Afteranalytics0.19 s0.52 s3 KB

Main-Thread Black Box: a freeze after the page is ready

About half a second after DOMContentLoaded, the phone’s main thread still froze for about 300 ms. The main thread runs JavaScript and also handles taps, clicks and cursor updates, so they all wait during a long task. That was the cursor I’d seen stick on my laptop, where the same task took 59 to 108 ms.

The black box records every task longer than 50 ms and the script or frame that ran it. The 297 ms task belonged to a hidden iframe that nothing in my code creates:

Main-thread long tasks on the live site0 s1 s2 s3 s4 s5 sthe page's own start-upmermaid starting upCloudflare bot checkPhone, a postslow 4G, CPU 4× slowerthe page's own start-up: 171 ms from 0.49 s171 msmermaid starting up: 104 ms from 3.90 s104 msCloudflare bot check: 297 ms from 4.57 s297 msfirst paintpage readyDesktop, Writing4G, full-speed CPUCloudflare bot check: 64 ms from 0.69 s64 msfirst paintpage readytaps and hover wait here
Long tasks (over 50 ms) on the main thread of the live site, before any fix. The browser cannot react to a tap or update the cursor during one. The Cloudflare task is attributed to the hidden iframe the injected script creates; it starts as soon as that script arrives, which is requested at “page ready”. Phone numbers are from one of three loads that agreed within 20 ms.
The numbers behind this chart
LoadTaskStartsBlocks for
Phone, a postthe page's own start-up0.49 s171 ms
Phone, a postmermaid starting up3.90 s104 ms
Phone, a postCloudflare bot check4.57 s297 ms
Desktop, WritingCloudflare bot check0.69 s64 ms

Build-to-Edge Diff: Cloudflare injects a bot check

The Build-to-Edge Diff compares the HTML Zola builds with what visitors receive. You can do a rough version with curl:

curl -s https://runtimenotes.com/ | tail -c 600

The last few hundred bytes held an inline script that isn’t in Zola’s output. Cloudflare adds it while serving the page, along with an analytics script and a Speculation-Rules header. It also removes the ETag.

Diagram: the HTML Zola writes passes through the Cloudflare edge, which adds a Speculation-Rules header, an analytics script and a bot check and drops the ETag. The bot check's hidden iframe loads main.js, which froze a throttled phone for 297 ms.

The script is Cloudflare’s JavaScript Detections, which checks whether a browser looks automated. It comes with Bot Fight Mode, a free setting that challenges traffic matching known bot patterns. After DOMContentLoaded it adds a hidden iframe that loads a 9.5 KB main.js. The iframe shares the page’s origin, so the script runs on the page’s main thread.

Cloudflare’s documentation says the script is deferred and uses a separate thread where available. In my runs it ran on the main thread. A Cloudflare Community thread reports Total Blocking Time rising from 80 ms to 1,530 ms after enabling Bot Fight Mode. A GitHub issue from September 2026 traces most of another site’s blocking time to the same script.

Turning off Bot Fight Mode

On the free plan, the documentation says JavaScript Detections can’t be turned off separately from Bot Fight Mode, which also does the bot blocking. This blog has no forms, logins or server code, so I turned Bot Fight Mode off for the whole domain. Cloudflare kept injecting the script. It was still there on 15 October 2026. On a paid plan, Super Bot Fight Mode can turn it off. On the free plan I haven’t found a way.

Speed Brain

The Speculation-Rules header comes from Speed Brain, which tells Chrome to prefetch a page when the visitor presses a link. A normal click holds the button down for about 120 ms, which is the head start the prefetch gets. The next page’s first byte arrived after 125 ms with Speed Brain and 126 ms without. Hovering doesn’t start a prefetch, so it doesn’t waste requests either. I left it on.

Twin-Origin Benchmarking: custom domain drops the ETag

Every Cloudflare Pages project also has a pages.dev address that serves the same files without the custom domain’s settings. The harness compared the RunPod post on both.

An ETag is a version tag on a response. A returning browser sends it back. If the page hasn’t changed, the server answers 304 Not Modified without the body. On pages.dev the HTML had an ETag and a repeat request got a 304. On runtimenotes.com there was no ETag or Last-Modified header, so every repeat visit downloaded the HTML again. You can compare the two addresses for your own project:

curl -sI https://<project>.pages.dev/ | grep -i etag
curl -sI https://<your-domain>/ | grep -i etag

The custom domain was slower too. The harness timed 30 requests to each address, plus style.css on the custom domain, which was already cached at the edge:

Time from sending the request to the first byte, per request0 ms25 ms50 ms75 ms100 ms125 ms150 msThe post on its pages.dev addresshas an ETag, a repeat visit gets a 30467 ms53 ms40 ms98 ms104 ms42 ms42 ms80 ms42 ms65 ms43 ms76 ms42 ms50 ms56 ms39 ms65 ms65 ms37 ms37 ms33 ms97 ms42 ms39 ms32 ms42 ms76 ms42 ms53 ms41 msmedian 43 msThe post on runtimenotes.comno ETag, a repeat visit downloads it again69 ms131 ms107 ms119 ms116 ms103 ms127 ms97 ms129 ms67 ms87 ms64 ms72 ms81 ms106 ms135 ms78 ms78 ms107 ms85 ms85 ms132 ms115 ms70 ms87 ms76 ms80 ms66 ms56 ms75 msmedian 86 msstyle.css on runtimenotes.comcached at the edge, for comparison28 ms28 ms25 ms28 ms22 ms42 ms26 ms40 ms40 ms33 ms21 ms37 ms35 ms27 ms26 ms35 ms39 ms26 ms32 ms29 ms37 ms46 ms39 ms35 ms22 ms25 ms27 ms31 ms35 ms44 msmedian 31 ms
Thirty requests to each, interleaved, from my connection to Cloudflare’s Singapore data centre. Each dot is one request. The time runs from the request being sent to the first byte arriving, so it includes one round trip (about 20 ms here) as well as the time spent on Cloudflare’s side.
The numbers behind this chart
RequestSamplesMedianp25p75Slowest
The post on its pages.dev address3043 ms41 ms65 ms104 ms
The post on runtimenotes.com3086 ms75 ms115 ms135 ms
style.css on runtimenotes.com3031 ms26 ms37 ms46 ms

For the same HTML, the custom domain took twice as long as pages.dev: a median of 86 ms against 43 ms. The edge-cached stylesheet took 31 ms. Cloudflare’s ETag documentation lists HTML-rewriting features that can remove ETags, including Email Obfuscation and Automatic HTTPS Rewrites. I haven’t found which one applies here yet. Next I’ll turn them off one at a time and see if the ETag comes back.

Real-User Radar: layout jump on desktop

Every lab test so far ran on an emulated phone, so it missed a problem on wide screens. Real-User Radar reads Cloudflare Web Analytics, which measures real visits in readers’ browsers. On 2 September 2026 it recorded a layout shift of 1.0 on the interchange fees post. Google rates anything above 0.25 as poor.

The cause was the old design’s contents drawer, a side panel with the post’s headings that opened by default on screens 1080 px and wider. The harness reproduced the jump at 1440 px on slow 4G with the CPU slowed four times. The live page scored 0.40 on every run.

The Visual Time Machine, recording every painted frame this time, showed why. The script that opened the drawer sat at the end of the article, so on a slow load the browser first drew the page with the contents inline at the top. When the script ran, it hid the list, moved the text up and slid the drawer in. Chrome counted each frame of that movement as a layout shift.

Deciding the layout before the first paint

The browser needed to know whether the drawer was open before it drew anything. A short script in the <head> now checks the screen width and whether the reader last left the drawer open, then sets two classes. The stylesheet uses them to place the article beside the drawer from the first frame:

if (matchMedia('(min-width: 1080px)').matches) {
  document.documentElement.classList.add('toc-js');
  if (localStorage.getItem('toc-collapsed') !== '1') {
    document.documentElement.classList.add('toc-open');
  }
}

Before, the page appeared centred with the contents inline at 0.69 s, the text jumped up at 0.75 s and the drawer was sliding in at 0.82 s. Now the drawer is open from the first frame. The only movement left is the headline rewrapping at 0.93 s when its font arrives. The layout shift fell to 0.037.

Desktop frames of the interchange fees post on slow 4G. Before: a centred page with the contents inline at 0.69 s, the text jumping up at 0.75 s and the drawer sliding in at 0.82 s. After: the drawer is open from the first frame and only the headline rewraps, at 0.93 s.
The interchange fees post at 1440 px on slow 4G with the CPU slowed four times. Times are from the start of the load.

Pixel-Parity Gate: nothing visible changed

The Pixel-Parity Gate checks that a speed fix didn’t change how the site looks. It serves the build from before and after a change, screenshots every page at phone and desktop widths and compares them pixel by pixel. When I moved the fixes to the site’s new design, the before and after screenshots were identical.

Results

Both fixed builds now reach page ready in under a second, down from 4.02 s: 0.86 s on the old design and 0.65 s on the new one. The harness measured the old design, the old design with the fixes and the new design with the same fixes in one session on 15 October 2026.

The RunPod post on three builds, emulated phone over slow 4GPage ready went from 4.02 s on the old design to 0.86 s with the fixes and 0.65 s on the new design with the same fixes. Fully loaded went from 4.95 s to 1.56 s and 2.23 s. First paint is the one measure that got later.Old designOld designOld design, fixedOld design, fixedNew design, fixedNew design, fixednew design, fixed0 s1 s2 s3 s4 s5 sFirst paintFirst paint, old design: 0.70 sFirst paint, old design, fixed: 0.79 sFirst paint, new design, fixed: 0.95 s0.95 sFonts settledFonts settled, old design: 3.70 sFonts settled, old design, fixed: 1.26 sFonts settled, new design, fixed: 2.23 s2.23 sPage readyPage ready, old design: 4.02 sPage ready, old design, fixed: 0.86 sPage ready, new design, fixed: 0.65 s0.65 sFully loadedFully loaded, old design: 4.95 sFully loaded, old design, fixed: 1.56 sFully loaded, new design, fixed: 2.23 s2.23 s0 KB250 KB500 KB750 KB1000 KBDownloadedDownloaded, old design: 899 KBDownloaded, old design, fixed: 227 KBDownloaded, new design, fixed: 368 KB368 KBThe RunPod post on three builds, emulated phone over slow 4GPage ready went from 4.02 s on the old design to 0.86 s with the fixes and 0.65 s on the new design with the same fixes. Fully loaded went from 4.95 s to 1.56 s and 2.23 s. First paint is the one measure that got later.Old designOld designOld design, fixedOld design, fixedNew design, fixedNew design, fixed0 s1 s2 s3 s4 s5 sFirst paintFirst paint, old design: 0.70 sFirst paint, old design, fixed: 0.79 sFirst paint, new design, fixed: 0.95 sFonts settledFonts settled, old design: 3.70 sFonts settled, old design, fixed: 1.26 sFonts settled, new design, fixed: 2.23 sPage readyPage ready, old design: 4.02 sPage ready, old design, fixed: 0.86 sPage ready, new design, fixed: 0.65 sFully loadedFully loaded, old design: 4.95 sFully loaded, old design, fixed: 1.56 sFully loaded, new design, fixed: 2.23 s0 KB500 KB1000 KBDownloadedDownloaded, old design: 899 KBDownloaded, old design, fixed: 227 KBDownloaded, new design, fixed: 368 KB
The RunPod post on an emulated phone over slow 4G. Medians of three loads of each build, all measured in one session on 15 October 2026. “Page ready” is DOMContentLoaded. Hollow circles are the old design, filled circles the old design with the fixes and diamonds the new design with the same fixes.
The numbers behind this chart
PageMeasureOld designOld design, fixedNew design, fixed
The RunPod postFirst paint0.70 s0.79 s0.95 s
The RunPod postFonts settled3.70 s1.26 s2.23 s
The RunPod postPage ready4.02 s0.86 s0.65 s
The RunPod postFully loaded4.95 s1.56 s2.23 s
The RunPod postDownloaded in the first 8 s899 KB227 KB368 KB
RunPod post on slow 4GOld designOld design, fixedNew design, fixed
Page ready4.02 s0.86 s0.65 s
Fully loaded4.95 s1.56 s2.23 s
Fonts settled3.70 s1.26 s2.23 s
First paint0.70 s0.79 s0.95 s
Downloaded899 KB227 KB368 KB

The new design takes longer to finish loading because its serif font is heavier. Source Serif 4 with its optical sizes is 122 KB for the regular face and 50 KB for italic, while the old design’s Hanken Grotesk was about 35 KB for each. First paint also moved from 0.70 s to 0.95 s.

Each technique’s finding and its result:

TechniqueFoundResult
Visual Time MachineText drawn twiceFonts 2.4 s sooner, first paint 0.1 s later
Network Waterfall MappingMermaid on every pagePage ready under 1 s
Tap-in-Flight TestingImages moving headings755 KB to 227 KB on load
Main-Thread Black BoxA 297 ms freezeTraced to Cloudflare
Build-to-Edge DiffInjected bot checkNo fix on the free plan
Twin-Origin BenchmarkingNo ETag on the custom domainCause not found yet
Real-User RadarDesktop layout jump0.40 to 0.037
Pixel-Parity GateNothing visible changedIdentical screenshots

If your Zola site runs on Cloudflare Pages, run the curl checks against your own domain. They show whether Cloudflare adds the bot-check script and drops the ETag on your pages too.

I’ve since tested the harness on 23 live sites. It also found a font reflow problem on this blog and helped me fix it. More on both in the second post of this series.