Back to Blog
When Your Frontend Needs a Dedicated CPU: Offloading to Web Workers with Comlink
7 min readJul 29, 20266 views

When Your Frontend Needs a Dedicated CPU: Offloading to Web Workers with Comlink

You've got a complex calculation, an intensive image manipulation, or maybe just a really big data transformation. You throw it into an async function, sprinkle in an 'await', and hope for the best. But when that 'best' still means a janky UI and frustrated users, it's time to give that heavy liftin

Web DevelopmentPerformanceToolingSoftware Design
Share

by Sunil Band

Your 'Async' Isn't Asynchronous Enough

We all love async/await. It makes dealing with asynchronous operations in JavaScript feel almost synchronous, taming callback hell and improving readability dramatically. But there's a fundamental misunderstanding many of us carry into our frontend development: async/await doesn't mean your code is running on a separate thread. It just means the event loop won't block while waiting for an I/O operation or a Promise to resolve. Your heavy computation, even if wrapped in async, is still hogging the main thread, freezing the UI, and making your app feel sluggish.

I've been there. You have a component that needs to crunch a large dataset received from an API before rendering. You stick a useEffect with an async function, thinking you're being smart. The data loads, the calculation starts, and suddenly, your smooth 60fps UI drops to a crawl, or worse, completely freezes for a few seconds. The user can't click buttons, can't scroll, can't do anything. That's the main thread begging for mercy.

The browser's main thread handles everything related to rendering, user input, and JavaScript execution. When you run a CPU-intensive task on it, you're essentially telling the browser to put everything else on hold. This isn't just an inconvenience; it's a direct hit to user experience and perceived performance. This is where Web Workers come in.

Web Workers: Your Frontend's Secret Weapon for Concurrency

Web Workers provide a way to run scripts in background threads, separate from the main execution thread of a web page. This means you can perform CPU-intensive tasks without blocking the user interface. Think of it as giving your browser an extra core to work with, dedicated solely to your long-running computations.

Creating a Web Worker is straightforward. You instantiate a Worker object, passing it the URL of a script that will run in the background. Communication between the main thread and the worker happens via messages, using postMessage and onmessage.

Let's look at a simple example. Suppose we want to perform a computationally expensive factorial calculation.

typescript
// main.ts (main thread)
const worker = new Worker('./worker.ts'); // Create a new worker instance

worker.onmessage = (event) => { // Listen for messages from the worker
  console.log('Result from worker:', event.data);
  // Update UI or perform other actions with the result
};

worker.postMessage(10); // Send a number to the worker for calculation

// worker.ts (worker thread)
function factorial(n: number): number {
  if (n === 0 || n === 1) {
    return 1;
  }
  let result = 1;
  for (let i = 2; i <= n; i++) {
    result *= i;
  }
  return result;
}

onmessage = (event) => { // Listen for messages from the main thread
  const num = event.data as number;
  const result = factorial(num);
  postMessage(result); // Send the result back to the main thread
};

This setup works, but it quickly becomes cumbersome. You have to manage message passing manually: sending data, receiving data, handling errors. What if you want to call a function directly on the worker, almost as if it were a local module? This is where libraries like Comlink shine.

Comlink is a tiny (less than 2KB gzipped) library from Google that abstracts away the complexity of message passing. It allows you to expose functions and objects from a Web Worker to the main thread (and vice-versa) as if they were local objects, using a Remote Procedure Call (RPC) pattern. This significantly cleans up your code and makes working with Web Workers much more ergonomic.

With Comlink, you define the API of your worker as a JavaScript object. Then, you expose it. On the main thread, you wrap the worker, and suddenly, you have a proxy object that allows you to call methods on the worker directly, returning Promises for the results.

Let's refactor our factorial example using Comlink.

typescript
// worker.ts (worker thread)
import * as Comlink from 'comlink';

const calculations = {
  factorial: (n: number): number => {
    if (n === 0 || n === 1) {
      return 1;
    }
    let result = 1;
    for (let i = 2; i <= n; i++) {
      result *= i;
    }
    return result;
  },
  // You can expose other complex functions here too
  sumArray: (arr: number[]): number => arr.reduce((acc, val) => acc + val, 0),
};

Comlink.expose(calculations);

// main.ts (main thread)
import * as Comlink from 'comlink';

interface WorkerCalculations {
  factorial(n: number): Promise<number>;
  sumArray(arr: number[]): Promise<number>;
}

async function runWorkerCalculations() {
  // Create a worker instance
  const worker = new Worker('./worker.ts');

  // Wrap the worker with Comlink to get a proxy object
  const calculations = Comlink.wrap<WorkerCalculations>(worker);

  // Now call methods on the worker directly as if they were local
  console.log('Calculating factorial of 10...');
  const factorialResult = await calculations.factorial(10); // Returns a Promise!
  console.log('Factorial Result:', factorialResult);

  console.log('Calculating sum of a large array...');
  const largeArray = Array.from({ length: 10_000_000 }, (_, i) => i);
  const sumResult = await calculations.sumArray(largeArray);
  console.log('Sum Result:', sumResult);

  // Remember to terminate the worker when done, especially if it's not long-lived
  worker.terminate();
}

runWorkerCalculations();

Notice how the main.ts code is much cleaner. We're calling calculations.factorial(10) as if it's a regular function, but under the hood, Comlink handles all the postMessage and onmessage boilerplate. The Promise<number> return type accurately reflects that the operation is asynchronous and happening in another thread.

This pattern is incredibly powerful. You can pass complex objects, functions, and even MessagePorts (for transferring ownership of an I/O channel) between threads. Comlink uses Transferable objects under the hood where possible (e.g., ArrayBuffer) to optimize performance by moving data without copying it, further reducing overhead for large data transfers.

Practical Use Cases

Where would you use this in a real application? Anywhere you have significant computation that can't be easily offloaded to a backend API:

  • Image Processing: Resizing, filtering, or compressing images directly in the browser.
  • Large Data Manipulation: Filtering, sorting, or aggregating massive datasets received from an API before displaying them.
  • Cryptographic Operations: Hashing, encryption, or decryption that might be required client-side.
  • Complex Simulations/Calculations: Any heavy number-crunching for things like financial models, scientific simulations, or game logic.
  • Offline Data Sync: Syncing large amounts of data with an IndexedDB database.

In all these scenarios, using a Web Worker ensures that your UI remains responsive, providing a much smoother user experience.

Trade-offs and Gotchas

While Web Workers and Comlink are powerful, they aren't a silver bullet. There are a few important considerations:

1. No DOM Access

Web Workers do not have access to the DOM. This is by design, as it prevents race conditions and simplifies the worker's execution model. If your heavy computation needs to interact with the DOM, you'll need to perform the computation in the worker and then send the results back to the main thread for DOM manipulation.

2. Communication Overhead

While Comlink makes communication easy, there's still overhead involved in serializing and deserializing data between threads. For very small, frequent operations, the overhead of message passing might outweigh the benefits of parallelization. You need to consider if the task is genuinely CPU-bound and long-running enough to justify the communication cost.

3. Debugging Complexity

Debugging code running in a Web Worker can be slightly more complex than debugging main thread code. Browser developer tools usually provide a way to inspect worker threads, but it's an extra step. Breakpoints in worker scripts behave slightly differently, and you need to be mindful of which context you're debugging.

4. Module Bundling

Setting up workers with modern module bundlers like Webpack, Rollup, or Vite requires specific configurations. You typically need to use a plugin or a special import syntax (like new Worker(new URL('./worker.ts', import.meta.url))) to ensure the worker script is correctly bundled and served. This isn't usually a deal-breaker, but it's an extra configuration step.

5. Worker Lifecycle Management

Workers consume resources. It's important to terminate workers when they are no longer needed using worker.terminate() to free up memory and CPU cycles. For long-lived tasks, you might have one persistent worker. For short, bursty tasks, you might spin up and tear down workers as needed. Comlink doesn't manage the worker lifecycle itself; that's still your responsibility.

Wrapping up

Don't let your frontend applications feel sluggish because of heavy client-side computations. Understanding the limitations of the JavaScript event loop and leveraging Web Workers is a critical skill for building truly responsive web applications. Comlink simplifies the often-tedious process of inter-thread communication, allowing you to focus on the logic rather than the plumbing.

Your concrete next step: try it out. Grab a small, CPU-intensive function from your current codebase – perhaps a complex data transformation or a regex operation over a large string – and refactor it to run in a Web Worker using Comlink. You'll likely be surprised by the immediate improvement in UI responsiveness. The Comlink GitHub repo is a great place to start, with plenty of examples to get you going.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕