Web Workers com Claude Code, React e TypeScript
Implemente Web Workers tipados com Claude Code, Vite, React, cleanup, buffers transferíveis e testes.
Problemas de performance no navegador nem sempre aparecem como erro. Muitas vezes a pagina apenas parece travada. Um CSV grande congela o campo de busca, um filtro de imagem trava o scroll, um indice local de pesquisa atrasa o primeiro clique, e um parser de logs que parecia simples na demo sofre com um export real. Web Worker resolve esse tipo de problema movendo trabalho pesado para fora da thread principal.
A thread principal cuida da UI: renderizacao, clique, digitacao e scroll. Um Worker e um espaco separado para calculo. Ele nao deve tocar DOM, window, document, estado React, rota, toast ou CSS. O papel dele e receber dados, transformar e devolver resultado. Essa separacao precisa estar clara para Claude Code antes de gerar o codigo.
Use referencias oficiais na revisao: MDN Web Workers API, MDN Transferable objects, Vite Web Workers e Claude Code docs. Para regras de repositorio, leia tambem boas praticas de CLAUDE.md.
Arquitetura
O desenho recomendado e direto: React cuida da interface, um hook cuida do ciclo de vida do Worker, e o Worker cuida apenas de transformacoes caras. Essa fronteira evita que Claude Code misture UI com computacao.
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
Os cinco casos de uso cobrem situacoes comuns. CSV aggregation testa parsing numerico. Image processing testa buffers transferiveis. Search indexing testa arrays grandes. Log analysis testa texto em volume. Heavy JSON transform testa recursao e limites de tipo.
| Caso de uso | Entrada do Worker | Saida |
|---|---|---|
| CSV aggregation | Texto CSV | Linhas, colunas e estatisticas |
| Image processing | Buffer de ImageData | Buffer em tons de cinza |
| Search index | Documentos | Indice token-documento |
| Log analysis | Texto de logs | Erros, avisos e mensagens comuns |
| Heavy JSON transform | JSON aninhado | Objeto plano |
Nem todo loop precisa de Worker. Filtros pequenos e ordenacoes simples costumam ser mais baratos na thread principal. Worker tem custo de inicio, custo de mensagem e as vezes custo de copia. Use quando a UI perde fluidez, quando a tarefa se repete ou quando o tamanho dos dados e imprevisivel.
Prompt para Claude Code
O prompt deve listar arquivos, requisitos, proibicoes e verificacoes. A proibicao de DOM dentro do Worker e essencial.
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
Sem essa fronteira, Claude Code pode escrever codigo aparentemente correto com document.querySelector, notificacao ou navegacao dentro do Worker. Isso deve voltar para revisao.
Mensagens tipadas
Comece pelo contrato de mensagens. payload: any economiza alguns minutos e custa muitas revisoes depois.
// 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 };
Quando uma nova tarefa entrar, peça para Claude Code atualizar esse arquivo primeiro. Assim a forma das mensagens continua visivel.
Implementacao do Worker
O exemplo usa postMessage puro. Comlink pode ser melhor para uma API estilo RPC, mas para a primeira versao o fluxo explicito facilita revisao. Se migrar, compare com o 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)}`);
}
O trecho de imagem merece revisao cuidadosa. Transferable move o buffer; ele nao apenas copia mais rapido. Depois do envio, o buffer antigo fica detached.
Hook React e cleanup
O hook concentra criacao, respostas, erros e encerramento do Worker.
// 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 tambem rejeita promessas pendentes. Isso evita que uma resposta atrasada tente atualizar uma tela desmontada.
Componente React
O componente abaixo dispara as cinco tarefas e mostra o resultado em uma area de status.
// 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>
);
}
Testes e revisao
Combine Playwright com uma verificacao manual de responsividade.
// 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.
Armadilhas comuns
Primeira: tocar DOM dentro do Worker. Segunda: deixar mensagens sem tipo. Terceira: reutilizar um buffer depois de transferi-lo. Quarta: esquecer terminate(). Quinta: usar caminho fragil de bundler em vez de new URL(..., import.meta.url). Sexta: colocar API calls, analytics, routing e textos de UI no Worker.
CTA e nota de verificacao
Em projeto individual, copie protocolo, Worker, hook e demo para uma branch pequena e teste com CSV, imagem ou log real. Em equipe, transforme isso em regras de CLAUDE.md, prompts de review, testes Playwright e evidencia de performance. ClaudeCodeLab pode ajudar em treinamento e consultoria Claude Code usando o repositorio real. Para rotina diaria, leia tambem dicas de produtividade Claude Code.
O resultado pratico foi: CSV, busca e logs ficam claros com postMessage; imagem exige revisao cuidadosa de transferable; JSON pesado precisa de limite de recursao e validacao. Antes de publicar, revisei code fences, links internos, links oficiais, updatedDate, pitfalls e cleanup.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Escada de segurança de permissões no Claude Code
Amplie de read-only para edições limitadas, comandos de prova e deploy checks sem perder controle.
Claude Code Small PR Proof Pack: pequenas mudanças fáceis de revisar
Um pacote de prova para PRs do Claude Code: diff, checks, URL pública, CTA e rollback.
Gate de revisão antes do commit com Claude Code
Revisão antes do commit com Claude Code: diff, build, URL pública, Gumroad, consultoria, testes e arquivos fora do escopo.