Neu veröffentlicht: E-Commerce mit Power Pages, Stripe & Analytics

· David Göschel · 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.

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

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:

  1. Background empfängt capture-screenshot
  2. Background fragt beim Content Script: get-element-bounds, bekommt die Position des gerade gehoverten Elements
  3. Background macht den Screenshot via captureVisibleTab
  4. Content Script schneidet das Bild zu
  5. Content Script zeigt das Preview Overlay und wartet
  6. User klickt Bestätigen
  7. Background fragt beim Content Script: get-instagram-metadata, das Content Script extrahiert Metadaten aus dem gespeicherten highlightedElement

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.

Zeitstrahl der Race Condition durch Overlay-Dismiss 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.

Zwei-Variablen-Lösung für stabiles Element-Tracking im Capture-Flow 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

  1. Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
  2. RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
  3. AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
  4. Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
  5. Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
  6. Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
  7. Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
  8. tsconfig und Vite: Node16 vs. bundler, warum Vite eigene Regeln hat: Artikel lesen
  9. Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
  10. Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
  11. Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
  12. Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: (dieser Artikel)
  13. PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
  14. Instagram Karussell vollständig erfassen mit MutationObserver: Lazy-Loading, Observer-before-click, Timeout-Fallback: Artikel lesen
  15. Notiz und Tags beim Screenshot-Speichern: Artikel lesen
  16. Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
  17. Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
  18. Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
  19. PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
  20. React Frontend mit react-oidc-context und Zitadel: Artikel lesen
  21. Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
  22. Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
  23. MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen
  24. access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen
  25. Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen
  26. Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen
  27. Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen
  28. Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen
  29. Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen
  30. Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen
  31. DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen
  32. Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen
  33. Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen
  34. Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen
  35. Traefik als einziger Einstiegspunkt im Docker Compose Stack: Artikel lesen
  36. Zitadel hinter Traefik richtig verdrahten mit Issuer, JWKS und Login V2: Artikel lesen
  37. Frontend reparieren wenn der nginx Healthcheck an localhost scheitert: Artikel lesen
  38. Observability für meinen Docker Compose Stack mit Bull Board und Dozzle: Artikel lesen
  39. Qdrant Dashboard sicher öffnen mit lokalem Traefik und SSH Tunnel: Artikel lesen
  40. Diagnose: Warum mein Chunking trotz Tokenisierung noch scheiterte: Artikel lesen
  41. Entscheidung: Warum ich den Chunk auf 1500 Tokens gesetzt habe: Artikel lesen
  42. Implementierung: Wie ich den Embedding Workflow in mehrere saubere Schritte zerlegt habe: Artikel lesen
  43. Validierung: Wie ich Chunking, Speicherung und Suche wieder zusammenbringe: 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.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen