Frontend-Performance-Tricks, die wir vergessen haben
Der schnellste Weg, ein modernes Frontend zu verlangsamen, besteht darin, anzunehmen, dass das Framework die Performance für einen übernimmt. Die meisten Low-Level-Techniken, die vor einem Jahrzehnt schnelle Websites auszeichneten – explizite Bildabmessungen, verzögerte Drittanbieter-Skripte, font-display-Hinweise, manuelle Preconnects – sind nach wie vor relevant. Doch das Framework sitzt nun zwischen Entwickler und Plattform, und die Abstraktion weist Lücken auf, die ein Lighthouse-Audit bereitwillig aufzeigt. Im Jahr 2019 schrieb ButterCMS, dass „Browser bald natives Lazy Loading unterstützen werden.” Diese Zukunft ist eingetroffen, hat sich als Standard etabliert und ist dann still und leise zu etwas geworden, das wir aufgehört haben zu überprüfen. Dieser Artikel beleuchtet die Frontend-Performance-Grundlagen, die wir delegiert haben, sowie die Fehlerbilder im Produktivbetrieb, die auftreten, wenn diese Delegation versagt.
Wichtigste Erkenntnisse
- Natives
loading="lazy"wird seit Safari 15.4 (März 2022) von allen gängigen Browsern unterstützt – jeder Intersection Observer Wrapper, der vor diesem Datum geschrieben wurde, ist toter Code, der das Bundle unnötig aufbläht. - Google Fonts kann Schriften mit
font-display: swapausliefern, aber benutzerdefinierte@font-face-Blöcke im eigenen CSS erben dieses Verhalten nicht – jeder einzelne ist bei langsamen Verbindungen ein potenzieller Flash of Invisible Text. setTimeout(fn, 0)wird im nächsten Task ausgeführt und kann mitten in einer Benutzerinteraktion landen;requestIdleCallbackwartet auf einen echten Leerlaufzeitraum und ist damit das richtige Primitive für nicht dringende Aufgaben.- Verwende
deferfür Skripte, die das DOM berühren, undasyncnur für wirklich unabhängige Skripte, daasync-Skripte in der Reihenfolge des Netzwerkeingangs ausgeführt werden, nicht in der Dokumentreihenfolge. - Seit INP im März 2024 FID als Core Web Vital abgelöst hat, sind ungedrosselte Scroll- und Resize-Handler, die den Main Thread blockieren, nun ein Rankingsignal – nicht nur ein Problem der Darstellungsflüssigkeit.
Explizite width/height-Attribute bei Bildern verhindern nach wie vor Layout-Verschiebungen
In React-Anwendungen, bei denen Bildabmessungen zur Laufzeit aus API-Antworten stammen, hat der Browser keinen reservierten Platz, den er zuweisen könnte – daher ist jedes Bild, das nach dem First Paint geladen wird, eine potenzielle Layout-Verschiebung, unabhängig davon, ob die Image-Komponente des Frameworks statische Assets korrekt behandelt. Cumulative Layout Shift ist eines der Core Web Vitals, die von Googles web.dev-CLS-Dokumentation definiert werden, und das sichtbare Fehlerbild ist konkret: Die Seite springt, wenn ein Bild geladen wird, und der Tap des Nutzers landet auf dem falschen Button.
Die Abstraktion, die hier versagt, ist die Image-Komponente des Frameworks. Next.js <Image> reserviert Platz, wenn width und height übergeben werden, tut aber nichts für rohe <img>-Tags in MDX-Inhalten, CMS-gerendertem HTML oder anderem Markup, das die Komponente nie verarbeitet. Moderne Browser berechnen ein implizites aspect-ratio aus den width- und height-Attributen (MDN dokumentiert dieses Verhalten), sodass die Abmessungen Platz reservieren, selbst wenn CSS die gerenderte Größe überschreibt.
// Vorher: Abmessungen kommen zur Laufzeit, kein Platz wird reserviert
<img src={product.imageUrl} alt={product.name} />
// Nachher: Attribute setzen eine Aspect-Ratio-Box, bevor das Bild lädt
<img
src={product.imageUrl}
alt={product.name}
width={product.width}
height={product.height}
style={{ width: '100%', height: 'auto' }}
/>
Wenn die API keine Abmessungen liefert, setze stattdessen ein aspect-ratio auf den Container. In jedem Fall existiert der Platz, bevor die Bytes eintreffen.
Discover how at OpenReplay.com.
font-display: swap und Preconnect für benutzerdefinierte Schriften
Jeder benutzerdefinierte @font-face-Block ohne font-display: swap ist bei langsamen Verbindungen ein potenzieller Flash of Invisible Text – FOIT –, bei dem ein Absatz für die gesamte Dauer des Font-Abrufs leer bleibt. Der font-display-Deskriptor steuert dies direkt: swap rendert sofort Fallback-Text und tauscht ihn gegen die benutzerdefinierte Schrift aus, sobald diese geladen ist – das ergibt einen Flash of Unstyled Text (FOUT) anstelle von FOIT, wie in der MDN-Referenz zu font-display beschrieben.
Die Lücke entsteht durch Delegation. Google Fonts kann font-display: swap in das ausgelieferte CSS einbetten, wenn die Stylesheet-URL den entsprechenden display-Parameter enthält – Teams, die das gehostete Stylesheet verwenden, denken daher nie darüber nach. Und dann schreiben sie eigene @font-face-Blöcke für Marken-Schriften, die dieses Verhalten nicht erben. Eine selbst gehostete Schrift ohne diesen Deskriptor liefert FOIT an jeden Besucher mit leerem Cache.
Beim Selbst-Hosten entfällt außerdem der Preconnect, den das Google-Stylesheet implizit nahegelegt hatte. Die web.dev-Empfehlungen zu frühen Netzwerkverbindungen empfehlen, eine Preconnect-Verbindung zum Font-Origin herzustellen, damit DNS-, TCP- und TLS-Handshakes abgeschlossen sind, bevor die Font-URL im CSS entdeckt wird.
@font-face {
font-family: "BrandSans";
src: url("/fonts/brand-sans.woff2") format("woff2");
font-display: swap; /* Fallback-Text wird sofort angezeigt, kein FOIT */
}
<link rel="preconnect" href="https://fonts.cdn.example.com" crossorigin />
Prüfe jeden @font-face-Block, den du manuell geschrieben hast. Die Gewohnheit, gehostetes CSS zu verwenden, verbirgt die Blöcke, die korrigiert werden müssen.
preconnect und dns-prefetch für Drittanbieter-Origins
Bundler und Frameworks übernehmen Preconnects für ihre eigenen CDN-Origins, aber Drittanbieter-Analytics-Endpunkte, Bild-CDNs und A/B-Testing-Dienste sind für den Build-Schritt unsichtbar – ihre DNS-Lookups erfolgen erst zum Anfragezeitpunkt, sofern du nicht manuell <link rel="preconnect"> hinzufügst. ButterCMS beschrieb den Mechanismus 2019 präzise: Preconnect weist den Browser an, DNS-Lookup, initiale Verbindung und TLS-Aushandlung „so früh wie möglich abzuschließen, anstatt erst dann, wenn das Script-Tag entdeckt wird.”
Die Kosten für DNS- und TLS-Handshakes sind nicht verschwunden; das Framework erinnert einen nur nicht mehr daran. Ein Segment-Endpunkt, ein Cloudinary-Origin oder ein Drittanbieter-Tag-Manager erfordert jeweils einen neuen Verbindungsaufbau, der die dahinterliegende Ressource blockiert. Verwende preconnect für Origins, die du mit Sicherheit früh ansprechen wirst, und dns-prefetch als leichtgewichtigeren Hinweis für Origins, die du möglicherweise ansprechen wirst – denn preconnect öffnet eine vollständige Verbindung, für die du bezahlst, unabhängig davon, ob sie genutzt wird. web.dev erläutert den Kompromiss zwischen beiden Hinweisen.
<!-- Kritischer Drittanbieter-Origin: vollständige Verbindung jetzt öffnen -->
<link rel="preconnect" href="https://cdn.imagecdn.example" crossorigin />
<!-- Wahrscheinlicher, aber nicht sicherer Origin: nur DNS auflösen -->
<link rel="dns-prefetch" href="https://analytics.example.com" />
Platziere diese Hinweise weit oben im <head>, noch vor den Skripten und Stylesheets, die die Anfragen auslösen.
Natives loading="lazy" hat deinen Intersection Observer Wrapper ersetzt
Natives loading="lazy" wird seit Safari 15.4 im März 2022 von allen gängigen Browsern unterstützt – jeder Intersection Observer Wrapper, der vor diesem Datum geschrieben wurde, ist nun toter Code, der Bundle-Gewicht und Wartungsaufwand erzeugt. Chrome hat es in Version 77 (August 2019) und Firefox in Version 75 (April 2020) eingeführt, wie die Browser-Kompatibilitätstabelle in der MDN-Referenz zum img-Element zeigt.
Die Lücke hier ist historischer, nicht framework-spezifischer Natur. Codebases haben in den Jahren vor dem Baseline-Status des Attributs useLazyImage-Hooks und <LazyImage>-Komponenten angesammelt, und diese Komponenten werden noch immer ausgeliefert – sie führen pro Bild einen Observer aus, halten Refs und rendern bei Intersection neu –, um das zu tun, was der Browser heute nativ und außerhalb des Main Threads erledigt. Das gleiche Attribut funktioniert auch für Iframes, was für eingebettete Karten und Video-Player unterhalb des sichtbaren Bereichs relevant ist.
// Vorher: ein manuell erstellter Observer, den die Plattform überflüssig gemacht hat
function LazyImage({ src, alt }) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) setVisible(true);
});
io.observe(ref.current);
return () => io.disconnect();
}, []);
return <img ref={ref} src={visible ? src : undefined} alt={alt} />;
}
// Nachher: der Browser übernimmt es, außerhalb des Main Threads
<img src={src} alt={alt} loading="lazy" width={800} height={600} />;
Behalte die expliziten Abmessungen bei – lazy-geladene Bilder verursachen Layout-Verschiebungen genauso leicht wie eager-geladene.
defer vs. async bei Drittanbieter-Skripten
Die praktische Entscheidungsregel: Verwende defer für jedes Skript, das das DOM liest oder schreibt, und async nur für Skripte, die wirklich eigenständig sind – denn async-Skripte werden in der Reihenfolge des Netzwerkeingangs ausgeführt, nicht in der Dokumentreihenfolge, und zwei async-Skripte mit einer Abhängigkeit zwischen ihnen können in einem Race Condition enden. Der HTML Living Standard zur Definition des Script-Elements legt fest, dass defer-Skripte nach Abschluss des Parsings in Dokumentreihenfolge ausgeführt werden, während async-Skripte sofort nach dem Abruf ausgeführt werden.
Die Lücke ist sozialer, nicht technischer Natur: Jemand fügt einen Vendor-Analytics-Snippet in <head> ein, genau so wie es die Copy-Paste-Anleitung des Anbieters zeigt, ohne Attribut – und ein einfaches <script> blockiert das Parsing, bis es heruntergeladen und ausgeführt wurde. Das sichtbare Fehlerbild ist Interaktionsverzögerung. Wenn Drittanbieter-Skripte die Interaktion blockieren, zeigen Session-Replays wiederholte Taps auf dasselbe Element – ein klassisches Rage-Click-Muster.
| Attribut | Ausführungszeitpunkt | Reihenfolgegarantie | Verwendung für |
|---|---|---|---|
| keines | Blockiert den Parser sofort | Dokumentreihenfolge | Fast nie |
async | Sobald abgerufen | Netzwerkeingangsreihenfolge | Unabhängige Analytics |
defer | Nach Abschluss des Parsings | Dokumentreihenfolge | Alles, das das DOM berührt |
<!-- Vorher: blockiert den Parser, verzögert First Paint und Interaktion -->
<script src="https://vendor.example/analytics.js"></script>
<!-- Nachher: unabhängiges Skript, blockiert den Parser nie -->
<script src="https://vendor.example/analytics.js" async></script>
requestIdleCallback statt setTimeout(fn, 0)
setTimeout(fn, 0) plant Arbeit im nächsten verfügbaren Task-Queue-Slot ein, der mitten in einer Benutzerinteraktion landen kann; requestIdleCallback wartet auf einen echten Leerlaufzeitraum und ist damit das richtige Primitive für Analytics-Initialisierung, Prefetch-Hydration und Telemetrie-Batching. Der Unterschied ist in der MDN-Referenz zu requestIdleCallback dokumentiert: Der Callback wird während der Leerlaufphasen des Browsers ausgeführt und erhält einen Deadline-Wert, den man vor weiterer Arbeit prüfen kann.
Dies ist das Primitive, das die meisten Teams nie übernommen haben – setTimeout(fn, 0) wurde zum reflexartigen „mach das später”-Idiom, und es gibt den Main Thread tatsächlich nicht frei. Da INP im März 2024 FID als Core Web Vital abgelöst hat (gemäß der web.dev-INP-Ankündigung), ist Main-Thread-Arbeit, die während einer Interaktion anfällt, nicht mehr nur ein Darstellungsproblem – sie ist ein Rankingsignal. requestIdleCallback wird in Chrome und Firefox unterstützt, aber nicht in Safari, daher ist eine Feature-Erkennung mit Fallback erforderlich.
function whenIdle(fn) {
if ("requestIdleCallback" in window) {
requestIdleCallback(fn, { timeout: 2000 });
} else {
setTimeout(fn, 0); // Safari-Fallback
}
}
// Nicht dringende Arbeit außerhalb des Interaktionspfads verschieben
whenIdle(() => initAnalytics());
Die timeout-Option stellt sicher, dass die Arbeit letztendlich ausgeführt wird, selbst wenn der Browser nie in den Leerlauf geht.
Debounce und Throttle bei Scroll-, Resize- und Input-Events
Ungedrosselte Scroll-, Resize- und Input-Handler, die den Main Thread blockieren, sind nun ein Rankingsignal – nicht nur ein Problem der Darstellungsflüssigkeit. Jedes Frame, das sie verzögern, ist ein potenzieller INP-Verstoß. Das Muster ist entstanden, weil useEffect das Hinzufügen eines rohen Listeners trivial macht: drei Zeilen, keine Ratenbegrenzung, und ein Handler, der bei jedem Scroll-Frame ausgelöst wird.
Debounce führt eine Funktion aus, nachdem die Aktivität aufgehört hat – korrekt für Sucheingaben und Resize-End-Arbeit. Throttle begrenzt die Häufigkeit – korrekt für Scroll-Position-Tracking, das während der Geste aktualisiert werden muss. Die MDN-Referenz zum Scroll-Event weist darauf hin, dass Scroll-Events mit hoher Frequenz ausgelöst werden können, und empfiehlt das Drosseln aufwändiger Handler.
useEffect(() => {
let ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
updateScrollPosition(window.scrollY);
ticking = false;
});
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
Das requestAnimationFrame-Gate drosselt auf ein Update pro Frame, und { passive: true } teilt dem Browser mit, dass der Handler preventDefault nicht aufrufen wird – der Browser kann damit scrollen, ohne auf dein JavaScript warten zu müssen.
Das kumulative Muster
Jede Technik in diesem Artikel ist Plattform-Wissen, das wir an einen Framework-Standard delegiert und aufgehört haben zu überprüfen. Keine davon ist neu – das ist der Punkt. Einzeln betrachtet kostet ein fehlendes font-display oder ein nicht-verzögertes Tag Millisekunden; zusammen sind sie die Lücke zwischen einer App, die sich schnell anfühlt, und einer, die sich trotz moderner Werkzeuge schwerfällig anfühlt. Der nächste konkrete Schritt: Öffne DevTools, prüfe deine manuell geschriebenen @font-face-Blöcke, deine Drittanbieter-<script>-Tags und deine useEffect-Listener anhand der oben genannten Regeln – und lösche den Intersection Observer Wrapper, den der Browser überflüssig gemacht hat.
Häufig gestellte Fragen
Verwende Debounce, wenn du nur den Endzustand nach dem Ende der Aktivität benötigst, beispielsweise beim Absetzen einer Suchanfrage, nachdem der Nutzer aufgehört hat zu tippen, oder beim Neuberechnen des Layouts nach dem Ende eines Resize-Vorgangs. Verwende Throttle, wenn du während einer kontinuierlichen Geste Aktualisierungen bei begrenzter Rate benötigst, etwa beim Verfolgen der Scroll-Position. Debounce wartet auf eine Pause; Throttle begrenzt die Häufigkeit, während das Event weiterhin ausgelöst wird.
Ja. Das loading-Attribut gilt sowohl für img- als auch für iframe-Elemente, sodass eingebettete Karten, Video-Player und Drittanbieter-Widgets unterhalb des sichtbaren Bereichs nativ verzögert geladen werden können – ohne einen Intersection Observer Wrapper. Die Browser-Unterstützung folgt dem Rollout für Bilder eng und erreichte den Baseline-Status in Chrome, Firefox und Safari im gleichen Zeitraum. Behalte explizite width- und height-Angaben bei, um Layout-Verschiebungen zu verhindern, da lazy-geladene Elemente genauso leicht Verschiebungen verursachen wie eager-geladene.
Sie konkurrieren miteinander und können in der falschen Reihenfolge ausgeführt werden. Async-Skripte werden ausgeführt, sobald jedes einzelne fertig heruntergeladen ist – in der Reihenfolge des Netzwerkeingangs, nicht in der Dokumentreihenfolge. Ein Skript, das von einem anderen async-Skript abhängt, kann daher zuerst ausgeführt werden und fehlschlagen. Die Lösung besteht darin, für beide Skripte defer zu verwenden, was die Ausführung nach dem Parsing in Dokumentreihenfolge garantiert, oder die Abhängigkeit vor dem abhängigen Skript in einem einzigen Bundle zu laden.
setTimeout mit einer Verzögerung von null plant Arbeit im nächsten Task-Queue-Slot ein, den der Browser möglicherweise sofort ausführt – auch mitten in einer Benutzerinteraktion. Es gibt den Main Thread also nicht wirklich frei. requestIdleCallback wartet auf einen echten Leerlaufzeitraum und übergibt einen Deadline-Wert, den man vor dem Fortfahren prüfen kann. Da INP im März 2024 zum Core Web Vital wurde, ist diese Unterscheidung bedeutsam: Arbeit, die während einer Interaktion anfällt, ist nun ein Rankingsignal.
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.