Claude Code Web Worker Guide for React and TypeScript
Build typed Web Workers with Claude Code, Vite, React, cleanup, transferable buffers, and practical tests.
Large browser tasks rarely fail in a dramatic way. They usually make the page feel frozen. A CSV import pauses typing. An image filter locks scrolling. A local search index blocks the first interaction after a route change. A log parser looks fine in a demo and then stalls when a real support export arrives. Web Workers solve this specific class of problem by moving CPU-heavy work away from the main thread.
Claude Code is useful here because the implementation touches several small files at once: a protocol type, a worker module, a React hook, UI wiring, and tests. The risk is that Claude Code can also generate a plausible but unsafe worker if you do not define boundaries. The worker must not touch DOM, window, document, React state, routing, toast messages, or component refs. It should accept data, compute, and return data.
Use the official references while reviewing the code: MDN Web Workers API, MDN Transferable objects, Vite Web Workers, and the Claude Code documentation. For repository-level rules, pair this with the internal CLAUDE.md best practices guide.
Architecture
The clean model is simple: React owns the interface, a hook owns worker lifecycle, and the worker owns expensive pure transformations. Claude Code should see that split before it writes code.
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
This article uses five practical jobs because they show different failure modes. CSV aggregation tests numeric parsing. Image processing tests transferable buffers. Search indexing tests large arrays. Log analysis tests string-heavy parsing. JSON flattening tests recursive conversion. You do not need all five in every product, but they make a good review harness for Claude Code output.
| Use case | Data sent to the worker | Data returned |
|---|---|---|
| CSV aggregation | CSV text | Row count, columns, numeric averages |
| Image processing | ImageData buffer | Grayscale buffer |
| Search index | Document array | Token-to-document index |
| Log analysis | Log text | Error count, warning count, top messages |
| Heavy JSON transform | Nested JSON | Flat key-value object |
Do not use a Worker for every loop. A small filter over 100 rows is usually cheaper on the main thread. Workers have startup cost, message cost, and sometimes copy cost. They are worth it when the UI would otherwise miss frames, when the task is repeated, or when the data size is unpredictable.
Prompt Claude Code With Boundaries
Start with a prompt that names the files, the allowed responsibilities, the forbidden responsibilities, and the verification commands. That gives Claude Code less room to invent a leaky architecture.
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
The important line is not only what to build. It is what the worker is not allowed to own. Without that line, generated code often mixes UI messages, side effects, and computation. That is not a Web Worker architecture; it is just a hidden component.
Typed Messages
Create the protocol first. Beginners often start with payload: any and a string type; that works for one demo and becomes painful when the third job arrives. A discriminated union keeps each job explicit.
// 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 };
This file becomes the contract you can ask Claude Code to review. When a new job is added, the prompt should say: update WorkerJob, update the result type, update tests, and do not widen the protocol to any.
Worker Implementation
The worker module below is intentionally plain. It uses raw postMessage, returns typed responses, and transfers the image buffer back to the main thread. Comlink is a good option for RPC-style APIs, but raw messages make the boundary visible for a first implementation. If you later adopt Comlink, review the same boundaries against the Comlink README.
// 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)}`);
}
The image case is the one to inspect most carefully. A transferable buffer avoids copying, but the original buffer is detached after transfer. That is expected. Do not write code that transfers a buffer and then tries to read the old ImageData again.
React Hook and Cleanup
The hook owns lifecycle. This keeps components from creating workers repeatedly and forgetting to terminate them. It also rejects pending jobs when the component unmounts, which prevents promises from resolving into a dead screen.
// 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 };
}
Ask Claude Code to explain why cleanup exists. If it answers only “to avoid memory leaks”, push for specifics: pending promises, route changes, repeated mounts, and stale event handlers.
UI Wiring
This demo is intentionally small. It proves that the worker receives different job shapes and that the page can keep responding while work runs.
// 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>
);
}
In a real app, the CSV may come from a file input, the image from a canvas, the documents from local content, and the logs from a support export. The interface changes, but the worker contract remains stable.
Verification
Use automated checks for the contract and manual checks for responsiveness. A passing unit test does not prove the page stays interactive.
// 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.
Then ask Claude Code for a focused review:
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.
Pitfalls
The first pitfall is DOM access. A worker cannot update the page directly. It returns a result; React renders that result. If generated code contains document.querySelector inside the worker, reject the change.
The second pitfall is message drift. One job uses { text }, another uses { payload }, another returns { data }, and soon the main thread is full of defensive checks. Keep the protocol file strict.
The third pitfall is transferable confusion. Transfer avoids copying, but the sender gives up the buffer. This matters most for images, binary exports, and large typed arrays.
The fourth pitfall is leaked workers. Route changes, modals, and tabs can mount and unmount components many times. Without terminate(), old workers keep running.
The fifth pitfall is bundler paths. Vite expects the new URL(..., import.meta.url) pattern for reliable worker bundling. A plain string path may work locally and fail in production.
The sixth pitfall is responsibility creep. The worker should not own API calls, analytics events, notifications, routing, or UI copy. Keep expensive transformation in the worker and side effects on the main thread.
Claude Code Lab Support
For a solo project, start by copying the protocol, worker, hook, and demo into a small branch and running it with your real CSV or log export. For a team, the harder work is standardizing review rules: where workers live, what they may access, which tests are required, and how performance evidence is captured before release.
ClaudeCodeLab can help turn this into a repeatable workflow through Claude Code training and consultation. A practical session can cover CLAUDE.md, worker boundaries, review prompts, Playwright checks, and performance receipts using your actual repository. For more day-to-day operating patterns, read the Claude Code productivity tips.
Verification Note
The implementation pattern in this article was checked against the current MDN worker model, Vite worker loading, and a React cleanup flow. In practice, CSV aggregation, search indexing, and log parsing work well with raw postMessage. Image processing benefits most from transferable buffers, and it is the section I would review most carefully before production. The article also includes internal links, official links, executable code fences, and updatedDate so it can pass the updated-article quality audit.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.