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

Web Workers con Claude Code en React y TypeScript

Implementa Web Workers tipados con Claude Code, Vite, React, cleanup, transferable buffers y pruebas.

Web Workers con Claude Code en React y TypeScript

Un problema de performance en el navegador no siempre aparece como un error claro. A veces la pagina simplemente deja de responder: un CSV grande bloquea el input, un filtro de imagen congela el scroll, un indice de busqueda local retrasa el primer clic, o un parser de logs funciona en la demo pero se queda corto con datos reales. Web Worker sirve para mover ese trabajo pesado fuera del hilo principal.

El hilo principal es el lugar donde el navegador pinta la UI y atiende clics, escritura y scroll. Un Worker es un espacio de ejecucion separado para calculo. No puede tocar el DOM directamente y no deberia conocer React state, routing, toast messages ni estilos. Recibe datos, procesa y devuelve resultados. Esa separacion es la diferencia entre una implementacion robusta y un componente escondido dentro de un Worker.

Claude Code ayuda porque puede editar el protocolo, el Worker, el hook de React, el componente y las pruebas en un solo flujo. Pero tambien puede generar codigo convincente y equivocado si no se le dan limites. Para revisar, usa las fuentes oficiales: MDN Web Workers API, MDN Transferable objects, Vite Web Workers y Claude Code docs. Para reglas de repositorio, combina esto con buenas practicas de CLAUDE.md.

Arquitectura

El modelo recomendado es simple: React controla la interfaz, un hook controla el ciclo de vida del Worker, y el Worker controla transformaciones costosas. Antes de pedir codigo, muestra esta frontera a Claude 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

Los casos elegidos cubren problemas reales. CSV aggregation valida parsing numerico. Image processing valida transferable buffers. Search indexing valida arrays grandes. Log analysis valida mucho texto. Heavy JSON transform valida recursion y limites de tipos.

Caso de usoDatos enviados al WorkerResultado
CSV aggregationTexto CSVFilas, columnas y estadisticas
Image processingBuffer de ImageDataBuffer en escala de grises
Search indexLista de documentosIndice token-documento
Log analysisTexto de logsErrores, warnings, mensajes frecuentes
Heavy JSON transformJSON anidadoObjeto plano key-value

No conviertas cada bucle en un Worker. Filtrar 100 filas o ordenar una lista pequena suele ser mas barato en el hilo principal. Worker tiene coste de arranque, coste de mensaje y a veces coste de copia. Usalo cuando la UI pierde frames, cuando el trabajo se repite o cuando el tamano de datos no es predecible.

Prompt para Claude Code

Un prompt util nombra archivos, requisitos, limites y pruebas. El punto mas importante es prohibir DOM y estado de React dentro del 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

La parte de “do not touch” evita muchos errores. Si Claude Code pone document.querySelector, notificaciones, llamadas de API o navegacion dentro del Worker, el diseno ya se esta rompiendo.

Mensajes tipados

Primero crea el contrato. Empezar con payload: any parece rapido, pero en la tercera tarea ya produce dudas: que forma tiene el payload, que devuelve y donde se maneja el error.

// 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 };

Este archivo es el contrato que debes pedir a Claude Code que respete. Cada nueva tarea debe actualizar el union type, el resultado esperado y las pruebas.

Implementacion del Worker

El siguiente Worker usa postMessage sin librerias. Comlink tambien es valido para una API estilo RPC, pero el mensaje explicito es mas facil de auditar al principio. Si luego migras, revisa los mismos limites con el README de Comlink.

// 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)}`);
}

La parte de imagen merece revision especial. Transferable no significa “copiar mas rapido”; significa transferir propiedad. Despues de enviar el buffer, el origen queda detached.

Hook de React y cleanup

El hook crea el Worker, guarda las promesas pendientes y termina el Worker al desmontar. Asi el componente no repite new Worker() ni olvida limpiar.

// 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 };
}

El cleanup tambien rechaza promesas pendientes. Eso evita que una respuesta tardia actualice una pantalla que ya no existe.

Componente de ejemplo

Este componente no intenta ser una UI final. Su trabajo es demostrar que las cinco tareas se pueden disparar y que la pagina sigue respondiendo.

// 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>
  );
}

Pruebas y revision

Playwright confirma que los resultados llegan. La inspeccion manual confirma que el hilo principal sigue vivo.

// 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.

Errores comunes

Primer error: tocar DOM desde el Worker. Segundo: dejar que los mensajes deriven hacia any. Tercer error: no entender transferable y reutilizar un buffer ya transferido. Cuarto: olvidar terminate() y dejar Workers vivos tras cambiar de ruta. Quinto: usar una ruta de bundler fragil en lugar de new URL(..., import.meta.url). Sexto: meter API calls, analytics, routing y UI copy en el Worker.

CTA y nota de verificacion

Para un proyecto individual, copia el protocolo, el Worker, el hook y el componente en una rama pequena y pruebalo con tu CSV o log real. Para un equipo, conviene convertirlo en reglas de CLAUDE.md, prompts de review, pruebas Playwright y recibos de performance. ClaudeCodeLab puede ayudarte en training y consultoria Claude Code usando un repositorio real. Para habitos diarios, revisa tambien tips de productividad Claude Code.

El resultado practico de esta implementacion es claro: CSV, busqueda y logs funcionan bien con postMessage; imagenes requieren revisar transferable con mas cuidado; JSON pesado necesita limites de recursion y validacion de entrada. Antes de publicar, comprobe que el articulo incluye codigo copiable, enlaces internos, enlaces oficiales, updatedDate y una seccion de fallos concretos.

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

PDF gratis: cheatsheet de Claude Code

Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.

Cuidamos tus datos y no enviamos spam.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.