Web Worker mit Claude Code, React und TypeScript
Implementiere typisierte Web Worker mit Claude Code, Vite, React, Cleanup, Transferables und Tests.
Performance-Probleme im Browser sehen selten wie ein klarer Fehler aus. Meist wirkt die Seite einfach blockiert. Ein CSV-Import stoppt Eingaben, ein Bildfilter macht Scrollen zaeh, ein lokaler Suchindex verzögert den ersten Klick, und ein Log-Parser funktioniert in der Demo, friert aber mit echten Support-Daten ein. Web Worker helfen genau bei dieser Klasse von Aufgaben: CPU-lastige Arbeit verlaesst den Main Thread.
Der Main Thread rendert die Oberflaeche und verarbeitet Klicks, Texteingaben und Scroll. Ein Worker ist ein separater Arbeitsbereich fuer Berechnung. Er darf den DOM nicht direkt veraendern und sollte React State, Routing, Toasts oder CSS nicht kennen. Er bekommt Daten, berechnet ein Ergebnis und schickt es zurueck. Diese Grenze muss Claude Code vor der Implementierung verstehen.
Nutze fuer die Pruefung die offiziellen Quellen: MDN Web Workers API, MDN Transferable objects, Vite Web Workers und die Claude Code Dokumentation. Fuer Repository-Regeln passt dazu der interne Artikel CLAUDE.md Best Practices.
Architektur
Das Ziel ist eine klare Trennung: React besitzt die UI, ein Hook besitzt den Worker-Lebenszyklus, und der Worker besitzt nur teure Datenumwandlungen. Diese Struktur sollte im Prompt stehen, bevor Claude Code Dateien aendert.
flowchart LR
UI["React UI"]
Hook["useDataWorker hook"]
Worker["data.worker.ts"]
Tasks["CSV / image / search / logs / JSON"]
UI --> Hook
Hook --> Worker
Worker --> Tasks
Worker --> Hook
Hook --> UI
Die fuenf Use Cases decken typische Risiken ab. CSV-Auswertung prueft numerisches Parsing. Bildverarbeitung prueft Transferables. Suchindexierung prueft grosse Arrays. Log-Analyse prueft viel Text. Schwere JSON-Transformation prueft Rekursion und Typgrenzen.
| Use Case | Daten an den Worker | Ergebnis |
|---|---|---|
| CSV-Auswertung | CSV-Text | Zeilen, Spalten, Kennzahlen |
| Bildverarbeitung | ImageData Buffer | Graustufen-Buffer |
| Suchindex | Dokumente | Token-zu-Dokument-Index |
| Log-Analyse | Log-Text | Errors, Warnings, Top-Meldungen |
| JSON-Transformation | Verschachteltes JSON | Flaches Key-Value-Objekt |
Nicht jede Schleife braucht einen Worker. Eine kleine Sortierung oder ein Filter ueber wenige Zeilen ist im Main Thread oft einfacher. Worker haben Startkosten, Message-Kosten und manchmal Kopierkosten. Sie lohnen sich, wenn die UI Frames verliert, die Aufgabe haeufig laeuft oder die Datenmenge unvorhersehbar ist.
Prompt fuer Claude Code
Der erste Prompt sollte Dateien, Anforderungen, Verbote und Pruefungen enthalten. Besonders wichtig ist das Verbot von DOM- und React-State-Zugriff im Worker.
Add a Web Worker to an existing Vite + React + TypeScript app.
Files:
- src/workers/worker-protocol.ts
- src/workers/data.worker.ts
- src/hooks/useDataWorker.ts
- src/components/WorkerDemo.tsx
Requirements:
- Support CSV summary, image grayscale, search indexing, log summary, and JSON flattening.
- Define typed request and response messages with TypeScript union types.
- Create the Worker in a React hook and terminate it during unmount cleanup.
- Transfer the ImageData ArrayBuffer instead of copying it.
- Do not touch DOM, window, document, routing, toast UI, or React state inside the Worker.
- Add Playwright or manual verification steps.
Checks:
- npm run typecheck
- npm run test
- npm run dev and confirm the UI stays responsive
Der wichtigste Teil ist die Grenze. Wenn Claude Code document.querySelector, Navigation oder UI-Benachrichtigungen in den Worker schreibt, ist die Architektur bereits undicht.
Typisierte Nachrichten
Beginne mit dem Protokoll. payload: any ist fuer eine Demo bequem, aber spaeter schwer zu pruefen. Eine Union macht jede Aufgabe sichtbar.
// src/workers/worker-protocol.ts
export type CsvSummary = {
rows: number;
columns: string[];
numeric: Record<string, { count: number; average: number; max: number }>;
};
export type ImageResult = {
width: number;
height: number;
buffer: ArrayBuffer;
};
export type SearchDocument = {
id: string;
title: string;
body: string;
};
export type SearchIndex = {
documents: number;
tokens: Record<string, string[]>;
};
export type LogSummary = {
lines: number;
errors: number;
warnings: number;
topMessages: string[];
};
export type JsonFlatResult = Record<string, string | number | boolean | null>;
export type WorkerJob =
| { type: "csv:summary"; text: string; delimiter?: "," | "\t" }
| { type: "image:grayscale"; width: number; height: number; buffer: ArrayBuffer }
| { type: "search:index"; documents: SearchDocument[] }
| { type: "log:summary"; text: string }
| { type: "json:flatten"; value: unknown };
export type WorkerResultMap = {
"csv:summary": CsvSummary;
"image:grayscale": ImageResult;
"search:index": SearchIndex;
"log:summary": LogSummary;
"json:flatten": JsonFlatResult;
};
export type WorkerRequest = {
id: string;
job: WorkerJob;
};
export type WorkerResponse<T = unknown> =
| { id: string; ok: true; result: T }
| { id: string; ok: false; error: string };
Dieses Protokoll ist die Grundlage der Review. Bei einer neuen Aufgabe soll Claude Code zuerst diese Datei erweitern, danach Worker, Hook und Tests.
Worker-Implementierung
Diese Variante nutzt rohes postMessage. Comlink ist fuer RPC-artige APIs sinnvoll, aber am Anfang ist ein sichtbarer Message-Fluss leichter zu auditieren. Spaeter kannst du mit dem Comlink README vergleichen.
// src/workers/data.worker.ts
import type {
CsvSummary,
ImageResult,
JsonFlatResult,
LogSummary,
SearchIndex,
WorkerRequest,
WorkerResponse,
} from "./worker-protocol";
const workerScope = self as unknown as DedicatedWorkerGlobalScope;
workerScope.onmessage = (event: MessageEvent<WorkerRequest>) => {
const { id, job } = event.data;
try {
const result = runJob(job);
const response: WorkerResponse = { id, ok: true, result };
const transfer = resultHasBuffer(result) ? [result.buffer] : [];
workerScope.postMessage(response, transfer);
} catch (error) {
const message = error instanceof Error ? error.message : "Worker failed";
workerScope.postMessage({ id, ok: false, error: message } satisfies WorkerResponse);
}
};
function runJob(job: WorkerRequest["job"]) {
switch (job.type) {
case "csv:summary":
return summarizeCsv(job.text, job.delimiter ?? ",");
case "image:grayscale":
return grayscale(job.width, job.height, job.buffer);
case "search:index":
return buildSearchIndex(job.documents);
case "log:summary":
return summarizeLogs(job.text);
case "json:flatten":
return flattenJson(job.value);
default:
return assertNever(job);
}
}
function summarizeCsv(text: string, delimiter: "," | "\t"): CsvSummary {
const rows = text.trim().split(/\r?\n/).filter(Boolean);
const headers = rows.shift()?.split(delimiter).map((cell) => cell.trim()) ?? [];
const numeric: CsvSummary["numeric"] = {};
for (const row of rows) {
row.split(delimiter).forEach((cell, index) => {
const key = headers[index] ?? `column_${index + 1}`;
const value = Number(cell);
if (!Number.isFinite(value)) return;
const current = numeric[key] ?? { count: 0, average: 0, max: Number.NEGATIVE_INFINITY };
current.count += 1;
current.average += (value - current.average) / current.count;
current.max = Math.max(current.max, value);
numeric[key] = current;
});
}
return { rows: rows.length, columns: headers, numeric };
}
function grayscale(width: number, height: number, buffer: ArrayBuffer): ImageResult {
const pixels = new Uint8ClampedArray(buffer);
for (let index = 0; index < pixels.length; index += 4) {
const gray = Math.round(pixels[index] * 0.299 + pixels[index + 1] * 0.587 + pixels[index + 2] * 0.114);
pixels[index] = gray;
pixels[index + 1] = gray;
pixels[index + 2] = gray;
}
return { width, height, buffer: pixels.buffer };
}
function buildSearchIndex(documents: Array<{ id: string; title: string; body: string }>): SearchIndex {
const tokens: SearchIndex["tokens"] = {};
for (const document of documents) {
const words = `${document.title} ${document.body}`
.toLowerCase()
.split(/[^a-z0-9]+/g)
.filter((word) => word.length >= 3);
for (const word of new Set(words)) {
tokens[word] = [...(tokens[word] ?? []), document.id];
}
}
return { documents: documents.length, tokens };
}
function summarizeLogs(text: string): LogSummary {
const lines = text.split(/\r?\n/).filter(Boolean);
const counts = new Map<string, number>();
let errors = 0;
let warnings = 0;
for (const line of lines) {
if (/\berror\b/i.test(line)) errors += 1;
if (/\bwarn(ing)?\b/i.test(line)) warnings += 1;
const normalized = line.replace(/\d{4}-\d{2}-\d{2}[^\s]*/g, "").replace(/\s+/g, " ").trim();
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
}
const topMessages = [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([message, count]) => `${count}x ${message}`);
return { lines: lines.length, errors, warnings, topMessages };
}
function flattenJson(value: unknown, prefix = ""): JsonFlatResult {
if (value === null || typeof value !== "object") {
return { [prefix || "value"]: value as string | number | boolean | null };
}
return Object.entries(value as Record<string, unknown>).reduce<JsonFlatResult>((acc, [key, child]) => {
const path = prefix ? `${prefix}.${key}` : key;
return { ...acc, ...flattenJson(child, path) };
}, {});
}
function resultHasBuffer(result: unknown): result is ImageResult {
return typeof result === "object" && result !== null && "buffer" in result && result.buffer instanceof ArrayBuffer;
}
function assertNever(value: never): never {
throw new Error(`Unsupported worker job: ${JSON.stringify(value)}`);
}
Der Bildfall ist besonders wichtig. Transferable bedeutet nicht “schneller kopieren”, sondern Besitz uebertragen. Der urspruengliche Buffer ist danach nicht mehr nutzbar.
React Hook und Cleanup
Der Hook kapselt Erstellung, ausstehende Promises, Fehlerbehandlung und Terminierung. Komponenten rufen nur runJob auf.
// src/hooks/useDataWorker.ts
import { useCallback, useEffect, useRef } from "react";
import type { WorkerJob, WorkerRequest, WorkerResponse } from "../workers/worker-protocol";
type PendingJob = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
};
export function useDataWorker() {
const workerRef = useRef<Worker | null>(null);
const pendingRef = useRef(new Map<string, PendingJob>());
const nextIdRef = useRef(0);
useEffect(() => {
const worker = new Worker(new URL("../workers/data.worker.ts", import.meta.url), {
type: "module",
});
workerRef.current = worker;
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const response = event.data;
const pending = pendingRef.current.get(response.id);
if (!pending) return;
pendingRef.current.delete(response.id);
if (response.ok) {
pending.resolve(response.result);
} else {
pending.reject(new Error(response.error));
}
};
worker.onerror = (event) => {
for (const pending of pendingRef.current.values()) {
pending.reject(new Error(event.message));
}
pendingRef.current.clear();
};
return () => {
for (const pending of pendingRef.current.values()) {
pending.reject(new Error("Worker was terminated"));
}
pendingRef.current.clear();
worker.terminate();
workerRef.current = null;
};
}, []);
const runJob = useCallback(<T,>(job: WorkerJob, transfer: Transferable[] = []) => {
const worker = workerRef.current;
if (!worker) return Promise.reject(new Error("Worker is not ready"));
const id = `worker-job-${Date.now()}-${nextIdRef.current}`;
nextIdRef.current += 1;
return new Promise<T>((resolve, reject) => {
pendingRef.current.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
});
worker.postMessage({ id, job } satisfies WorkerRequest, transfer);
});
}, []);
return { runJob };
}
Cleanup schuetzt vor alten Antworten, hängenden Promises und Workern, die nach Navigation weiterlaufen.
React-Beispiel
Das Beispiel verbindet fuenf Aufgaben mit Buttons. Fuer echte Apps kommen Daten aus File Upload, Canvas, CMS oder Support-Export.
// src/components/WorkerDemo.tsx
import { useRef, useState } from "react";
import { useDataWorker } from "../hooks/useDataWorker";
import type { CsvSummary, ImageResult, LogSummary, SearchIndex } from "../workers/worker-protocol";
const sampleCsv = `team,score,cost
alpha,91,1200
beta,84,950
gamma,96,1430`;
const sampleLogs = `2026-06-02T10:00:00Z INFO started
2026-06-02T10:01:00Z WARN cache miss
2026-06-02T10:02:00Z ERROR payment retry failed
2026-06-02T10:03:00Z ERROR payment retry failed`;
export function WorkerDemo() {
const { runJob } = useDataWorker();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [message, setMessage] = useState("Idle");
async function handleCsv() {
const summary = await runJob<CsvSummary>({ type: "csv:summary", text: sampleCsv });
setMessage(`CSV rows: ${summary.rows}, score average: ${summary.numeric.score.average.toFixed(1)}`);
}
async function handleSearch() {
const index = await runJob<SearchIndex>({
type: "search:index",
documents: [
{ id: "a", title: "CSV reports", body: "Aggregate revenue and cost columns" },
{ id: "b", title: "Log monitor", body: "Find warning and error messages quickly" },
],
});
setMessage(`Search index tokens: ${Object.keys(index.tokens).length}`);
}
async function handleLogs() {
const summary = await runJob<LogSummary>({ type: "log:summary", text: sampleLogs });
setMessage(`Errors: ${summary.errors}, warnings: ${summary.warnings}`);
}
async function handleImage() {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
context.fillStyle = "#2f80ed";
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#f2994a";
context.fillRect(20, 20, 80, 80);
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const buffer = imageData.data.buffer as ArrayBuffer;
const result = await runJob<ImageResult>(
{ type: "image:grayscale", width: imageData.width, height: imageData.height, buffer },
[buffer],
);
context.putImageData(
new ImageData(new Uint8ClampedArray(result.buffer), result.width, result.height),
0,
0,
);
setMessage("Image converted in worker");
}
async function handleJson() {
const result = await runJob<Record<string, unknown>>({
type: "json:flatten",
value: { user: { id: 1, plan: "pro" }, flags: { beta: true } },
});
setMessage(`Flattened keys: ${Object.keys(result).join(", ")}`);
}
return (
<section>
<div>
<button onClick={handleCsv}>Summarize CSV</button>
<button onClick={handleSearch}>Build search index</button>
<button onClick={handleLogs}>Analyze logs</button>
<button onClick={handleImage}>Process image</button>
<button onClick={handleJson}>Flatten JSON</button>
</div>
<p role="status">Worker finished: {message}</p>
<canvas ref={canvasRef} width={160} height={120} aria-label="Image processing preview" />
</section>
);
}
Tests und Review
Automatisiere Ergebnispruefung und pruefe Responsiveness manuell. Beides ist noetig.
// tests/worker-demo.spec.ts
import { expect, test } from "@playwright/test";
test("heavy worker jobs finish without blocking the page", async ({ page }) => {
await page.goto("http://localhost:5173/");
await page.getByRole("button", { name: "Summarize CSV" }).click();
await expect(page.getByRole("status")).toContainText("CSV rows");
await page.getByRole("button", { name: "Build search index" }).click();
await expect(page.getByRole("status")).toContainText("Search index tokens");
await page.getByRole("button", { name: "Analyze logs" }).click();
await expect(page.getByRole("status")).toContainText("Errors:");
});
Manual inspection checklist:
1. Open Chrome DevTools Performance tab.
2. Click each worker button with a large sample.
3. Confirm typing and scrolling still respond while the worker runs.
4. Navigate away from the component and confirm no new Worker remains.
5. Check that image processing transfers an ArrayBuffer and does not reuse the detached buffer.
Review only the Web Worker implementation.
Find:
- code that touches DOM, window, or React state inside the worker
- untyped postMessage payloads
- missing terminate cleanup
- incorrect transferable usage
- bundler paths that fail in Vite
- responsibilities that should stay on the main thread
Return file paths, line numbers, and concrete fixes.
Typische Fallen
Erste Falle: DOM-Zugriff im Worker. Zweite: untypisierte Messages. Dritte: Transferables wie Kopien behandeln. Vierte: fehlendes terminate(). Fuenfte: fragile Bundler-Pfade statt new URL(..., import.meta.url). Sechste: API Calls, Analytics, Routing und UI-Texte im Worker statt im Main Thread.
Beratung und Verifikationsnotiz
Fuer ein Einzelprojekt reicht ein kleiner Branch mit Protokoll, Worker, Hook und Demo. Teste ihn mit echten CSV-, Bild- oder Logdaten. Fuer Teams braucht es zusaetzlich CLAUDE.md, Review-Prompts, Playwright-Pruefungen und Performance-Belege. ClaudeCodeLab kann das ueber Claude Code Training und Beratung an einem echten Repository strukturieren. Fuer taegliche Arbeitsablaeufe lies auch die Claude Code Produktivitaetstipps.
Das praktische Ergebnis: CSV, Suche und Logs bleiben mit postMessage gut nachvollziehbar. Bildverarbeitung braucht die strengste Transferable-Review. JSON-Transformation braucht klare Rekursionsgrenzen. Vor der Veroeffentlichung wurden Code-Fences, interne Links, offizielle Links, updatedDate, konkrete Fallen und Cleanup-Hinweise geprueft.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Permission Safety Ladder: Zugriff kontrolliert erweitern
Von read-only zu begrenzten Änderungen, Prüfbefehlen und Deploy-Checks mit klarer Kontrolle.
Claude Code Small PR Proof Pack: kleine Änderungen reviewbar machen
Ein Proof Pack für Claude-Code-PRs: Diff, Checks, öffentliche URL, CTA-Pfad und Rollback.
Claude-Code-Review-Gate vor dem Commit
Vor dem Commit mit Claude Code prüfen: Diff, Build, öffentliche URL, Gumroad-Links, Beratung-CTA, fehlende Tests und fremde Dateien.