Det här är en genomgång av prestandaarbete på vår egen sajt — codentra.se — inte ett kunduppdrag. Vi publicerar den för att det intressanta inte var optimeringen. Det var de två dagar vi nästan lade på att optimera fel sak.
Vår startsida renderar en roterande jordglob i trådram: en punktsamplad sfär med en dag/natt-gräns, ett radarsvep, stadsljus och bågar som skjuts ut från Stockholm. Den är med bred marginal det dyraste på sidan, så när PageSpeed Insights rapporterade 17 830 ms Total Blocking Time var globen den självklara misstänkta.
Det var inte globen.
Först, det som faktiskt var långsamt
Innan feldiagnosen fanns ett verkligt problem värt att åtgärda. Globen genererade sitt punktmoln vid körning: en Fibonacci-sfär som samplades vid varje sidladdning, på huvudtråden, innan första bildrutan kunde ritas. Det kostade 1 639 ms.
Det arbetet är helt deterministiskt. Samma indata ger samma 47 615 punkter varje gång, på varje enhet, för alltid. Det har alltså ingenting i en webbläsare att göra. Vi flyttade det till ett byggsteg som skriver punkterna till en binärfil — tre Int16-värden per punkt, 279 KB — som klienten hämtar och läser rakt in i en typad array.
Vinsten per bildruta kom från att ersätta projektionsbibliotekets anrop med inlinad kartesisk rotation, samla varje ritning i en path-fyllning per ljusstyrkeband och ersätta bandarrayer med fast kapacitet med en counting sort — det sista spelade roll eftersom dag/natt-gränsen trycker ihop hela nattsidan i de mörkaste banden, och toppbeläggningen översteg i tysthet vad de fasta arrayerna rymde. Punkter föll bort utan ett ljud.
Handskriven projektionsmatematik är där renderingsbuggar föds, så vi kontrollerade den i stället för att lita på den: rotationen verifierades mot d3-geos ortografiska projektion över 20 000 samplade koordinater och stämde överens inom 1,5e-12 pixlar.
De 17,8 sekunderna
Med allt det gjort rörde sig labbsiffrorna knappt. Total Blocking Time var fortfarande katastrofal. Det är i det läget det är väldigt lockande att fortsätta optimera det man redan förstår.
Det vi gjorde i stället var att titta på den renderade DOM:en på den driftsatta sidan. Den innehöll nästan ingenting — en reservrad med texten "This page couldn't load". Dokumentet var 900 pixlar högt. Lokalt var det 12 361.
Sidan kraschade under hydreringen, och React rev ner och renderade om hela trädet på klientsidan. Det var den nedrivningen som de 17,8 sekunderna mätte. Siffran var verklig. Det var bara inte ett prestandaproblem — det var ett undantag med ett stoppur på.
Den faktiska buggen
Kraschen kom från en scrollkopplad animation. Framer Motion kompilerar intervallformen av useTransform till native Web Animations API-animationer, och WAAPI-specifikationen kräver att keyframe-offsets ligger inom [0, 1] och är monotont icke-avtagande. Offsets utanför det intervallet kastar ett fel.
// step index 0 → start = 0
const opacity = useTransform(
progress,
[start - 0.05, start, end, end + 0.05], // -0.05 → TypeError
[0, 1, 1, 0]
);Ett enda negativt tal, kastat vid hydrering, tog ner varje sektion på sidan. Och eftersom felet kastades inuti ett animationsbibliotek snarare än i applikationskoden pekade ingenting i konsolen på sektionen som orsakade det.
Under det fanns ett andra, subtilare problem. Även med alla offsets inom intervallet hanterar Framers WAAPI-kompilering delintervall fel — ett steg som lever i 0,50–0,75 i stället för hela 0–1 — vilket renderade steg som borde vara dolda med full opacitet, staplade ovanpå varandra. Att läsa tillbaka värdena med getComputedStyle gav siffror som inte gick ihop, och det är avslöjandet. Lösningen var att göra om varje scrollkopplad transform till funktionsformen, som stannar i JavaScript och aldrig kompileras till WAAPI.
Den tomma bildrutan
Ett verkligt problem överlevde kraschfixen: Speed Index, som mäter hur snabbt en sida målar synligt innehåll snarare än hur länge den blockerar.
Vår bakgrund är en WebGL-shader. Tills den kompilerats och ritat sin första bildruta målade canvasen ingenting — så vyn stod platt, och sedan ändrades varje pixel på en gång. Speed Index straffar precis den formen. Vi gav canvasen en CSS-bakgrund härledd från shaderns egna färgkonstanter, så att första målningen redan är ungefär rätt och shadern tonar in i den, och vi kortade fördröjningarna på rubrikens entré.
Vad vi tar med oss
- Ett labbmått ger dig en siffra, inte en orsak. Total Blocking Time kan inte skilja dyrt arbete från en kraschloop — båda ser ut som en upptagen huvudtråd.
- Kontrollera att sidan renderades innan du optimerar hur snabbt den renderades. Renderad DOM och dokumenthöjd är två billiga kontroller som hade sparat oss två dagar.
- Deterministiskt arbete hör hemma vid byggtid. Om resultatet är identiskt på varje enhet är det ett val, inte ett krav, att beräkna det på användarens enhet.
- Verifiera handskriven matematik mot en referensimplementation. Vår stämde med d3-geo inom 1,5e-12 px, och därför kunde vi utesluta renderaren direkt.
- Måla något innan du målar rätt sak. En bakgrund som kommer sent kostar mer än en bakgrund som börjar ungefärlig.
Tröskelvärdena för Core Web Vitals bedöms vid 75:e percentilen av verkliga besök — LCP på högst 2,5 s, INP på högst 200 ms, CLS på högst 0,1. Labbverktyg är hur du hittar problem. Fältdata är hur du vet om du faktiskt hade ett.
