Zum Inhalt springen
Alle Artikel
6 Min. Lesezeit

Warum mein Portfolio geruckelt hat – und wie ich es gemessen habe

Ein Punktfeld, ein Invert-Cursor und ein paar Endlos-Animationen – wie ich die Leerlauf-Last meiner Seite an den meisten Stellen um über 90 % gesenkt habe, ohne einen einzigen Effekt zu verlieren.

#performance#css#react

Nach dem Relaunch dieser Seite kam das Feedback, vor dem sich jeder fürchtet, der viele Animationen einbaut: „Es laggt.“ Dazu noch ein zweites, rätselhafteres: „Die Seite springt manchmal von Hell auf Dunkel.“

Auf meinem Rechner lief alles flüssig. Genau das ist das Problem mit Performance: Man sieht sie nicht, wenn man auf schneller Hardware entwickelt. Also habe ich aufgehört zu raten und angefangen zu messen.

Erst messen, dann ändern

Chrome bringt über das DevTools-Protokoll (CDP) Zähler mit, die man auch automatisiert auslesen kann. Mit Puppeteer lade ich die Seite, lasse sie drei Sekunden in Ruhe – keine Maus, kein Scrollen – und vergleiche die Zähler davor und danach:

js
const cdp = await page.createCDPSession()
await cdp.send("Performance.enable")
const metrics = async () =>
  Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map((m) => [m.name, m.value]))

const a = await metrics()
await new Promise((r) => setTimeout(r, 3000))
const b = await metrics()

console.log({
  taskMs: (b.TaskDuration - a.TaskDuration) * 1000,
  styleRecalcs: b.RecalcStyleCount - a.RecalcStyleCount,
  layouts: b.LayoutCount - a.LayoutCount,
})

Eine Seite, auf der nichts passiert, sollte hier fast nur Nullen liefern. Meine lieferte 1.162 ms Arbeit und rund 200 Style-Neuberechnungen in drei Sekunden – also in jedem einzelnen Frame. Die Seite war im Leerlauf permanent beschäftigt.

Ein Performance-Trace zeigte, wer da arbeitet: 240 Animation-Frame-Callbacks in zwei Sekunden, die meisten aus zwei Quellen.

Täter 1: Schleifen, die nie schlafen

Das Punktfeld im Hero. Ein Canvas mit über tausend Punkten, die dem Cursor ausweichen und in einer langsamen Welle atmen. Damit die Welle läuft, wurde gezeichnet – auch wenn niemand die Maus bewegt hat. Jetzt animiert das Feld nur noch, solange sich die Maus bewegt, lässt die Welle dann weich auslaufen und beendet die Schleife komplett. Der Trick dabei war ein Detail: Punkte in Cursornähe sind dauerhaft verschoben. „Ist noch etwas verschoben?“ war deshalb die falsche Frage – richtig ist „Bewegt sich noch etwas?“.

Die Laufbänder. Framer Motions useAnimationFrame hängt sich in die globale Animationsschleife und läuft für immer, auch wenn das Band gar nicht zu sehen ist. Ersetzt durch eine eigene Schleife, die nur läuft, solange das Band im Bild ist, und direkt ein transform schreibt.

Allein das senkte die JavaScript-Zeit im Leerlauf von rund 150 ms auf gut 20 ms pro drei Sekunden.

Täter 2: mix-blend-mode

Der Cursor war ein kleiner Kreis, der alles darunter invertiert – mix-blend-mode: difference. Sieht großartig aus, ist aber teuer: Der Browser muss bei jeder Mausbewegung die Ebene mit der kompletten Seite darunter verrechnen. Auf einer starken Grafikkarte merkt man das nicht, auf einem Laptop mit Onboard-Grafik schon.

Der Cursor ist jetzt ein feiner Ring, der nur transform und opacity ändert – beides erledigt die GPU, ohne dass irgendetwas neu gezeichnet werden muss.

Täter 3: Endlos-Animationen außerhalb des Bildschirms

Trotzdem blieb es dabei: Style-Neuberechnung in jedem Frame, an jeder Stelle der Seite – und zwar ohne JavaScript-Stack. Per Ausschlussverfahren (Abschnitte nacheinander aus dem DOM entfernen und neu messen) war klar: Es sind die kleinen CSS-Endlosanimationen. Pulsierende Status-Punkte, rotierende Ringe, ein blinkender Cursor, das Commit-Laufband.

Was ich nicht wusste: Chrome rechnet laufende CSS-Animationen auch dann jeden Frame neu, wenn sie gar nicht sichtbar sind. Ein pulsierender Punkt ganz oben im Hero kostet also auch dann noch, wenn man längst beim Kontaktformular ist.

Die Lösung ist erfreulich klein. Ein IntersectionObserver markiert jeden Abschnitt, der nicht im Bild ist:

ts
const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) entry.target.toggleAttribute("data-paused", !entry.isIntersecting)
  },
  { rootMargin: "200px 0px" },
)
document.querySelectorAll("main > section").forEach((section) => observer.observe(section))

Und eine einzige CSS-Regel hält dort alles an:

css
[data-paused] [class*="animate-"],
[data-paused] .ticker-track {
  animation-play-state: paused !important;
}

Täter 4: Eigenschaften, die Layout kosten

Zwei Animationen liefen zwar nur im sichtbaren Bereich, waren aber trotzdem teuer, weil sie Eigenschaften animiert haben, die die GPU nicht übernehmen kann:

  • Der kleine „Scroll“-Hinweis im Hero animierte transform-origin. Damit fällt die ganze Animation zurück auf den Hauptthread. Jetzt gleitet die Linie per translateY durch eine Maske – gleicher Effekt, null Layout.
  • Der Spotify-Fortschrittsbalken wuchs per width-Transition. Jede Breitenänderung heißt: Layout neu berechnen, in jedem Frame, solange Musik läuft. Jetzt ist der Balken immer voll breit und wird per translateX hineingeschoben.

Und ein React-Klassiker: Die Spotify-Karte nutzte Framer Motions layout-Animation und renderte jede Sekunde neu, weil die Zeitanzeige tickt. layout misst bei jedem Render das Layout aus. Die tickenden Teile sind jetzt eigene kleine Komponenten – die Karte selbst rendert nicht mehr ständig neu.

Der Hell-Dunkel-Bug

Bleibt das Rätsel mit dem Theme. Die Ursache war ein Effekt, den ich bewusst eingebaut hatte: Beim Kontaktbereich kippte die ganze Seite ins Negativ. Wer schnell scrollte oder weiter unten neu lud, sah plötzlich ein dunkles statt helles Design – und hielt es zu Recht für einen Fehler. Dazu kam, dass für den weichen Übergang kurzzeitig jedes Element eine Farb-Transition bekam.

Der Effekt ist rausgeflogen. Manchmal ist die beste Optimierung, etwas wegzulassen.

Das Ergebnis

Gemessen im Leerlauf, jeweils drei Sekunden an verschiedenen Stellen der Seite (Zwischenstand nach den Schleifen-Fixes → Endstand):

PositionArbeit vorherArbeit nachherStyle-Neuberechnungen
Hero1.067 ms550 ms187 → 82
Über mich1.126 ms104 ms187 → 24
Projekte909 ms160 ms187 → 42
Skills1.323 ms42 ms187 → 0
Werdegang1.423 ms79 ms187 → 11
GitHub & Live1.416 ms88 ms187 → 15
Kontakt1.444 ms43 ms187 → 0

Im Hero laufen bewusst noch sichtbare Effekte (Wortwechsel, Status-Punkt, Scroll-Hinweis) – das ist gewollt. Bleibt die Maus dort still liegen, kommt nach ein paar Sekunden auch der Hero auf rund 30 ms pro Sekunde. Gemessen wurde übrigens in Headless-Chrome ohne Grafikkarte; die absoluten Zahlen sind dadurch eher pessimistisch, entscheidend ist das Verhältnis.

Automatisch leichter, wenn es trotzdem ruckelt

Kein Gerät ist wie das andere. Deshalb misst die Seite beim ersten Scrollen selbst die Bildrate: Liegt der Median über 22 ms pro Frame – also unter etwa 45 fps –, schaltet sie für den Rest der Sitzung in einen Lite-Modus ohne Unschärfe, Lichtstrahl und Cursor-Ring. Mit sechsfach gedrosselter CPU getestet: Lite greift. Auf normaler Hardware bleibt alles an.

Wer neugierig ist: Im Terminal (~) zeigt perf den aktuellen Modus, und perf lite bzw. perf full schalten ihn um.

Was ich mitnehme

  • Messen statt raten. Drei Zähler aus CDP haben mehr verraten als jedes Bauchgefühl.
  • Leerlauf ist der wichtigste Test. Eine ruhige Seite sollte nichts tun.
  • Nur transform und opacity animieren. Alles andere kostet Layout oder Paint.
  • Unsichtbares anhalten. Was nicht im Bild ist, muss nicht laufen.
  • Effekte haben einen Preis. Wenn einer mehr kostet, als er bringt, fliegt er raus.

Weitere Artikel