You might not need Comlink

Note: This article is a draft. Examples from the Evolu codebase are coming soon.

Comlink is a popular library (12.5k stars) that makes Web Workers feel like calling async functions. It's well-designed and tiny (~1.1kB). Many projects use it successfully.

But after evaluating it for Evolu, I decided to use plain web APIs instead. Here's why.

Comlink wraps postMessage with ES6 Proxy, so instead of:

worker.postMessage({ type: "query", sql: "SELECT * FROM users" });
worker.onmessage = (e) => handleResult(e.data);

You write:

const result = await workerProxy.query("SELECT * FROM users");

It also provides Comlink.proxy() for callbacks, Comlink.transfer() for transferables, and supports SharedWorker, iframes, and Node's worker_threads.

The Proxy abstraction leaks

Debugging is harder

When you console.log a Comlink proxy, you don't see the actual object - you see a Proxy. Setting breakpoints and inspecting state requires understanding what's happening under the hood.

Performance overhead

While Proxy performance is fast enough for most use cases, issue #647 showed real bottlenecks under load. Proxies must also be manually released with proxy[Comlink.releaseProxy](), otherwise they leak memory. Comlink uses WeakRef for automatic cleanup, but it doesn't work reliably with SharedWorkers (issue #673).

The alternative: plain web APIs

Instead of Comlink's Proxy abstraction, Evolu uses MessageChannel and a simple Callbacks<T> utility for request-response correlation:

interface Callbacks<T> {
  register: (callback: (arg: T) => void) => CallbackId;
  execute: (id: CallbackId, arg: T) => void;
}

Combined with Promise.withResolvers, you get clean RPC:

const callbacks = createCallbacks<QueryResult>(deps);

const query = async (sql: string): Promise<QueryResult> => {
  const { promise, resolve } = Promise.withResolvers<QueryResult>();
  const callbackId = callbacks.register(resolve);
  queryPort.postMessage({ sql, callbackId });
  return promise;
};

// When response arrives:
queryPort.onmessage = (e) => {
  callbacks.execute(e.data.callbackId, e.data.result);
};

No Proxy, no WeakRef finalization registry, no magic. Just a Map of pending callbacks. It works with any transport - postMessage, WebSocket, whatever.

TODO: More examples from Evolu codebase showing MessageChannel patterns:

  • init message handshake
  • HeartBeat for connection monitoring
  • different topologies (notify selected tabs, broadcast to all, etc.)
  • helpers

The maintenance situation

Comlink has 76 open issues and 40 open PRs - some from 2022, waiting 3+ years. The maintainers are too busy and looking for help. Even merged PRs don't get released to npm. There's a PR #683 that fixes many issues, but it has significant breaking changes and has been blocked on CLA since October 2024.

Conclusion

Comlink is a good library for simple cases - offloading computation to a worker with straightforward request-response patterns.

But if you need:

  • Explicit initialization handshakes
  • SharedWorker with multiple tabs
  • Reliable cleanup
  • Easy debugging
  • Full control over message flow
  • Different topologies (broadcast to all tabs, notify selected tabs, etc.)

...consider using plain web APIs with simple helpers. MessageChannel gives you typed, independent pipes. A callbacks Map gives you request-response correlation. It's more code upfront, but it's code you understand and control.

Sometimes the "boring" approach is the right one.