Debugging a slow Zola blog with an agentic performance test harness
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:
| Technique | What it does |
|---|---|
| Visual Time Machine | Replays the load as a visitor, a frame every 100 ms |
| Network Waterfall Mapping | Maps every download on a timeline |
| Tap-in-Flight Testing | Link loading propagation |
| Main-Thread Black Box | Traces blocking tasks to the source |
| Build-to-Edge Diff | Deployed HTML against what visitors receive |
| Twin-Origin Benchmarking | Isolates the cost of custom domain settings |
| Real-User Radar | Checking lab results against real visitors’ reports |
| Pixel-Parity Gate | Pixel-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.
| Event | Time |
|---|---|
| First paint | 0.68 s |
| Page ready (DOMContentLoaded) | 4.04 s |
| Fully loaded | 5.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.The numbers behind this chart
Part What From To Size On screen blank 0.00 s 0.68 s On screen text in system fonts 0.68 s 3.70 s 1 web fonts, from Google downloading 0.51 s 3.63 s 173 KB 2 Mermaid library downloading 0.71 s 3.89 s 81 KB 3 four diagram images downloading 0.22 s 5.08 s 625 KB 4 Cloudflare bot check downloading 4.03 s 5.20 s 11 KB 4 Cloudflare bot check main thread blocked 4.57 s 4.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:The numbers behind this chart
Page First paint Fonts settled Stand-in fonts Home page, slow 4G before 0.54 s 1.79 s 1.25 s Home page, slow 4G after 0.74 s 0.84 s 0.09 s A post, slow 4G before 0.64 s 3.63 s 2.99 s A post, slow 4G after 0.80 s 1.25 s 0.46 s Home page, 4G before 0.29 s 0.55 s 0.26 s Home page, 4G after 0.33 s 0.32 s 0.00 s A post, 4G before 0.38 s 0.91 s 0.54 s A post, 4G after 0.50 s 0.57 s 0.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:
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.
Tap-in-Flight Testing: lazy images break heading links
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.The numbers behind this chart
Images Heading ends at Off by Layout shift after the tap Lazy, with sizes 60 px +0 px 0.001 Lazy, no sizes 435 px +375 px 0.112 Not lazy, no sizes 28 px -32 px 0.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:The numbers behind this chart
Load Request Start End Size Before HTML page 0.00 s 0.21 s 11 KB Before style.css 0.18 s 0.44 s 5 KB Before Google Fonts CSS 0.19 s 0.42 s 1 KB Before logo font 0.46 s 3.59 s 73 KB Before body font 0.46 s 2.38 s 34 KB Before mono font 0.46 s 2.24 s 31 KB Before italic font 0.46 s 2.39 s 35 KB Before mermaid 0.66 s 3.83 s 81 KB Before diagram 1 0.19 s 3.50 s 93 KB Before diagram 2 0.19 s 4.57 s 157 KB Before diagram 3 0.24 s 4.71 s 166 KB Before diagram 4 0.24 s 4.93 s 207 KB Before analytics 0.19 s 0.53 s 3 KB After HTML page 0.00 s 0.23 s 15 KB After style.css 0.19 s 0.54 s 8 KB After Google Fonts CSS not requested After logo font 0.19 s 0.49 s 6 KB After body font 0.19 s 0.80 s 34 KB After mono font 0.19 s 0.78 s 31 KB After italic font 0.58 s 1.21 s 35 KB After mermaid not requested After diagram 1 0.78 s 1.55 s 93 KB After diagram 2 waits until you scroll near it After diagram 3 waits until you scroll near it After diagram 4 waits until you scroll near it After analytics 0.19 s 0.52 s 3 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:The numbers behind this chart
Load Task Starts Blocks for Phone, a post the page's own start-up 0.49 s 171 ms Phone, a post mermaid starting up 3.90 s 104 ms Phone, a post Cloudflare bot check 4.57 s 297 ms Desktop, Writing Cloudflare bot check 0.69 s 64 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.
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:The numbers behind this chart
Request Samples Median p25 p75 Slowest The post on its pages.dev address 30 43 ms 41 ms 65 ms 104 ms The post on runtimenotes.com 30 86 ms 75 ms 115 ms 135 ms style.css on runtimenotes.com 30 31 ms 26 ms 37 ms 46 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.
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 numbers behind this chart
Page Measure Old design Old design, fixed New design, fixed The RunPod post First paint 0.70 s 0.79 s 0.95 s The RunPod post Fonts settled 3.70 s 1.26 s 2.23 s The RunPod post Page ready 4.02 s 0.86 s 0.65 s The RunPod post Fully loaded 4.95 s 1.56 s 2.23 s The RunPod post Downloaded in the first 8 s 899 KB 227 KB 368 KB
| RunPod post on slow 4G | Old design | Old design, fixed | New design, fixed |
|---|---|---|---|
| Page ready | 4.02 s | 0.86 s | 0.65 s |
| Fully loaded | 4.95 s | 1.56 s | 2.23 s |
| Fonts settled | 3.70 s | 1.26 s | 2.23 s |
| First paint | 0.70 s | 0.79 s | 0.95 s |
| Downloaded | 899 KB | 227 KB | 368 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:
| Technique | Found | Result |
|---|---|---|
| Visual Time Machine | Text drawn twice | Fonts 2.4 s sooner, first paint 0.1 s later |
| Network Waterfall Mapping | Mermaid on every page | Page ready under 1 s |
| Tap-in-Flight Testing | Images moving headings | 755 KB to 227 KB on load |
| Main-Thread Black Box | A 297 ms freeze | Traced to Cloudflare |
| Build-to-Edge Diff | Injected bot check | No fix on the free plan |
| Twin-Origin Benchmarking | No ETag on the custom domain | Cause not found yet |
| Real-User Radar | Desktop layout jump | 0.40 to 0.037 |
| Pixel-Parity Gate | Nothing visible changed | Identical 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.