We blamed the animation library for four days
Our own site scored 58 on mobile PageSpeed. We were confident we knew why, and we were wrong three times before we measured properly. The two fixes that worked took about six lines.
EK Ra Sunya Team
EK Ra Sunya Inc.
Our own website scored 58 on mobile PageSpeed. Not a client's site. Ours, the one that is supposed to demonstrate that we know how to build fast web applications.
The fix took about six lines across two files. Getting to those six lines took four days, and three of those days were spent confidently fixing things that were not broken.
This is the writeup of what we got wrong, because the wrong turns are the part worth reading. Everyone publishes the after screenshot.
The number moved and we chased it
PageSpeed read 83 at the start of the week. A few deploys later it read 58. That looks exactly like a regression you just shipped, so we treated it as one and started bisecting our own recent commits.
It was not a regression. The site had a Cumulative Layout Shift of 1.0, which is about as bad as that metric goes, and it had been there the whole time. We confirmed it by checking out a commit from before any of the week's work and measuring again: still 1.0. PageSpeed had simply caught it this time and missed it before. CLS depends on when the measurement runs and how far the page has scrolled, so a long-standing shift can hide from several consecutive runs and then show up looking like something you did on Tuesday.
The lesson we actually took from this one: before you bisect, confirm the thing you are bisecting for is new. We spent half a day looking for a regression that did not exist.
Three suspects, all innocent
With CLS 1.0 as the real target, we started removing the obvious culprits. Each time we removed one, rebuilt, and measured.
Lenis, the smooth-scroll library. Removed it. CLS still 1.0.
The preloader, the animated intro overlay that covers the page on first paint. Obvious suspect, and it was also carrying the blame for roughly 1.8 seconds of load time in our notes. Removed it. CLS still 1.0. The 1.8 seconds turned out not to be real either.
Images without dimensions, the classic cause. Lighthouse's `unsized-images` audit passed. Not that.
Three suspects, three rebuilds, three measurements, no progress. What saved the week was that each one was checked rather than assumed. If we had removed all three at once and the number had moved, we would have shipped three changes, credited the wrong one, and learned nothing.
It was the loading state
Lighthouse attributes a layout shift to a specific DOM node, and it had been pointing at the same one the entire time. The shift came from `loading.tsx`, the Suspense fallback Next.js renders while a route segment resolves.
The fallback was a `fixed inset-0` overlay. It paints, covers the viewport, then gets swapped for the real page. Every navigation was a full-viewport shift, which is how a single element earns a CLS of 1.0 on its own.
The fix was two lines in that file. CLS went from 1.0 to 0. We verified it three ways before believing it: the same build measured locally before and after, then PageSpeed, then GTmetrix, each independently.
Two lines. Four days. The ratio is the honest part of this post.
Then the blocking time, which was not our JavaScript either
CLS fixed, Total Blocking Time was still around 400ms on throttled mobile. Our notes from earlier in the week blamed framer-motion, the animation library, and proposed converting three components away from it to drop the bundle.
We built one of those conversions to see what it would save. It saved 0 KiB. framer-motion was still being pulled in site-wide by other components, so converting three of them removed nothing at all.
So we measured instead of theorising, using Lighthouse's `bootup-time` audit, which attributes main-thread work to individual scripts. The largest consumer was not framer-motion. It was not any of our code. It was Cloudflare's own injected JavaScript, and the single biggest item inside it was Rocket Loader at 145ms.
Rocket Loader defers scripts to speed up first paint. On a Next.js app it was rewriting all 30 script tags on the page, changing their `type` attribute so the browser would not execute them normally, then executing them itself. An optimisation feature, doing the opposite of optimising, on a framework that already handles its own script loading.
We turned it off. Bootup time went from 1.7s to 0.9s. TBT went from about 400ms to 80ms on throttled mobile, and to 0ms on desktop. No framer-motion rewrite was needed, and we still have not done one.
Where it landed
| Metric | Before | After |
|---|---|---|
| CLS | 1.0 | 0 |
| PageSpeed mobile | 58 | 85 |
| LCP | 3.6s | 1.9s |
| TBT (throttled mobile) | ~400ms | 80ms |
| TBT (desktop) | - | 0ms |
| HTML edge cache | DYNAMIC | HIT |
The 85 predates the last two fixes, so the current number is higher. We have not re-measured it carefully enough to publish a figure, and quoting a number we have not verified is the mistake this entire post is about.
The edge cache line is the other change worth mentioning: a Cloudflare cache rule that makes HTML eligible for caching at the edge, scoped to exclude authenticated paths. Cache an admin route by accident and you serve one user's authenticated page to another, or serve a stale redirect that logs people out. That rule needs its exclusions written before it is enabled, not after.
Do not trust one measurement tool
The same commit measured 51 to 58 on local Lighthouse, 54 against the live site, and 83 then 58 on PageSpeed. Same code every time.
Local runs are unthrottled, cold-cache, headless, and sharing a CPU with whatever else is building. They are useful for comparing two builds on the same machine within a few minutes of each other. They are not useful as an absolute score, and they disagree with the field data your users actually generate.
PageSpeed's anonymous API also has a daily quota, and it returns HTTP 429 when you exhaust it. We exhausted it. If you are iterating on performance work, you will too, so plan on the web interface for spot checks and keep local Lighthouse for A/B comparisons between builds.
Use one tool to compare two versions of your own site. Use a different one to decide whether your site is fast. Do not mix those jobs.
What we would tell a client
Performance work has a strong pull toward the interesting explanation. The animation library, the bundle size, the framework overhead. Those are fun problems and they make for good architecture conversations.
The actual causes here were a loading state and a CDN feature somebody enabled once. Neither is interesting. Both were findable in an afternoon by reading what the measurement tools were already saying, which in our case was the same DOM node Lighthouse had been naming since the first run.
If you are about to rewrite a component for performance reasons, measure what you are about to save first. We built a conversion that saved 0 KiB, and that null result was more useful than the four days of theory that preceded it, because it ended the argument.
We build and maintain web applications for clients who care about this, and the work above is the kind of thing an ongoing engagement catches before it reaches a score of 58. If that is a problem you currently have, get in touch.
Have a project in mind?
Let us help you build something scalable, fast, and built to last.
Start a conversationKeep reading
Jev does not hallucinate. It hands you the uncertainty.
TypeSafe AI's new model returns a probability instead of a sentence. That is a real improvement. It also moves a decision nobody is talking about onto your side of the wire.
EngineeringThe same model scored 62.7% and 99.9% on the same test
OpenAI's GPT-6 Astra posted two very different numbers on the same benchmark in the same week. The gap was not the model. It was the harness, and that distinction matters to anyone buying software.

