· Webentwicklung · 7 minuten Lesezeit
Race Condition in Chrome Extensions debuggen und vermeiden
Das Shadow DOM Overlay löste eine Race Condition aus. Instagram wurde als generic erkannt, alle Metadaten fehlten. Ursache und Fix mit capturedElement und einem previewOpen Flag.

Inhalt
- Der Fehler, der sich nicht reproduzieren lässt
- Wie die Plattformerkennung funktioniert
- Warum das Overlay das Problem verursacht
- Die Lösung in zwei Teilen
- Was dieser Fehler über Chrome Extension Architektur lehrt
- Alle Artikel der Serie
Der Fehler, der sich nicht reproduzieren lässt
Ich hatte Phase 2 der Extension fertiggestellt. Tailwind CSS v4 im Popup, Shadow DOM Overlay für die Vorschau, farbkodierte Duplikatserkennung beim Hover. Alles gebaut, alles getestet. Dann kam das erste echte Nutzungsfeedback.
Die Plattform wurde nicht mehr erkannt. Jeder Capture auf Instagram lieferte platform: "generic". Keine Metadaten. Kein Channel-Name. Kein Timestamp. Nur die URL und der Seitentitel.
Das war unerwartet, weil die Plattformerkennung überhaupt nichts geändert hatte. Kein Commit hatte payload-context-builder.ts angefasst. Die Logik war identisch.
Und genau das ist das Symptom einer Race Condition: Der Fehler liegt nicht im geänderten Code. Er liegt in der zeitlichen Beziehung zwischen zwei Vorgängen, die vorher nie ein Problem waren.
Wie die Plattformerkennung funktioniert
Um zu verstehen, warum der Fehler passiert, muss man wissen, wie die Extension die Metadaten extrahiert. Der Ablauf über die drei Kontexte ist:
- Background empfängt
capture-screenshot - Background fragt beim Content Script:
get-element-bounds, bekommt die Position des gerade gehoverten Elements - Background macht den Screenshot via
captureVisibleTab - Content Script schneidet das Bild zu
- Content Script zeigt das Preview Overlay und wartet
- User klickt Bestätigen
- Background fragt beim Content Script:
get-instagram-metadata, das Content Script extrahiert Metadaten aus dem gespeichertenhighlightedElement
Die entscheidende Variable ist highlightedElement. Sie wird im Content Script bei jedem mouseover-Event aktualisiert. Sie zeigt immer auf das Element, über dem die Maus gerade ist.
Warum das Overlay das Problem verursacht
Schritt 6 ist der Moment, an dem die Race Condition entsteht.
Der User klickt auf den Bestätigen-Button im Shadow DOM Overlay. Das Overlay wird aus dem DOM entfernt: host.remove(). Das ist ein DOM-Mutation-Event. Der Browser reagiert darauf und feuert ein mouseover-Event auf das Element, das sich jetzt physisch unter dem Cursor befindet, da das Overlay weggefallen ist.
Das Content Script hört auf alle mouseover-Events. Es aktualisiert highlightedElement sofort.
Dann, Millisekunden später, schickt der Background Service Worker die Anfrage get-instagram-metadata. Das Content Script verarbeitet sie und liest highlightedElement. Aber das zeigt nicht mehr auf den Instagram-Post. Es zeigt auf das Element, das nach dem Entfernen des Overlays unter dem Cursor lag. Oft ein Button, ein Container, ein Navigations-Element.
// content/main.ts -> the problem in one variable
let highlightedElement: HTMLElement | null = null;
document.addEventListener('mouseover', (event) => {
if (!isScreenshotActive) return;
highlightedElement = event.target as HTMLElement; // gets overwritten after overlay dismiss
});
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.action === 'get-instagram-metadata') {
extractInstagramPostMetadata(highlightedElement) // reads the already overwritten value
.then((metadata) => sendResponse({ metadata }));
return true;
}
});Die Extraktion schlägt fehl, weil highlightedElement kein Instagram-Post mehr ist. Sie gibt null zurück. Der Background fällt auf generic zurück. Keine Metadaten.
Abbildung: Zeitstrahl der Race Condition. Der User drückt Ctrl+Q, das Overlay öffnet sich, der User klickt Bestätigen, das DOM-Remove-Event feuert ein neues mouseover, und erst dann kommt die get-instagram-metadata Anfrage an, aber highlightedElement zeigt schon auf ein anderes Element.
Die Lösung in zwei Teilen
Das Problem hat zwei Ursachen, also braucht es zwei Gegenmaßnahmen.
Teil 1: Element beim richtigen Zeitpunkt einfrieren
Der richtige Zeitpunkt ist get-element-bounds. Das ist die Nachricht, die der Background am Anfang des Capture-Flows schickt. In diesem Moment gilt: Der User hat gerade Ctrl+Q gedrückt. Das highlightedElement ist genau das, was er erfassen will.
Ich friere es als capturedElement ein:
let capturedElement: HTMLElement | null = null;
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.action === 'get-element-bounds') {
capturedElement = highlightedElement; // snapshot at the right moment
const bounds = getElementBounds();
sendResponse({ bounds });
return false;
}
if (message.action === 'get-instagram-metadata') {
const elementToExtract = capturedElement || highlightedElement; // stable reference
extractInstagramPostMetadata(elementToExtract).then((metadata) => sendResponse({ metadata }));
return true;
}
});capturedElement wird nicht durch mouseover verändert. Es bleibt stabil für den gesamten Capture-Flow, unabhängig davon, was der Browser dazwischen feuert.
Teil 2: Mouseover während des Overlays pausieren
Das Einfrieren der Referenz ist der wichtigste Fix. Aber es gibt noch eine zweite Schwachstelle: Wenn der User die Maus auf dem Weg zum Bestätigen-Button über Instagram-Elemente bewegt, schreibt das mouseover-Listener weiterhin in highlightedElement. Das ist unnötig und verursacht visuelle Artefakte (die Umrandungsfarbe springt).
Die Lösung ist ein previewOpen-Flag:
let previewOpen = false;
// In the show-preview handler
if (message.action === 'show-preview') {
previewOpen = true;
showCapturePreview(message.dataUrl, message.width, message.height).then((confirmed) => {
previewOpen = false; // only release when overlay is gone
sendResponse({ confirmed });
});
return true;
}
// In the mouseover listener
document.addEventListener('mouseover', (event) => {
if (!isScreenshotActive || previewOpen) return; // no update while overlay is open
highlightedElement = event.target as HTMLElement;
});Solange das Overlay offen ist, werden keine neuen Hover-Events verarbeitet. Die Maus kann sich beliebig bewegen. highlightedElement bleibt stabil. Und capturedElement ist sowieso eingefroren.
Abbildung: Der Fix mit zwei Variablen. capturedElement wird beim get-element-bounds eingefroren und nicht mehr durch mouseover überschrieben. previewOpen blockiert den mouseover-Listener für die Dauer des Overlays.
Was dieser Fehler über Chrome Extension Architektur lehrt
Das Interessante an dieser Race Condition ist, dass sie nicht durch schlechten Code entstand. Die ursprüngliche Architektur war für den ursprünglichen Ablauf korrekt.
Das Problem entstand durch die zeitliche Entkopplung in einem dreistufigen Nachrichtensystem. Wenn A eine Nachricht an B schickt und B mit C interagiert bevor A die nächste Nachricht schickt, kann der Zustand in B zwischen den Nachrichten von A durch C verändert werden.
Das ist keine Chrome-Extension-Besonderheit. Das ist das fundamentale Problem aller event-driven Systeme mit geteiltem Zustand. Die Lösung ist immer dieselbe: Zustand zum richtigen Zeitpunkt einfrieren und explizit durch den Flow tragen, statt ihn als globale Variable immer neu zu lesen.
In diesem Fall war der richtige Zeitpunkt get-element-bounds. Ab da ist die Absicht des Users klar. Ab da darf sich nichts mehr am Ziel des Captures ändern.
Alle Artikel der Serie
Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
tsconfig und Vite:
Node16vs.bundler, warum Vite eigene Regeln hat: Artikel lesenInstagram Caption mit MutationObserver vollständig laden: Artikel lesen
Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht (dieser Artikel)
PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
Instagram Karussell vollständig erfassen mit MutationObserver: Lazy-Loading, Observer-before-click, Timeout-Fallback: Artikel lesen
Notiz und Tags beim Screenshot-Speichern: Artikel lesen
Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
React Frontend mit react-oidc-context und Zitadel: Artikel lesen
Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen
access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen
Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen
Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen
Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen
Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen
Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen
Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen
DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen
Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen
Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen
Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen
Traefik als einziger Einstiegspunkt im Docker Compose Stack: Artikel lesen
Zitadel hinter Traefik richtig verdrahten mit Issuer, JWKS und Login V2: Artikel lesen
Frontend gesund machen wenn der nginx Healthcheck an localhost scheitert: Artikel lesen
Du arbeitest an einer Chrome Extension oder einem anderen event-driven System und stolperst über ähnliche Zustandsprobleme? Lass uns das gemeinsam einschätzen.



