Pixuntra
Blog

Deleting one animation loop cut our mobile LCP from 2.1s to 86ms

6 min read
PerformanceNext.js

Our marketing site was scoring 100 on desktop Lighthouse and 92–96 on mobile. The gap was Largest Contentful Paint. Here is how we found the cause, including the part where our first hypothesis was wrong.

The symptom

Mobile Lighthouse reported LCP at 3.3 seconds. The LCP element was the hero paragraph — plain text, no image, nothing to download.

The phase breakdown is where it got interesting:

TTFB          454ms   14%
Load Delay      0ms    0%
Load Time       0ms    0%
Render Delay 2869ms   86%

Eighty-six percent render delay on a text element. Nothing was being downloaded for it. Something was preventing the browser from painting.

The wrong hypothesis

Our first assumption was webfonts. The page loads two — Geist and Geist Mono, about 53KB together. With font-display: swap, text paints in a fallback and then repaints when the real font arrives. LCP is recorded at that second paint.

That story fit the evidence, so we changed swap to optional, which lets the browser keep the fallback rather than repainting.

It did not help. LCP stayed at 3.3s.

Before that we had also tried removing preload from the mono font, reasoning it competed for bandwidth with the sans font that actually is the LCP element. That made things measurably worse: FCP went from 0.9s to 1.2s, LCP from 3.1s to 3.3s.

Two hypotheses, both plausible, both wrong. The only reason we knew is that we measured after each change instead of assuming.

The actual cause

The clue was in a field we had been ignoring:

firstContentfulPaint            1094
largestContentfulPaint          3322
observedFirstContentfulPaint    2102
observedLargestContentfulPaint  2102

The observed values were identical. In the real trace, the page painted once and that was both FCP and LCP. The optional change had worked. The divergence was in Lighthouse's simulated model, which replays the dependency graph over a synthetic slow connection with a 4× CPU slowdown.

That pointed at CPU, not network. And main-thread work was 1.9 seconds.

The site is almost entirely server-rendered with three small client components. One of them was this:

useEffect(() => {
  let raf = 0;
  const tick = () => {
    // ...cursor position, idle detection, orbiter updates
    raf = requestAnimationFrame(tick);
  };
  raf = requestAnimationFrame(tick);   // ← starts immediately, always
  // ...
}, []);

A custom cursor. It runs a requestAnimationFrame loop from the moment it mounts.

On every page. Including mobile.

Mobile devices have no cursor. The loop ran every frame, read layout, updated transforms on an element nobody could see, and competed with hydration during page load — to render precisely nothing.

The fix

Two changes, neither of them clever.

Do not set anything up on devices without a cursor:

if (!globalThis.matchMedia("(pointer: fine)").matches) return;

Do not start the loop until there is something to animate:

const startLoop = () => {
  if (running) return;
  running = true;
  raf = requestAnimationFrame(tick);
};

// called from the pointermove handler, not on mount

The result

| Metric | Before | After | | --- | --- | --- | | Observed LCP (home) | 2102 ms | 86 ms | | Observed LCP (services) | — | 78 ms | | Main-thread work (services) | 1.9 s | 0.3 s |

Across every page, observed LCP landed between 64ms and 86ms.

Zero visual change. Desktop behaves exactly as before, and mobile never had a cursor to draw.

What we would tell someone facing this

Measure after every change, including the ones you are confident about. We had two convincing hypotheses and both were wrong. The cost of checking was one Lighthouse run each; the cost of not checking would have been shipping a worse site while believing we had improved it.

Always-on JavaScript animation is expensive even when it renders nothing. A requestAnimationFrame loop does not care whether anyone can see the result. Gate ambient animation on the capability it needs — (pointer: fine), prefers-reduced-motion, intersection with the viewport — and start it lazily.

Read the observed metrics, not just the score. Lighthouse's simulated numbers are a modelled worst case. When observed FCP and LCP are identical but the simulated ones differ by two seconds, that difference is telling you where to look.

The best optimisation is usually deletion. We did not add caching, preloading or a library. We stopped doing work that had no purpose.

Want this kind of attention on your project?

We build software for startups and established teams — with the performance, accessibility and security work treated as delivery rather than an upsell.