Skip to content

Vaynerov Technologies

Wir entwickeln nicht einfach — wir zaubern jede Codezeile und jeden Pixel.

Alle ArtikelDeep Dives

Mobile Safari schnell machen: zwei Bugs, ein Symptom

Der Bericht bestand aus einem halben Satz: dauert ewig, lädt manchmal gar nicht. Dahinter steckten zwei zusammenhanglose Bugs in einer einzigen Beschwerde — einer darin, wie Seiten ausgeliefert wurden, einer darin, was sie dem Telefon nach ihrer Ankunft antaten. Hier ist die ganze Kampagne, samt der Entscheidung, die wir dreiundzwanzig Tage nach ihr wieder umgedreht haben.

Vaynerov TechnologiesDas Studio
Veröffentlicht 12 Min. Lesezeit
Auf dieser Seite
  1. Jede Locale-Route war dynamisch
  2. Die Datenbank cachen, nicht die Seite
  3. Derselbe Hebel, dreiundzwanzig Tage später
  4. Neunzig Animationen, die niemand ansah
  5. Jedes iPhone ist ein Low-Tier-Gerät
  6. LazyMotion, im zweiten Anlauf
  7. Das letzte ULP

Jeder Performance-Bericht von einem echten Menschen kommt komprimiert an. Unserer sagte, die Seite brauche auf einem iPhone ewig und lade manchmal überhaupt nicht. Dieser Satz enthält keine Diagnose, und der schnellste Weg, eine Woche zu verbrennen, ist, ihn als einen Bug zu behandeln — also Animationen zu löschen, weil sich die Seite schwer anfühlt, oder Cache-Header hinzuzufügen, weil sich die Seite langsam anfühlt. Performance-Arbeit an Mobile Safari konvergiert erst, wenn man das Symptom zweiteilt: wie lange das Telefon auf Bytes wartet, und was das Telefon tut, sobald es sie hat.

Wir haben beides gemessen, in jeder Hälfte eine eigene Ursache gefunden und sie als zwei Commits am selben Tag behoben. Die Auslieferungshälfte war ein struktureller Fehler in unserem Next.js-Routenbaum, durch den jede lokalisierte Seite dynamisch gerendert wurde. Die Laufzeithälfte war Ansammlung — rund neunzig unendliche Animationen, eine WebGL-Bühne, die auf Hardware hochfuhr, die man nie hätte fragen dürfen, und der Animationskern von framer-motion im initialen Bundle der Startseite.

250–950 ms → 3–8 ms
TTFB der Startseite
1.12 MB → 468 KB
HTML der Startseite
7 → 345
vorgerenderte Routen
−91 KB
initiales JS der Startseite

Das Ergebnis an zwei Fronten, gemessen am 26.07.2026 über die Commits zu Auslieferung und Laufzeit.

Jede Locale-Route war dynamisch

Die Auslieferungszahl war hässlich, bevor das Netzwerk überhaupt ins Spiel kam. Die TTFB der Startseite lag bei 250–950 ms auf localhost, mit zehn bis zwanzig ungecachten Supabase-Abfragen pro Aufruf. Auf einem Laptop, über Loopback, mit warmer Datenbank. Was auch immer das Telefon erlebte, es war das plus alles, was ein Mobilfunk-Roundtrip draufschlägt.

Die Ursache waren nicht die Abfragen. Sie war, dass keine Seite statisch sein durfte. next-intl war nie auf statisches Rendern umgestellt worden, und — der Teil, auf den es wirklich ankam — unser Root-Layout ermittelte die aktive Locale per getLocale() aus dem Request-State. Ein Lesezugriff zur Request-Zeit in einem Layout kontaminiert jede Route darunter. Next.js schloss völlig korrekt, dass unter diesem Layout nichts vorgerendert werden kann, jede Locale-Route wurde also pro Request gerendert, mit no-store ausgeliefert und nie CDN-cachebar. Der CMS-Fächer lief dann bei jedem einzelnen Aufruf, weil es nichts gab, worin er hätte gecacht werden können.

Die Lösung war kein Cache-Header. Sie war, app/layout.tsx zu löschen.

Die neuere next/root-params-API von Next existiert für genau diese Art Problem und greift hier nicht: [locale] ist kein Root-Param, solange ein Layout darüber sitzt. Der einzige Weg, das Locale-Segment an die Spitze des Baums zu bekommen, ist, nichts darüber zu haben — was heißt, das eine gemeinsame Root-Layout vollständig aufzugeben. Also haben wir es getan. Es gibt im Repository kein app/layout.tsx mehr. An seiner Stelle stehen vier Root-Layouts, eines je Routenfamilie, die jeweils eine gemeinsame <RootShell> rendern, der das <html>- und <body>-Markup und alles Globale gehört: [locale] plus (admin), (admin-auth) und pitch, wobei die letzten drei locale="en" fest verdrahten, weil in einer Admin-Konsole nichts eine Übersetzungstabelle braucht. global-error.tsx behält sein eigenes <html>, wie es muss.

Vorher: Ein Root-Layout liest die Locale aus dem Request-State, und der ganze Baum darunter wird dynamisch. Nachher: Vier Root-Layouts teilen sich eine Komponente statt eines Elternteils, und der Locale-Baum ist statisch bzw. ISR.

Die andere Hälfte der Umstellung geschieht pro Seite: setRequestLocale muss in jeder Server-Komponente aufgerufen werden, die eine lokalisierte Seite rendert, und wir rufen es inzwischen in 34 Dateien auf. Das ist eine wirklich schlechte Invariante — sie ist unsichtbar, sie schweigt bei Verletzung, und eine Seite, die sie vergisst, geht nicht kaputt, sondern wird nur still wieder dynamisch und nimmt ihre TTFB mit. Wir haben die Regel in die Dateikommentare geschrieben, aus denen eine neue Seite am ehesten kopiert wird, und wir schreiben sie auch hier hin: Neue Seiten müssen setRequestLocale wiederholen.

Eine Route blieb mit Absicht dynamisch. Die interaktive Roadmap liest useSearchParams ohne Suspense-Grenze, was sie ans dynamische Rendern nagelt; sie ist ohnehin eine noindex-Fläche, und es zu erzwingen hätte bedeutet, eine Seite umzubauen, über die sich niemand beschwert hat. Sie ausdrücklich festzunageln war besser, als es dem Zufall zu überlassen.

Die Datenbank cachen, nicht die Seite

Vorrendern repariert den Request-Pfad, aber unsere Inhalte sind redaktionell pflegbar: Abschnittstexte, Seiten-Metadaten und Zeileninhalte kommen alle über eine kleine CMS-Schicht aus Supabase. Eine Build-Matrix über sechs Locales, die pro Abfrage und Seite einmal die Datenbank trifft, würde einen langsamen Request gegen einen langsamen Build tauschen. Also wanderten die drei Lesepfade hinter unstable_cachegetSectionRow unter ["cms-section"], getRowsForLocale unter ["cms-rows"], getPageMetaRow unter ["cms-page-meta"], alle mit { revalidate: 300, tags: ["cms-content"] }. Ein Seitenrender und der komplette Build über sechs Locales treffen Supabase jetzt einmal pro eindeutiger Abfrage und fünf Minuten statt einmal pro Request.

Ein Detail in diesem Satz ist tragend und geht bei einem Refactor leicht verloren: Das CMS benutzt einen cookie-freien, öffentlichen Supabase-Client. cookies() innerhalb von unstable_cache aufzurufen wirft ab Next 15, ein Client, der die Session liest, lässt sich also überhaupt nicht cachen. Wir wissen das, weil derselbe Fehler Monate zuvor bereits still unsere datenbankgestützten Übersetzungs-Overrides erledigt hatte, hinter einem nackten catch. Redakteurinnen und Redakteure warten die fünf Minuten übrigens nicht ab — Schreibaktionen im Admin koppeln revalidatePath mit revalidateTag("cms-content"), und die i18n-Overrides tragen ihr eigenes Tag "i18n-overrides".

Die Auslieferungszahlen bewegten sich als Block. Der Build ging von 7 vorgerenderten Routen auf 345, verteilt auf 497 statische Seiten. Die Startseite liefert mit x-nextjs-cache: HIT und einer TTFB im Bereich 3–8 ms aus — CDN-Werte, weil sie jetzt ein CDN-Artefakt ist. Das HTML selbst fiel von 1,12 MB auf 468 KB, wenn auch nicht ausschließlich wegen des Vorrenderns, womit wir bei der Entscheidung wären, die wir umgedreht haben.

Derselbe Hebel, dreiundzwanzig Tage später

Am 3. Juli haben wir Nexts inlineCss eingeschaltet. Die Begründung war stichhaltig und die Belege waren echt: Unser Stylesheet war 38,6 KB groß, es war der letzte render-blockierende Request der Seite, und es kostete rund 530 ms mobiles FCP. Inlining nahm einen Roundtrip vom kritischen Pfad. Wir haben in derselben Änderung browserslist enger gezogen — Chrome, Edge und Firefox ab 111, Safari und iOS ab 16.4 —, denn eine moderne Basis ist genau das, was ein kleines Stylesheet klein macht.

Am 26. Juli haben wir es wieder ausgeschaltet. Zwei Dinge hatten sich geändert. Das Stylesheet war auf rund 320 KB roh gewachsen (etwa 40 KB gzip-komprimiert); und inline eingebettetes CSS reist im HTML jeder Navigation mit, ohne dass der Browser es zwischen Seiten cachen könnte. Solange jede Seite ein dynamischer Render war, war das ein fairer Tausch — das HTML war ohnehin nicht cachebar, das CSS verlor durch die Mitfahrt also nichts. Sobald die Routen vorgerendert und CDN-gecacht waren, drehte sich die Rechnung um: Ein cachebares Stylesheet, einmal geholt, schlägt 320 KB, die in jedem Dokument neu ausgeliefert werden.

Derselbe Hebel, der gegenteilige Schluss, dreiundzwanzig Tage auseinander — und beide Entscheidungen waren in ihrem Kontext richtig.

Wir behalten dieses Paar im Kopf, wenn uns jemand eine Performance-Regel zitiert. inlineCss ist weder gut noch schlecht. Es ist eine Wette auf das Verhältnis zwischen der Größe Ihres Stylesheets und der Cachebarkeit Ihres HTML — und dieses Verhältnis haben wir selbst verändert.

Neunzig Animationen, die niemand ansah

Die Laufzeithälfte war nicht ein Fehler; sie waren drei Monate kleiner Fehler. Unendliche CSS-Animationen, seitenweite Blend-Schichten, eine WebGL-Bühne und der eifrige Kern von framer-motion im initialen Bundle — jedes für sich vertretbar, als es landete, zusammen ein Telefonschmelzer. Die klarste Messung: Wenn Sie zum Schluss-CTA der Startseite gescrollt sind, laufen rund neunzig unendliche Animationen gleichzeitig. Dem Compositor ist es egal, dass achtzig davon weit außerhalb des Bildes liegen.

Der Riegel ist mit Absicht dumm. SectionViewportGate ist eine Client-Komponente, die jedes #main-content > section mit 160 px Root-Margin beobachtet und ein Attribut data-offscreen umschaltet; den Rest erledigt CSS mit animation-play-state. Die Attributwechsel passieren bewusst außerhalb von React — neunzig Observer-Callbacks durch State-Updates zu leiten hätte mehr gekostet als die Animationen, die wir anhalten wollten.

Daneben haben wir zu content-visibility: auto gegriffen, womit der Browser Layout und Paint für Abschnitte überspringen darf, zu denen er nicht gescrollt hat. Diese Änderung ging mit einem Bug live, der das Nacherzählen wert ist.

/* nth-of-type, not nth-child. Three JSON-LD <script> children
   precede the sections inside <main>. */
section:nth-of-type(n + 3) {
  content-visibility: auto;
  contain-intrinsic-size: auto 800px;
}

Eine Pseudoklasse, eine ganze Klasse sehr verwirrender CLS. Der Hero darf auf diese Regel niemals passen.

Als nth-child(n+3) geschrieben, zählt der Selektor alle Kinder von <main> — und drei JSON-LD-<script>-Tags kommen zuerst. Der Versatz verschob sich um drei, und die Regel landete auf jedem Abschnitt, den Hero eingeschlossen. content-visibility impliziert contain: size, der 100svh hohe Hero wurde also zuerst auf seiner Platzhalterhöhe von 800 px ausgelegt und korrigierte sich einen Moment später: ein Reflow beim Laden und ein Layout-Shift auf dem allerersten Bildschirm, erzeugt von einer Änderung, deren ganzer Zweck es war, den ersten Bildschirm billiger zu machen. nth-of-type zählt nur <section>-Elemente. Ein Wort — und der Hero ist wieder außen vor.

Jedes iPhone ist ein Low-Tier-Gerät

Unsere WebGL-Flächen sitzen hinter einer Erkennung der Qualitätsstufe. detectTier liest vier Signale — pointer: coarse, hardwareConcurrency ?? 4, deviceMemory ?? 4 und die kleinere Bildschirmkante. Ein Gerät gilt als Telefon, wenn es grob zeigt und seine kleinste Kante unter 700 px liegt, und ein Telefon steigt nur mit mindestens sechs Kernen und mindestens sechs gemeldeten Gigabyte Speicher in die mittlere Stufe auf.

StufeWie ein Gerät dorthin kommtDPR-Deckel
lowJedes Telefon, das den Aufstieg mit 6 Kernen / 6 GB verfehlt — also jedes iPhone1,25
mediumDesktops standardmäßig, aufgestiegene Telefone und jeder Server-Render1,5
highFeiner Zeiger mit Kernen und Speicher im Rücken2

Die Stufentabelle — und der Grund, warum der SSR-Standard medium ist: Der Server kann es nicht wissen, und medium ist die ehrliche Schätzung.

Safari legt deviceMemory überhaupt nicht offen. Der Standardwert ?? 4 verfehlt die ≥6-Hürde daher bedingungslos, und jedes iPhone landet in der niedrigen Stufe. Wir haben eine Stunde damit verbracht, das für einen Erkennungsfehler zu halten, bevor wir schlossen, dass es die richtige Antwort ist, nur versehentlich erreicht: iOS Safari hat die niedrigste Obergrenze für WebGL-Kontexte aller Browser, die wir unterstützen, und ist am ehesten bereit, einen Tab unter Speicherdruck zu killen. Die Bühne kehrt jetzt vor dem Mounten zurück, wenn tier === "low" gilt, was bedeutet, dass ihr Chunk nicht einmal geholt wird. Der billigste WebGL-Kontext ist der, den man gar nicht erst erzeugt.

Der Rest der Laufzeit-Diät ist eine Liste kleiner Amputationen, die jeweils Arbeit entfernen, von der Touch-Geräte von vornherein nichts hatten:

  • Das Codefeld des Hero rendert auf Touch statisch, komplett ohne Frame-Schleife. Es gibt keinen Cursor zu jagen, und die wandernde Linse kostete auf dem ersten Bildschirm des Telefons ein Canvas-Neuzeichnen über den ganzen Viewport mit 60 fps. Sein Resize-Pfad überspringt außerdem den fillText-Neuaufbau mit rund 2600 Aufrufen, denn unter iOS zählt das Einklappen der URL-Leiste als Resize.
  • Das Code-Linsen-Overlay ist auf pointer: fine beschränkt. Es ist ein fixiertes Canvas mit mix-blend-mode — es belastete auf Telefonen jeden Scroll-Frame, um einen Effekt zu erzeugen, den Touch gar nicht auslösen kann.
  • Die Orbs der Referenzarbeiten tauschten filter: blur(28px) gegen gebackene radiale Verläufe mit drei Stopps. Sie animieren ihre Skalierung, der Gauß lief also in jedem Frame neu; ein Verlauf, der genauso aussieht, kostet beim Skalieren nichts. will-change gilt jetzt nur noch für Zeiger mit Hover-Fähigkeit, und Touch flacht preserve-3d ab.
  • Die Videos wurden auf 960×540 neu kodiert, was sie von 11,2 MB auf 5,1 MB brachte. Sie wurden in *-2.mp4 umbenannt, um unseren Dateinamensvertrag für unveränderliche Caches einzuhalten — die Assets gehen mit max-age=31536000, immutable raus, eine geänderte Datei braucht also einen geänderten Namen, und im Gegenzug sparen wiederkehrende Besuche rund 100 MB.
Auslieferung, vorher und nachher. Die TTFB-Balken sind in beiden Fällen die Messung auf localhost — die Werte des Telefons waren auf beiden Seiten der Änderung schlechter.

LazyMotion, im zweiten Anlauf

Der langwierigste Posten der Kampagne begann mit einem Bundle-Report, der 63 KB ungenutztes JavaScript anprangerte. Wir sind losgezogen und fanden, dass der Chunk react-dom selbst war, und das trimmt man nicht. Das eigentliche Fett war framer-motion: motion irgendwo zu importieren zieht den Animationskern eifrig herein, und unsere Startseite importierte es in den meisten ihrer Abschnitte.

Der Anlauf vom 3. Juli wickelte jeden Abschnitt in ein eigenes LazyMotion mit aktiviertem strict und brachte die Startseite von 284 KB auf 269 KB Transfer. Der Strict-Modus ist es, der die Einsparung echt macht — er verbietet die einfachen motion.*-Komponenten, ein einzelner verirrter Import kann den Kern also nicht klammheimlich zurückschleppen. Er hat auch Zähne: Wir mussten framers imperatives animate() in unserem Zählzahl-Hook killen und als kleines easeOutQuart per requestAnimationFrame neu schreiben, weil dieser eine Import reichte, um den Animationskern zurück auf den LCP-kritischen Graphen zu setzen. Eine Schleifenvariable namens m in der Gravitas-Orbit-Komponente musste umbenannt werden, weil sie framers m-Namensraum verdeckte.

Dann wurde es zurückgenommen. Das Revert ist ein sauberes, vollständiges Revert mit automatisch erzeugtem Commit-Text und ohne genannten Grund, was heißt: Das Ehrlichste, was wir dazu sagen können, ist, dass wir nicht wissen, was kaputtging. Wir haben Meinungen; wir haben keine Belege. Diese Lücke ist ihre eigene Lektion über Commit-Hygiene, und sie ist der Grund, warum der zweite Anlauf eine andere Form bekam.

// One shared provider. The feature bundle is loaded asynchronously,
// so it streams in after first paint instead of blocking it.
const loadDomAnimation = () =>
  import("framer-motion").then((m) => m.domAnimation);

<LazyMotion features={loadDomAnimation} strict>

Die neu gelandete Form: ein einziger MotionProvider mit asynchronem Feature-Loader, und der Strict-Modus hält jeden Verbraucher ehrlich.

Die Fassung vom 26. Juli ersetzte die Wrapper pro Abschnitt durch einen gemeinsamen MotionProvider, und sie ging erst hinein, nachdem jeder Verbraucher auf der Startseiten-Route von Hand geprüft worden war. Es sind drei: das Games-Showcase, die Lab-Leiste und der Wrapper für die Kartenneigung. Die Hausregel steht jetzt schriftlich fest — Startseiten-Abschnitte benutzen m.* unter dem Provider, niemals motion.* —, und der Strict-Modus setzt sie zur Laufzeit durch statt erst im Code-Review.

Eine Falle verdient eine Nennung, weil sie im Review unsichtbar ist. CardTilt behält einen konstanten Elementtyp: Es riegelt sein Verhalten auf Stil-Ebene ab und tauscht nie ein div gegen ein m.div. Den Elementtyp auf dem Client zu tauschen würde den ganzen Teilbaum nach der Hydration neu mounten — bei einer Karte, die eine Canvas-Bühne enthält, heißt das: genau das abreißen und neu bauen, was man billiger machen wollte. Über Stile abzuriegeln ist hässlicher und richtig.

Das initiale JavaScript der Startseite landete bei 1365 KB roh, von 1456 KB kommend, und kein einziger Chunk trägt noch den Motion-Kern.

Das letzte ULP

Das seltsamste Fundstück der Kampagne hatte mit Geschwindigkeit nichts zu tun. Eines unserer Spiele-Poster — das von Riftline — berechnet seine SVG-Koordinaten aus Trigonometrie, und React 19 vergleicht bei der Hydration serverseitig gerenderte Attribute mit clientseitig gerenderten. Nodes libm und Chromiums libm sind sich in der letzten Einheit an letzter Stelle uneinig. Die beiden Zeichenketten unterscheiden sich in einer Ziffer, die nie ein Mensch sehen wird, React merkt es, und man bekommt eine Hydration-Warnung auf einem statischen Bild. Die Lösung ist, die Koordinaten zu quantisieren, bevor sie Attribute werden — was eine andere Art ist zu sagen: Wenn sich zwei Implementierungen auf eine Zahl einigen müssen, verlangen Sie nicht, dass sie sich auf die ganze Zahl einigen.

Beide Commits gingen mit grünem tsc, grünem eslint und grünem Production-Build live, und alle 27 bestehenden End-to-End-Tests liefen durch. Diese letzte Prüfung wiegt schwerer, als sie klingt: Fast jede Änderung hier entfernt Arbeit, und Arbeit zu entfernen ist der einfachste Weg, versehentlich ein Feature zu entfernen.

Wem dieselbe Kampagne bevorsteht, dem würden wir sagen: Die Zweiteilung ist die ganze Methode. Auslieferungsfehler und Laufzeitfehler erzeugen identische Beschwerden und teilen sich fast keine Lösungen; die Auslieferungshälfte war hier eine strukturelle Entscheidung darüber, wo im Routenbaum die Locale wohnt, und die Laufzeithälfte waren neunzig kleine. Keine von beiden hätte man gefunden, indem man auf die andere starrt.

Die Invariante, mit der wir jetzt leben, ist die, die uns am wenigsten gefällt. setRequestLocale muss auf jeder lokalisierten Seite stehen, und nichts schlägt fehl, wenn es fehlt — die Seite hört einfach auf, statisch zu sein, und der ganze Auslieferungsgewinn sickert still weg, eine Route nach der anderen. Wer sehen will, wofür diese Zahlen bezahlen: Das Lab ist das Schwerste, was wir ausliefern, und der beste Test dafür, ob die Diät gehalten hat. Der Umbau der Startseite erklärt, warum es überhaupt so viel zu rendern gibt, und sechs Sprachen in einer Codebasis erklärt, warum das Locale-Segment dort saß, wo es saß.