Schneller geladen, fehleranfälliger: Wie PageSpeed-Optimierungen Race Conditions erzeugen können
-
7 min
Läuft bei mir. Der Slider macht auf deinem Rechner genau das, was er soll, und auf dem Handy im Zug mit einem Balken Empfang macht er gar nichts. Die Konsole ist sauber, Lighthouse zeigt Grün, und trotzdem rührt sich nichts. Schuld ist oft die PageSpeed-Optimierung selbst: Sie nimmt Skripte aus dem kritischen Pfad und damit eine Reihenfolge weg, die du bisher geschenkt bekommen hast.
Ein klassisches <script> blockiert den Parser, also kann sich nachfolgender Code darauf verlassen. Mit async, eingefügten Skripten oder Lazy Loading hängt genau dieser Code plötzlich am Timing. Auf dem Laptop gewinnt jedes Mal dasselbe Skript, im Funkloch das falsche. Das ist eine Race Condition (Wettlaufsituation), und dauerhaft hilft nur, Abhängigkeiten ausdrücklich zu deklarieren.
Die üblichen Verdächtigen
Fünf Muster machen den meisten Ärger. Hinter jedem steckt eine Annahme über die Reihenfolge, die nie jemand aufgeschrieben hat.
-
async statt defer:
Skripte laufen in Ankunftsreihenfolge, das Plugin kann vor seiner Bibliothek starten. -
Inline-Skript setzt eine Bibliothek voraus:
$ is not defined, weil die Bibliothek deferred ist und das Snippet nicht. -
Event schon vorbei:
Ein spätes Skript wartet auf einDOMContentLoaded, das längst gefeuert hat. -
Consent und Tracking:
Analytics fragt einen Consent-Status ab, der noch gar nicht gesetzt ist. -
Antworten in falscher Reihenfolge:
Eine langsame, alte Anfrage überschreibt ein neueres Ergebnis.
Was ist eine Race Condition bei der PageSpeed-Optimierung?
Eine Race Condition liegt vor, wenn das Ergebnis davon abhängt, welche von mehreren Aufgaben zuerst fertig wird. Der Begriff kommt aus der Elektronik, wo zwei Signale verschiedene Wege nehmen. Im Browser laufen Skripte, Stylesheets und API-Antworten um die Wette, und die Strecke ist ein Netzwerk, das du nicht kontrollierst.
Optimierung kostet dich die Reihenfolge-Garantie, aber nicht auf eine einzige Art. async und eingefügte Skripte laufen, sobald sie da sind. (Per createElement('script') erzeugte Skripte sind standardmäßig async, außer du setzt script.async = false.) defer und Module behalten die Dokumentreihenfolge, laufen aber erst nach dem Parsing. Nachlesen kannst du das im HTML Standard und in der MDN-Referenz zum <script>-Element.
| Ladeart | Wann es läuft | Reihenfolge erhalten? | Hinweis |
|---|---|---|---|
| Klassisches <script> | Sofort, blockiert das Parsing | Ja | Standard, aber am langsamsten für den ersten Ladevorgang |
| async | Sobald der Download fertig ist | Nein | Kann vor oder nach dem Ende des Parsings laufen |
| defer | Nach dem Parsing, vor DOMContentLoaded | Ja, Dokument[-]reihenfolge | Nur externe klassische Skripte, wirkungslos bei Inline-Skripten |
| type="module" | Nach dem Parsing, wie defer | Ja, Dokument[-]reihenfolge | Inline-Module sind ebenfalls deferred |
| type="module" mit async | Sobald verfügbar | Nein | Die Reihenfolge gilt nur ohne async-Attribut |
Wie sehen diese Race Conditions im Code aus?
Jeder Verdächtige bekommt dieselbe Behandlung: erst das Problem, dann ein Codeblock mit der kaputten und der reparierten Variante.
async statt defer: Wo liegt der Unterschied?
async-Skripte laufen, sobald sie geladen sind, in beliebiger Reihenfolge. defer-Skripte laufen nach dem Parsing, in Dokumentreihenfolge. Wer defer "für mehr Speed" gegen async tauscht, bricht jedes Skript, das von einem anderen abhängt. Fix: beide mit defer laden.
<!-- Kaputt: das Plugin kann vor seiner Basisbibliothek laufen -->
<script async src="/assets/slider-lib.js"></script>
<script async src="/assets/slider-plugin.js"></script>
<!-- Repariert: beide laufen nach dem Parsing, in dieser Reihenfolge -->
<script defer src="/assets/slider-lib.js"></script>
<script defer src="/assets/slider-plugin.js"></script>
"$ is not defined": Inline-Skripte, die eine fertige Bibliothek voraussetzen
Der Klassiker heißt Uncaught ReferenceError: $ is not defined (Firefox und Safari formulieren ihn anders, die Ursache ist dieselbe). Die Bibliothek ist deferred, das kleine Inline-Snippet im Template nicht, also läuft es zuerst. jQuery ist hier nur das Beispiel: Jede deferred oder async geladene Bibliothek verhält sich genauso. Klassische Inline-Skripte lassen sich nicht deferren, und async bewirkt bei ihnen nichts. Fix: den Code in eine deferred Datei auslagern oder inline lassen und auf DOMContentLoaded warten.
<script defer src="/assets/jquery.min.js"></script>
<script defer src="/assets/slick.min.js"></script>
<!-- Variante 1, kaputt -->
<script>
// Kaputt: läuft sofort beim Parsen, vor den deferred Skripten
$('.teaser-slider').slick();
</script>
<!-- Variante 2, repariert (eine Variante nehmen, nicht beide) -->
<script>
// Repariert: DOMContentLoaded feuert, nachdem alle deferred Skripte
// gelaufen sind. Das gilt nur, solange die Bibliotheken defer nutzen.
// Bei async garantiert nichts, dass sie bis dahin gelaufen sind.
document.addEventListener('DOMContentLoaded', function () {
$('.teaser-slider').slick();
});
</script>
"DOMContentLoaded already fired": Events, die schon vorbei sind
Der Satz ist keine echte Browser-Fehlermeldung, sondern die Beschreibung des Symptoms. Ein spät geladenes Skript (async, Tag Manager, Lazy Load) registriert seinen Listener, wenn das Parsing schon durch ist. Das Event ist weg, der Init-Code läuft nie. Ein defer-Skript hat das Problem nicht, es läuft vor dem Event. Fix: document.readyState prüfen. Der Wert ist loading, solange der Parser arbeitet, danach interactive oder complete.
// Kaputt: feuert nie, wenn dieses Skript nach dem Parsing läuft
document.addEventListener('DOMContentLoaded', init);
// Repariert: zu jedem Zeitpunkt sicher
if (document.readyState === 'loading') {
// { once: true } ist optional, das Event feuert ohnehin nur einmal
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
Consent und Tracking
Der Consent-Manager lädt asynchron, und das Analytics-Skript fragt einen Status ab, der noch nicht gesetzt ist. Je nachdem, wer gewinnt, trackst du vor der Einwilligung oder gar nicht. Das darf nicht der Ladereihenfolge überlassen bleiben. Fix: Analytics startet nur auf ein ausdrückliches Consent-Signal, und der aktuelle Status wird für Skripte wiedergegeben, die sich spät anmelden.
// Kaputt: prüft einmal und trackt nie, wenn der Consent einen Moment später kommt
if (window.consentState?.analytics === true) {
startTracking();
}
// Repariert. Status- und Eventnamen sind Beispiele: nimm die,
// die dein Consent-Manager tatsächlich liefert.
function onConsent(callback) {
if (window.consentState?.analytics === true) {
callback(); // Einwilligung liegt schon vor, nicht warten
} else {
window.addEventListener('consent:analytics', callback, { once: true });
}
}
onConsent(startTracking);
Antworten in falscher Reihenfolge
Live-Suche, Filter und Paginierung folgen demselben Muster. Anfrage A ist langsam, Anfrage B schnell. B wird zuerst gerendert, dann trudelt A ein und überschreibt das neuere Ergebnis mit einem veralteten. Der Nutzer sieht Treffer für das, was er vor zwei Tastenanschlägen getippt hat. Fix: die vorherige Anfrage abbrechen, damit nur die jüngste Antwort rendern darf.
// Kaputt: eine langsame frühe Antwort kann ein neueres Ergebnis überschreiben
async function searchBroken(term) {
const res = await fetch(`/api/search?q=${encodeURIComponent(term)}`);
render(await res.json()); // render() ist deine eigene Funktion
}
// Repariert - mehrere Abort-Signale verhindern das gegenseitige Überschreiben
let controller;
async function searchFixed(term) {
controller?.abort(); // vorherige Anfrage abbrechen
controller = new AbortController();
// Der try-Block deckt auch res.json() ab, ein Abbruch während
// des Lesens des Bodys wird also ebenfalls abgefangen.
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(term)}`, {
signal: controller.signal,
});
render(await res.json());
} catch (err) {
// Setzt den Standard-Abbruchgrund voraus. Wer abort(customReason)
// aufruft, bekommt stattdessen diesen Grund als Rejection.
if (err.name !== 'AbortError') throw err;
}
}
Wie behebst du Race Conditions dauerhaft?
Das Ziel: Das Ergebnis soll nicht davon abhängen, wer gewinnt. Du musst das Rennen nicht zuverlässiger gewinnen, es darf dir egal sein.
-
deferodertype="module"als Standard. Beide behalten die Dokumentreihenfolge, Module sind ohnehin deferred.asyncbleibt Skripten vorbehalten, die unabhängig sind und zu jedem Zeitpunkt eintreffen dürfen, etwa ein in sich geschlossenes Embed. Analytics hängt meist an Consent oder Data Layer und fällt deshalb selten darunter. -
Abhängigkeiten ausdrücklich machen. Modul-Imports, Promises oder Custom Events statt der Reihenfolge der
<script>-Tags. Code, der sagt "ich brauche X", überlebt auch den nächsten Template-Umbau. -
Init-Code schreiben, der zu jedem Zeitpunkt und auch zweimal sicher läuft. Prüf
document.readyStateund schütze dich vor doppelter Initialisierung, denn Tag Manager und CMS-Erweiterungen laden gern alles ein zweites Mal (Snippet unten). -
Veraltete Ergebnisse verwerfen.
AbortControlleroder ein Request-Zähler sorgen dafür, dass nur die jüngste Antwort rendert. - Status wiedergeben, nicht nur ankündigen. Ein Event feuert genau einmal. Wer sich später anmeldet, muss den aktuellen Stand (Consent, Konfiguration, "Bibliothek bereit") auch direkt lesen können.
function initSlider(el) {
if (el.dataset.sliderReady) return; // bereits initialisiert
// ... Slider aufbauen. Wirft das einen Fehler, bleibt das Flag
// ungesetzt, und ein späterer Aufruf kann es erneut versuchen.
// Können sich zwei Aufrufe bei einem asynchronen Aufbau überlappen,
// setze das Flag stattdessen zuerst und setze es bei einem Fehler zurück.
el.dataset.sliderReady = '1'; // erst nach erfolgreichem Aufbau setzen
}
document.querySelectorAll('.teaser-slider').forEach(initSlider);
Wie findest du Race Conditions, bevor es deine Besucher tun?
Ein Rennen, das du nicht reproduzieren kannst, kannst du auch nicht reparieren. Also machst du den langsamen Läufer absichtlich langsam.
- Netzwerk und CPU drosseln. Das Network-Panel der Chrome DevTools bringt Profile wie Slow 4G mit (sie ändern sich je nach DevTools-Version). Die CPU-Drosselung sitzt in den Aufnahmeeinstellungen des Performance-Panels, und die mobile Emulation von Lighthouse arbeitet mit 4x.
- Kalt testen. "Disable cache" anhaken (gilt nur, solange die DevTools offen sind) und das Ganze im privaten Fenster wiederholen.
- Request blockieren oder verzögern. Request Blocking blockiert nur. Damit siehst du, was passiert, wenn der Consent-Manager nie ankommt. Zum bloßen Verzögern brauchst du Throttling, einen lokalen Proxy oder einen Service Worker.
- Oft neu laden. Ein Rennen, das bei einem von zwanzig Ladevorgängen scheitert, braucht zwanzig Ladevorgänge. Lass eine Schleife in einem automatisierten Browsertest laufen.
-
Konsole beobachten. Sporadische
is not defined,is not a functionund "cannot read properties of undefined" sind die typischen Fingerabdrücke.
Du willst ein zweites Paar Augen auf dein Skript-Loading oder deine PageSpeed-Optimierung?
Schnell, aber fragil ist nicht schnell
Eine Seite mit 100 Lighthouse-Punkten, die bei, sagen wir, einem von zwanzig Besuchern auf langsamer Verbindung kaputtgeht, zahlt später: mit Support-Tickets, verlorenen Conversions und Bugs, die keiner nachstellen kann. Code, der deklariert, was er braucht, übersteht eine neue Ladestrategie, ein CMS-Update und einen umgebauten Template-Block. Code, der nur läuft, weil Skript A zufällig vor Skript B ankommt, wartet auf ein langsameres Netz.
Wie PageSpeed-Arbeit in einem echten Projekt aussieht, zeigt unser Beitrag zur Dieter Schwarz Stiftung: PageSpeed-Optimierung für die Dieter Schwarz Stiftung.
Markus Milkereit
Gründer und leitender Entwickler
Schreibt über Barrierefreiheit, wartungsfreundliche Frontends und die weniger glamourösen Aspekte eines erfolgreichen Website-Betriebs.