Bỏ qua để vào nội dung chính
Web Workers with Comlink: Run Heavy Tasks Without Blocking the UI

Web Workers with Comlink: Run Heavy Tasks Without Blocking the UI

Bởi Olivia Reed
15 thg 3, 20266 phút đọc

Web Workers are powerful but painful to use raw. Comlink makes them feel like regular async functions. Learn how to offload heavy computation without janky UIs.

You've profiled your app and found the culprit: a heavy computation — parsing a large JSON blob, running a search algorithm, processing image data — is blocking the main thread and causing jank. You know Web Workers can help, but the postMessage API feels like writing code from 2010. Enter Comlink.

The Problem: Main Thread Bottleneck

JavaScript is single-threaded. When your React component triggers a heavy computation, everything freezes — animations stutter, inputs become unresponsive, and users reach for the back button. Web Workers run code in a separate thread, but the native API is verbose and error-prone:

// The painful way — raw Web Worker
const worker = new Worker("./worker.js");

worker.postMessage({ type: "PARSE_CSV", data: largeCSVString });

worker.onmessage = (event) => {
  if (event.data.type === "PARSE_CSV_RESULT") {
    setResults(event.data.payload);
  }
};

worker.onerror = (error) => {
  console.error("Worker error:", error);
};

You're manually serializing message types, handling responses with callbacks, and losing all type safety. For anything beyond a trivial example, this becomes unmanageable.

Comlink (by the Chrome team) wraps the postMessage channel with ES Proxies, making worker functions callable as if they were local async functions:

// worker.ts — runs in a separate thread
import * as Comlink from "comlink";

const api = {
  parseCSV(csv: string): Record<string, string>[] {
    // Heavy parsing logic — won't block the UI
    const lines = csv.split("
");
    const headers = lines[0].split(",");
    return lines.slice(1).map((line) => {
      const values = line.split(",");
      return Object.fromEntries(headers.map((h, i) => [h, values[i]]));
    });
  },

  async searchDocuments(query: string, docs: string[]): Promise<string[]> {
    // Expensive fuzzy search
    return docs.filter((doc) =>
      doc.toLowerCase().includes(query.toLowerCase())
    );
  },
};

export type WorkerApi = typeof api;
Comlink.expose(api);

// main.ts — call worker functions like normal async code
import * as Comlink from "comlink";
import type { WorkerApi } from "./worker";

const worker = new Worker(new URL("./worker.ts", import.meta.url));
const api = Comlink.wrap<WorkerApi>(worker);

// Just await it — Comlink handles the message passing
const results = await api.parseCSV(csvData);
console.log(results); // Fully typed!

Here's a practical pattern for integrating Comlink workers into React with proper lifecycle management:

"use client";

import { useEffect, useRef, useState, useCallback } from "react";
import * as Comlink from "comlink";
import type { WorkerApi } from "./search-worker";

export function HeavySearch({ documents }: { documents: string[] }) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<string[]>([]);
  const [searching, setSearching] = useState(false);
  const workerRef = useRef<Comlink.Remote<WorkerApi> | null>(null);

  useEffect(() => {
    const worker = new Worker(
      new URL("./search-worker.ts", import.meta.url)
    );
    workerRef.current = Comlink.wrap<WorkerApi>(worker);

    return () => worker.terminate();
  }, []);

  const handleSearch = useCallback(async (searchQuery: string) => {
    if (!workerRef.current || !searchQuery) return;
    setSearching(true);
    const matches = await workerRef.current.searchDocuments(
      searchQuery,
      documents
    );
    setResults(matches);
    setSearching(false);
  }, [documents]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => {
          setQuery(e.target.value);
          handleSearch(e.target.value);
        }}
        placeholder="Search documents..."
      />
      {searching && <p>Searching...</p>}
      {results.map((r, i) => (
        <div key={i}>{r}</div>
      ))}
    </div>
  );
}

When to Reach for Web Workers

Not everything belongs in a worker. The overhead of serializing data through postMessage (even with Comlink) means small operations are faster on the main thread. Use workers for:

  • CSV/JSON parsing of large datasets (> 1MB)
  • Fuzzy search across thousands of documents
  • Image processing or canvas operations
  • Markdown/code syntax highlighting of large files
  • Cryptographic operations

Transferable Objects for Zero-Copy Performance

For binary data (ArrayBuffers, ImageData), use Comlink's transfer() to move ownership instead of copying:

import { transfer } from "comlink";

// Zero-copy transfer of an ArrayBuffer to the worker
const buffer = new ArrayBuffer(1024 * 1024);
await api.processBuffer(transfer(buffer, [buffer]));
// buffer is now neutered (empty) — ownership moved to worker

Takeaways

  • Comlink eliminates Web Worker boilerplate — worker functions become async calls
  • Always terminate workers in React's cleanup function to prevent memory leaks
  • Use transfer() for large binary data to avoid serialization overhead
  • Profile first — only move computation to workers when it actually blocks the main thread (> 16ms)

Web Workers have been available for over a decade, but tooling like Comlink finally makes them practical for everyday frontend development. If your users are experiencing jank, this is one of the highest-impact optimizations you can make.

Không spam, hủy đăng ký bất kỳ lúc nào.

Bài viết liên quan