Codentra
All writing
Case study7 min read

17 seconds of blocking time that weren't real

PageSpeed told us our homepage blocked the main thread for 17.8 seconds. The number was accurate and the diagnosis was wrong. What we found instead.

·Performance · Core Web Vitals · Next.js · Framer Motion

This is a write-up of performance work on our own site — codentra.se — not a client engagement. We publish it because the interesting part wasn't the optimisation. It was the two days we nearly spent optimising the wrong thing.

Our homepage renders a rotating wireframe Earth: a dot-sampled sphere with a day/night terminator, a radar sweep, city lights, and arcs launching from Stockholm. It is the most expensive thing on the page by a wide margin, so when PageSpeed Insights reported 17,830 ms of Total Blocking Time, the globe was the obvious suspect.

It was not the globe.

First, the part that was genuinely slow

Before the misdiagnosis, there was a real problem worth fixing. The globe generated its point cloud at runtime: a Fibonacci sphere sampling routine that ran on every page load, on the main thread, before the first frame could draw. It cost 1,639 ms.

That work is completely deterministic. The same inputs produce the same 47,615 points every time, on every device, forever. So it has no business running in a browser at all. We moved it to a build step that writes the points to a binary file — three Int16 values per point, 279 KB — which the client fetches and reads straight into a typed array.

1,639 ms → 0 ms
Point generation
moved to build time
13,149 → 47,615
Points rendered
3.6× denser
1.206 ms → ~0.5 ms
Per-frame projection
while tripling the point count

The per-frame win came from replacing the projection library call with inlined cartesian rotation, batching every draw into one path fill per brightness band, and replacing fixed-capacity band arrays with a counting sort — the last one mattered because the terminator collapses the entire night hemisphere into the dimmest bands, and peak occupancy quietly exceeded what the fixed arrays could hold. Points were being dropped silently.

Hand-rolling projection maths is where rendering bugs come from, so we checked it rather than trusting it: the rotation was verified against d3-geo's orthographic projection across 20,000 sampled coordinates, agreeing to within 1.5e-12 pixels.

The 17.8 seconds

With all of that done, the lab numbers barely moved. Total Blocking Time was still catastrophic. That is the point where it is very tempting to keep optimising the thing you already understand.

What we did instead was look at the rendered DOM of the deployed page. It contained almost nothing — a fallback line reading "This page couldn't load". The document was 900 pixels tall. Locally it was 12,361.

The page was crashing during hydration, and React was tearing down and re-rendering the entire tree client-side. That teardown is what the 17.8 seconds measured. The number was real. It just wasn't a performance problem — it was an exception with a stopwatch on it.

The actual bug

The crash came from a scroll-linked animation. Framer Motion compiles the range form of useTransform into native Web Animations API animations, and the WAAPI spec requires keyframe offsets to sit inside [0, 1] and be monotonically non-decreasing. Offsets outside that range throw.

// step index 0 → start = 0
const opacity = useTransform(
  progress,
  [start - 0.05, start, end, end + 0.05],  // -0.05 → TypeError
  [0, 1, 1, 0]
);
The first stage started 5% before the section did.

One negative number, thrown at hydration, took down every section on the page. And because the thrown error was inside an animation library rather than application code, nothing in the console pointed at the section that caused it.

There was a second, subtler problem underneath. Even with every offset in range, Framer's WAAPI compilation mishandles sub-ranges — a stage living in 0.50–0.75 rather than the full 0–1 — which rendered stages that should have been hidden at full opacity, stacked on top of each other. Reading the values back with getComputedStyle returned numbers that made no sense, which is the tell. The fix was to convert every scroll-linked transform to the function form, which stays in JavaScript and never compiles to WAAPI at all.

17,830 ms → 0 ms
Total Blocking Time
the crash, not the code
900 px → 12,361 px
Rendered document height

The blank frame

One real problem survived the crash fix: Speed Index, which measures how quickly a page paints visible content rather than how long it blocks.

Our background is a WebGL shader. Until it compiled and drew its first frame, the canvas painted nothing — so the viewport sat flat, then every pixel changed at once. Speed Index punishes exactly that shape. We gave the canvas a CSS background derived from the shader's own palette constants, so the first paint is already approximately right and the shader resolves into it, and we shortened the entrance delays on the headline.

64 → 93
Speed Index, mobile
median of three local runs

What we took from it

  • A lab metric tells you a number, not a cause. Total Blocking Time cannot distinguish expensive work from a crash loop — both look like a busy main thread.
  • Check that the page rendered before you optimise how fast it rendered. Rendered DOM and document height are two cheap assertions that would have saved us two days.
  • Deterministic work belongs at build time. If the output is identical on every device, computing it on the user's device is a choice, not a requirement.
  • Verify hand-rolled maths against a reference implementation. Ours agreed with d3-geo to 1.5e-12 px, which is why we could rule the renderer out immediately.
  • Paint something before you paint the right thing. A background that arrives late costs more than a background that starts approximate.

Core Web Vitals thresholds are assessed at the 75th percentile of real visits — LCP at or under 2.5 s, INP at or under 200 ms, CLS at or under 0.1. Lab tools are how you find problems. Field data is how you know whether you actually had one.

Tell us what you're building.

A few lines about your project is enough to start. You'll get a reply within one business day with a clear next step.