Web Workers with Comlink: Run Heavy Tasks Without Blocking the UI
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: Workers That Feel Like Functions
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!
Using Comlink in a Next.js React Component
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.
Admin
Cal.com
Open source scheduling — tự host booking system, thay thế Calendly. Free & privacy-first.
Bình luận (0)
Đăng nhập để bình luận
Chưa có bình luận nào. Hãy là người đầu tiên!