Get in touch
Uncategorized

We Thought the Site Was Fast. PageSpeed Said 58 — and the Problem Wasn’t Where Everyone Looks

We were sure the site was fast. The server delivers the page in 0.4 seconds, the cache works, images aren’t 4 megabytes each, the theme was written from scratch without any page builder. Then we opened PageSpeed Insights, switched to “Mobile” — and saw 58.

PageSpeed Insights before optimisation: performance score 58, TBT 1160 ms
This is what the report looked like before the changes. Measured on a dev copy with the old code, so we didn’t need a time machine (that’s why SEO is 66 — the dev domain has noindex set)

The interesting part started in the list of issues. There was nothing you’d normally suspect: no slow server, no giant JPEGs, no “too many plugins”. There was one line that explained everything:

PageSpeed diagnostics: minimize main-thread work 9.4 s, 13 long tasks
9.4 seconds of main-thread work and 13 long tasks — on a page with no heavy widget in sight

In one working day we brought the mobile scores up to 97–100 / 100 / 100 / 100 and along the way got a 3/3 in the new “Agentic browsing” section. Below is the actual code that did it, what optimisations didn’t work, and why that last section is about to matter more than the rest.

An honest disclaimer first

  • These are lab measurements. Lighthouse runs the page on an emulated Moto G Power with a throttled 4G connection. Real users on a fast connection will see better results; on a cheap phone in the subway, worse.
  • The score wobbles. The same site in three runs in a row gives 97, 99 and 100. We take the median of three — and recommend everyone do the same before arguing about “but I got 89”.
  • 100 isn’t the goal. The goal is for the page to open and start responding to your finger within a second. The score is just a convenient way to check you’ve got there.

Where the 40 points were hiding

1. An endless logo-row animation — 950 ms of CPU work

A strip of client logos was scrolling via requestAnimationFrame: every frame, JavaScript calculated the offset and wrote it into transform. It looked perfect and cost almost a full second of main-thread work in the first seconds of loading. Here’s what it looked like:

// before: JS calculates position every frame
const tick = (now) => {
  const dt = (now - last) / 1000;
  last = now;
  state.forEach(s => {
    s.offset += s.dir * s.speed * dt;
    if (s.offset <= -s.half) s.offset += s.half;
    s.track.style.transform = `translate3d(${s.offset.toFixed(2)}px,0,0)`;
  });
  rafId = requestAnimationFrame(tick);
};

And here’s the same thing, but running on the compositor — the main thread doesn’t take part at all. The tracks are already duplicated, so a -50% offset loops seamlessly:

/* after: the animation lives in CSS */
@keyframes cc-marq-l { from { transform: translate3d(0,0,0) }
                       to   { transform: translate3d(-50%,0,0) } }

.lm-track { animation: cc-marq-l var(--lm-dur, 70s) linear infinite }
.stack-row[data-dir="right"] .stack-track { animation: cc-marq-r 75s linear infinite }

@media (prefers-reduced-motion: reduce) { .lm-track, .stack-track { animation: none } }

Only one bit of JS remained — measuring the track width once and converting a fixed speed (45 px/s) into animation seconds.

2. three.js for one rectangle

The animated gradient in the header is a full-screen fragment shader. It was pulling in three.js: 1.3 MB of library (252 KB compressed) just to draw two triangles and pass three variables to the GPU. We dropped the library and wrote the same thing in plain WebGL:

const gl = canvas.getContext('webgl', { antialias:false, alpha:false, depth:false });

const compile = (type, src) => {
  const sh = gl.createShader(type);
  gl.shaderSource(sh, src); gl.compileShader(sh);
  if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh));
  return sh;
};

const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));   // the same shader as before
gl.linkProgram(program);
gl.useProgram(program);

// one triangle that covers the whole screen
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);

Along with the animation loop, resize handling and pausing off-screen — about 3 KB of code instead of 1.3 MB. Visually, the gradient didn’t change by a single pixel.

3. A client map nobody had seen yet

The world-map section was loading d3-geo, topojson and a 740 KB country-geometry file — right at the start, even though the map itself sits at the very bottom of the page. Now all of that arrives when the section is 400 px away:

const section = document.querySelector('.clients-map');

const io = new IntersectionObserver((entries) => {
  if (entries.some(e => e.isIntersecting)) {
    io.disconnect();
    boot();                       // only here do we load d3 + topojson + geojson
  }
}, { rootMargin: '400px 0px' });

io.observe(section);

4. Google Fonts as a third party

The Manrope font was coming from fonts.googleapis.com, with the file itself from fonts.gstatic.com: two extra DNS lookups and two TLS handshakes on the critical path. We brought it in-house, two subsets instead of six:

/* assets/css/fonts.css — our own, not Google's */
@font-face{
  font-family:'Manrope'; font-style:normal; font-weight:400 800; font-display:swap;
  src:url('../fonts/manrope-cyrillic.woff2') format('woff2');
  unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

5. WooCommerce on pages that have no shop

WooCommerce loads its styles and scripts everywhere — on the home page, on the blog, on the “About” page. We strip it out wherever there’s no shop:

function catcode_needs_woo_assets(): bool {
    if (!function_exists('is_woocommerce')) return false;
    if (is_woocommerce() || is_cart() || is_checkout() || is_account_page()) return true;
    if (is_singular(['module', 'product'])) return true;
    if (function_exists('WC') && WC()->cart && !WC()->cart->is_empty()) return true;
    // a page with a shop shortcode/block — keep it too
    return false;
}

6. Images — yes, those too

Case-study cards were serving full-size 1440 px JPEGs even to phones. Now every JPEG and PNG in the media library has a WebP twin, and the theme wraps the markup in a <picture> with a fallback to the original:

add_filter('wp_get_attachment_image', function ($html) {
    $srcset = catcode_webp_srcset($html);   // '' if even one size is missing a .webp
    if ($srcset === '') return $html;

    return sprintf('<picture><source type="image/webp" srcset="%s"%s>%s</picture>',
        esc_attr($srcset), $sizes, $html);
});

What we got

PageSpeed Insights after optimisation: 97 performance, 100 accessibility, 100 best practices, 100 SEO, 3/3 agentic browsing
The same site after the changes: FCP 1.1 s, LCP 1.5 s, TBT 100 ms, CLS 0
PageSpeed diagnostics after optimisation: two long tasks instead of thirteen
There used to be 13 long tasks and 9.4 s of main-thread work — now there are 2 tasks left

What did NOT help

Half the advice from typical checklists gave us nothing in this case — and that’s just as useful to know as what worked.

  • WebP on its own. The homepage images sit below the fold and load lazily. They bloated the page weight, but barely moved the score — the gain came from JavaScript, not from them.
  • defer on every script. We already had that — all the theme’s scripts were deferred before the optimisation. It doesn’t help if the deferred script then paints every single frame.
  • Preloading “just in case”. Preloading everything just reshuffles the request queue. Exactly two preloads actually helped: the font and the header logo.
  • Lowering the shader’s resolution. We honestly tried to keep the live WebGL animation on mobile, dropping the render scale to 0.4× and 30 fps, then to 0.25× and 20 fps. TBT fell from 594 to 273 ms and stopped moving: Lighthouse’s mobile rendering runs without a GPU, in software. The trade-off — on phones, a still frame of the same shader is shown first (a 17 KB WebP), and the animation starts after the first tap or scroll.
  • Small micro-optimisations. Removing WooCommerce assets, trimming unused CSS on the homepage, deferring gtag — all together gave about 2–3 points. They only matter once the big problems are already fixed.

Agentic browsing — a new section worth watching

In May 2026 Google moved the Agentic Browsing category from experimental into Lighthouse’s main configuration, and it soon showed up in PageSpeed Insights. The score here isn’t 0–100, but a ratio of checks passed: 3/3, 2/3 and so on.

The question this section answers: can an AI agent read the page, understand its controls, and complete a task without guessing? Here’s what those three checks look like in a real report:

Agentic browsing section in PageSpeed Insights: accessibility tree, CLS 0, llms.txt file
Accessibility tree, Cumulative Layout Shift and llms.txt — three checks an ordinary site can pass
  • Accessibility tree. Whether interactive elements have programmatic names, correct roles and proper nesting. The same thing a screen reader needs — now an agent needs it too.
  • Layout stability (CLS). So the agent doesn’t click where a button used to be half a second ago.
  • An llms.txt file at the domain root — a machine-readable markdown description of the site: what the project is, which pages matter, where the documentation is.

There’s a fourth check too — WebMCP, markup for the site’s forms and actions so an agent can use them directly. It only appears for sites that have already implemented this experimental protocol, so for an ordinary site the ceiling is 3/3.

Our 3/3 wasn’t luck: we put up llms.txt back in spring, along with structured data for the module pages, and we’ve kept accessibility at 100 from day one. What matters to understand: this isn’t a ranking factor, and the category is explicitly marked as still evolving. But the direction is clear — site quality has officially stopped being purely a matter of human experience. Half of your future “visitors” are assistants coming to fetch an answer on the user’s behalf.

The main takeaway

Our biggest gain didn’t come from small optimisations, but from dropping heavy JS animations in the first viewport. One row of logos on requestAnimationFrame and one 1.3 MB library cost more points than every image, font and third-party script put together.

The practical takeaway for any site: before you compress images, open the Performance tab and see who’s painting every frame. The most expensive thing on a page isn’t what’s heavy — it’s what runs continuously.

What to take away from this

  • Don’t trust a single PageSpeed run — take the median of three, or you’ll be arguing with a random number.
  • Look for requestAnimationFrame and libraries pulled in for a single function.
  • Anything below the fold should load as it comes into view, not at start-up.
  • Fonts and scripts from other people’s domains are handshakes that the first paint of text pays for.
  • Check your own llms.txt and accessibility tree now, while barely anyone is reading the “Agentic browsing” section carefully yet.

If your mobile score is under 70 and you’re not sure where to start — get in touch. We’ll look at your site and tell you the three things that will move the needle most in your specific case, without generic advice about “compressing images”.

Share

Ready to talk about your release?

We reply within 60 minutes during working hours — with a budget estimate, a rough timeline and the team line-up for your task.

Send a request