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

Web Worker dengan Claude Code, React, dan TypeScript

Bangun Web Worker bertipe dengan Claude Code, Vite, React, cleanup, transferable buffer, dan pengujian.

Web Worker dengan Claude Code, React, dan TypeScript

Masalah performa di browser tidak selalu muncul sebagai error. Sering kali halaman hanya terasa beku. Import CSV membuat input berhenti, filter gambar membuat scroll patah-patah, pembuatan indeks pencarian lokal menunda klik pertama, dan parser log yang aman di demo bisa macet saat menerima export support sebenarnya. Web Worker dipakai untuk memindahkan kerja CPU-heavy dari main thread.

Main thread adalah tempat browser menggambar UI dan menangani klik, input, serta scroll. Worker adalah ruang kerja terpisah untuk komputasi. Worker tidak boleh menyentuh DOM secara langsung dan sebaiknya tidak tahu tentang React state, routing, toast, atau CSS. Worker menerima data, memproses, lalu mengirim hasil kembali.

Claude Code cocok untuk pekerjaan ini karena beberapa file harus berubah bersama: protocol, Worker, React hook, component, dan test. Tetapi tanpa batasan jelas, Claude Code dapat membuat implementasi yang terlihat masuk akal namun salah. Saat review, pakai referensi resmi: MDN Web Workers API, MDN Transferable objects, Vite Web Workers, dan Claude Code docs. Untuk aturan repo, baca juga CLAUDE.md best practices.

Arsitektur

Model yang aman sederhana: React mengurus UI, hook mengurus lifecycle Worker, dan Worker hanya mengurus transformasi data yang berat. Berikan batas ini ke Claude Code sebelum meminta implementasi.

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

Artikel ini memakai lima use case. CSV aggregation menguji parsing angka. Image processing menguji transferable buffer. Search indexing menguji array besar. Log analysis menguji teks panjang. Heavy JSON transform menguji rekursi dan batas tipe.

Use caseData ke WorkerHasil
CSV aggregationTeks CSVBaris, kolom, statistik
Image processingBuffer ImageDataBuffer grayscale
Search indexDokumenIndeks token-dokumen
Log analysisTeks logError, warning, pesan teratas
Heavy JSON transformJSON bertingkatObject datar

Tidak semua loop perlu Worker. Filter kecil dan sort sederhana sering lebih murah di main thread. Worker punya biaya start, biaya message, dan kadang biaya copy. Gunakan saat UI kehilangan respons, tugas sering berjalan, atau ukuran data sulit diprediksi.

Prompt untuk Claude Code

Prompt pertama harus menyebut file, requirement, larangan, dan cara verifikasi. Larangan DOM di dalam Worker harus eksplisit.

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

Bagian “do not touch” sangat penting. Jika Claude Code menaruh document.querySelector, navigasi, toast, atau API call di Worker, minta revisi.

Protocol bertipe

Mulai dari kontrak pesan. payload: any terlihat cepat, tetapi membuat caller menebak bentuk data.

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

Saat menambah job baru, minta Claude Code mengubah protocol ini lebih dulu, lalu Worker, hook, dan test. Dengan begitu kontrak tidak tersebar di banyak component.

Implementasi Worker

Contoh ini memakai postMessage langsung. Comlink juga bisa dipakai untuk API bergaya RPC, tetapi pesan eksplisit lebih mudah diaudit pada implementasi pertama. Jika perlu pindah, bandingkan dengan 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)}`);
}

Bagian image paling penting untuk direview. Transferable memindahkan kepemilikan buffer. Setelah dikirim, buffer lama tidak boleh dipakai lagi.

Hook React dan cleanup

Hook ini membuat Worker, menyimpan promise yang pending, menangani error, dan memanggil terminate() saat unmount.

// 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 bukan hanya soal memori. Ia juga mencegah response terlambat masuk ke layar yang sudah hilang.

Component React

Component demo ini memanggil lima job dan menampilkan hasilnya.

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

Test dan review

Automated test memastikan hasil muncul. Manual inspection memastikan halaman tetap responsif.

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

Pitfall umum

Pertama, Worker menyentuh DOM. Kedua, pesan dibiarkan tanpa tipe. Ketiga, transferable dianggap seperti copy biasa. Keempat, Worker tidak di-terminate(). Kelima, path bundler rapuh dan tidak memakai new URL(..., import.meta.url). Keenam, API call, analytics, routing, dan copy UI masuk ke Worker.

CTA dan catatan verifikasi

Untuk project pribadi, salin protocol, Worker, hook, dan demo ke branch kecil lalu uji dengan CSV, gambar, atau log nyata. Untuk tim, ubah ini menjadi aturan CLAUDE.md, prompt review, Playwright check, dan bukti performa. ClaudeCodeLab dapat membantu melalui training dan konsultasi Claude Code dengan repository nyata. Untuk rutinitas harian, baca juga tips produktivitas Claude Code.

Hasil praktiknya: CSV, search, dan log cukup jelas dengan postMessage; image processing paling membutuhkan review transferable; JSON berat perlu batas rekursi dan validasi input. Sebelum publikasi, artikel ini dicek untuk code fence, internal link, official link, updatedDate, pitfall konkret, dan cleanup note.

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

PDF gratis: cheatsheet Claude Code

Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.

Kami menjaga datamu dan tidak mengirim spam.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.