Tips & Tricks (Updated: 6/2/2026)

Claude Code Web Worker Guide for React and TypeScript

Build typed Web Workers with Claude Code, Vite, React, cleanup, transferable buffers, and practical tests.

Claude Code Web Worker Guide for React and TypeScript

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 caseData sent to the workerData returned
CSV aggregationCSV textRow count, columns, numeric averages
Image processingImageData bufferGrayscale buffer
Search indexDocument arrayToken-to-document index
Log analysisLog textError count, warning count, top messages
Heavy JSON transformNested JSONFlat 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.

#Claude Code #Web Worker #parallel processing #performance #TypeScript
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.