Karya Semi
HomeBlogSearchCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

XGitHubLinkedIn
  1. Home
  2. /Categories
  3. /Web Development

Offloading Heavy Computations with Web Workers in Modern JavaScript

Keep your UI responsive by running CPU-heavy tasks in the background. Learn how to implement javascript web workers to boost performance and prevent page freezing.

Dian Rijal Asyrof/August 13, 2026/7 min read
Illustration for Offloading Heavy Computations with Web Workers in Modern JavaScript

We have all seen it. You click a button on a web app—like a button to download multiple files as a ZIP—and the page instantly freezes. The loading spinner stops spinning, the hover effects on buttons disappear, and after a few seconds, the browser asks if you want to kill the page.

This happens because JavaScript is single-threaded. When you run a heavy computation on the main thread, you block everything else. The browser cannot paint, it cannot handle clicks, and it cannot scroll. Web Workers solve this by giving us access to true background threads.

The Main Thread Bottleneck

To understand why we need workers, we have to look at how the browser manages tasks. The main thread has a lot of responsibilities. It executes JavaScript, calculates styles, performs layout operations, and paints the pixels on the screen.

For a web app to feel smooth, the browser needs to update the screen 60 times per second. This means the browser has a budget of about 16.6 milliseconds per frame. If any single task on the main thread takes longer than that, the browser misses a frame. The user experiences this as a stutter, which is a primary cause of poor responsiveness when optimizing Core Web Vitals.

Most web applications spend their time waiting for network requests or handling user input. JavaScript is great at this because of its asynchronous event loop. But async code does not mean parallel code. Running a heavy mathematical calculation, parsing a massive JSON payload, or processing an image still blocks the main thread. The event loop cannot process the next task until the current one finishes.

Enter Web Workers

A Web Worker is a script that runs in a separate thread, completely isolated from the main thread. This means you can run complex calculations for seconds or even minutes without affecting the responsiveness of the user interface.

Because workers run in a different thread, they operate in a different environment. They do not run in the context of the window object. Instead, they run inside DedicatedWorkerGlobalScope.

This isolation comes with strict limitations:

  • No DOM Access: You cannot manipulate HTML elements, read layout properties, or query selectors from a worker.
  • No Window Objects: You do not have access to window.localStorage, window.location, or the document object.
  • Limited APIs: You can use standard JavaScript features, fetch for network requests, IndexedDB for storage, WebSockets, and timers like setTimeout and setInterval.

Communication between the main thread and the worker is event-driven. They send messages back and forth using the postMessage API.

Building a Basic Worker

Let's look at how to set up a basic worker. We will start with two files: main.js and worker.js.

First, the main thread script:

// main.js
const worker = new Worker('worker.js');
 
// Send data to the worker
worker.postMessage({ number: 40 });
 
// Listen for messages from the worker
worker.onmessage = (event) => {
  console.log('Result from worker:', event.data);
};
 
// Handle errors
worker.onerror = (error) => {
  console.error('Worker error:', error.message);
};

Next, the worker script:

// worker.js
self.onmessage = (event) => {
  const { number } = event.data;
  
  // Perform some work
  const result = fibonacci(number);
  
  // Send the result back
  self.postMessage(result);
};
 
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

When you instantiate a worker using new Worker('worker.js'), the browser starts a new operating system thread and loads the script. The two threads communicate by passing messages. The main thread sends a number, the worker calculates the Fibonacci sequence, and it sends the result back. During the calculation, the main thread is completely free to handle user input.

Offloading a Real-World Task

Let's look at a more practical example. Imagine an application that processes large arrays of objects, like a dashboard that filters and aggregates thousands of sales transactions.

Doing this on the main thread can cause noticeable delay. Let's move the sorting and filtering logic to a worker.

Here is our worker script, which handles data processing:

// processor.worker.js
self.onmessage = (event) => {
  const { data, filterQuery, sortBy } = event.data;
  
  const filtered = data.filter(item => 
    item.category.toLowerCase() === filterQuery.toLowerCase()
  );
  
  const sorted = filtered.sort((a, b) => {
    if (a[sortBy] < b[sortBy]) return -1;
    if (a[sortBy] > b[sortBy]) return 1;
    return 0;
  });
  
  self.postMessage(sorted);
};

In the main application, we can wrap this worker communication in a Promise to make it easier to use with async/await syntax:

// main.js
function processData(data, filterQuery, sortBy) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('processor.worker.js');
    
    worker.postMessage({ data, filterQuery, sortBy });
    
    worker.onmessage = (event) => {
      resolve(event.data);
      worker.terminate(); // Clean up the worker thread
    };
    
    worker.onerror = (error) => {
      reject(error);
      worker.terminate();
    };
  });
}
 
// Usage
async function updateUI() {
  showSpinner();
  try {
    const largeDataset = await fetchLargeDataset();
    const processed = await processData(largeDataset, 'electronics', 'price');
    renderTable(processed);
  } catch (error) {
    showError(error);
  } finally {
    hideSpinner();
  }
}

Notice the call to worker.terminate() in the Promise resolver. Workers use system resources. If you spin up a new worker for every task and never close them, you will leak memory. Terminating the worker kills the thread and frees up those resources.

Data Transfer Mechanics: Structured Clone vs. Transferables

When you send data to a worker using postMessage, the browser does not share the memory address of that data. Instead, it copies the data.

The browser uses the Structured Clone Algorithm to serialize the object, send it across the thread boundary, and deserialize it on the other side. This prevents race conditions where both threads try to modify the same object at the same time.

For small datasets, this cloning process is fast. But if you send a 100MB array or a large image buffer, the serialization and deserialization can take tens of milliseconds. This copy operation runs on the main thread, which defeats the purpose of using a worker.

To solve this, JavaScript offers Transferable Objects.

Transferables

When you transfer an object, you pass the actual memory address to the worker. There is no copy operation. The transfer is nearly instantaneous, regardless of the data size.

The catch is that once you transfer an object, it becomes unusable on the main thread. The main thread loses access to the memory buffer.

Only specific types of objects can be transferred, such as ArrayBuffer, MessagePort, and ImageBitmap.

Here is how you use transferables:

// main.js
const worker = new Worker('worker.js');
 
// Create a 32MB buffer
const buffer = new ArrayBuffer(32 * 1024 * 1024); 
const view = new Int32Array(buffer);
// Fill the array with data
for (let i = 0; i < view.length; i++) {
  view[i] = i;
}
 
// Send the buffer to the worker as a transferable object
// The second argument is an array of objects to transfer
worker.postMessage({ data: buffer }, [buffer]);
 
console.log(buffer.byteLength); // 0 (The buffer is now empty on the main thread)

In the worker, you receive the buffer normally:

// worker.js
self.onmessage = (event) => {
  const buffer = event.data.data;
  const view = new Int32Array(buffer);
  
  // Process the data in place
  for (let i = 0; i < view.length; i++) {
    view[i] = view[i] * 2;
  }
  
  // Transfer it back to the main thread
  self.postMessage({ data: buffer }, [buffer]);
};

This zero-copy mechanism is critical when working with raw binary data, like WebGL textures, audio processing buffers, or large CSV datasets parsed into binary arrays.

Shared Memory with SharedArrayBuffer

If transferring ownership is not enough, you can share memory between the main thread and workers using SharedArrayBuffer. This allows both threads to read and write to the same memory space directly.

To prevent threads from writing over each other's data at the same time, you use Atomics to manage synchronization.

// main.js
const worker = new Worker('worker.js');
const sharedBuffer = new SharedArrayBuffer(1024);
const sharedArray = new Int32Array(sharedBuffer);
 
worker.postMessage({ sharedBuffer });
 
// Write to shared memory safely
Atomics.store(sharedArray, 0, 123);

Using SharedArrayBuffer requires strict security headers on your server due to hardware-level vulnerabilities like Spectre. You must serve your page with:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Without these headers, SharedArrayBuffer is disabled in modern browsers.

Modern Tooling and Vite Integration

Writing raw worker files can be clunky when using modern bundlers, especially when managing frontend bundle optimization to eliminate dead code. You often want to write workers in TypeScript, import utility functions, and bundle them with the rest of your app.

Vite supports Web Workers out of the box. If you are upgrading your build setup, you can refer to the Vite 8 migration guide to ensure a smooth transition. You can import a worker file by using the ?worker suffix or by using the standard constructor syntax.

// main.ts
import MyWorker from './worker.ts?worker';
 
const worker = new MyWorker();
worker.postMessage('hello');

Alternatively, you can use the standard syntax, which Vite detects and bundles correctly:

// main.ts
const worker = new Worker(
  new URL('./worker.ts', import.meta.url),
  { type: 'module' }
);

Using { type: 'module' } allows you to use standard ES imports inside your worker script.

Comlink: Simplifying the Messaging API

The message-based API of Web Workers can lead to messy code as your application grows. You end up writing large switch statements to handle different message types.

Google Chrome Labs created a library called Comlink that wraps Web Workers in an RPC (Remote Procedure Call) interface. It makes functions inside a worker look like standard async functions on the main thread.

Here is how you use Comlink:

First, define the worker:

// worker.js
import * as Comlink from 'comlink';
 
const api = {
  add(a, b) {
    return a + b;
  },
  heavyCalculation(data) {
    // perform work
    return data.map(x => x * 2);
  }
};
 
Comlink.expose(api);

Then, use it on the main thread:

// main.js
import * as Comlink from 'comlink';
 
async function init() {
  const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
  const api = Comlink.wrap(worker);
  
  // Call worker methods as if they were local async functions
  const sum = await api.add(40, 2);
  console.log(sum); // 42
  
  const result = await api.heavyCalculation([1, 2, 3]);
  console.log(result); // [2, 4, 6]
}
 
init();

Comlink handles the underlying postMessage calls and event listeners under the hood, making your codebase clean and maintainable.

Debugging Web Workers

Debugging code running in a background thread can be tricky. Fortunately, modern browser dev tools have good support for workers.

In Chrome DevTools, workers appear in the Sources panel under a dedicated section, or in the Threads list in the debugger. You can set breakpoints inside your worker code just like you would on the main thread.

Console logs inside a worker will print to the main console, but they are usually tagged with the name of the worker thread so you can tell them apart.

If you need to inspect network requests made by a worker, make sure the "All" or "Fetch/XHR" filter is selected in the Network tab. Some browsers show worker requests with a distinct gear icon next to them.

When Not to Use Web Workers

While workers are useful, they are not a default solution for every performance issue. They come with overhead.

  • Startup Cost: Creating a new worker takes time. The browser has to spin up a thread and compile the script. Do not create workers for quick tasks that take less than 10-15 milliseconds.
  • Serialization Overhead: If you send large objects without using transferables, the time spent copying the data can be longer than the execution time of the task on the main thread.
  • Complexity: Introducing workers means managing asynchronous state, handling errors across threads, and dealing with bundler configurations.

Keep simple tasks on the main thread. Use workers when you have clear, heavy computations that demonstrably degrade the user experience.

DR

Dian Rijal Asyrof

Writes about useful AI tools, programming practice, and the craft of building reliable software.

Previous articleTailscale Traces Database Corruption to a 16-Year-Old SQLite WAL BugNext articleResearcher Publishes Windows Zero-Day After Microsoft Legal Threats
Web DevelopmentJavaScriptFrontend
On this page↓
  1. The Main Thread Bottleneck
  2. Enter Web Workers
  3. Building a Basic Worker
  4. Offloading a Real-World Task
  5. Data Transfer Mechanics: Structured Clone vs. Transferables
  6. Transferables
  7. Shared Memory with SharedArrayBuffer
  8. Modern Tooling and Vite Integration
  9. Comlink: Simplifying the Messaging API
  10. Debugging Web Workers
  11. When Not to Use Web Workers
  12. Keep simple tasks on the main thread. Use workers when you have clear, heavy computations that demonstrably degrade the user experience.

On this page

  1. The Main Thread Bottleneck
  2. Enter Web Workers
  3. Building a Basic Worker
  4. Offloading a Real-World Task
  5. Data Transfer Mechanics: Structured Clone vs. Transferables
  6. Transferables
  7. Shared Memory with SharedArrayBuffer
  8. Modern Tooling and Vite Integration
  9. Comlink: Simplifying the Messaging API
  10. Debugging Web Workers
  11. When Not to Use Web Workers
  12. Keep simple tasks on the main thread. Use workers when you have clear, heavy computations that demonstrably degrade the user experience.

See also

Illustration for The Download-as-ZIP Button That Crashes Your Users' Browsers
Web Development/Aug 11, 2026

The Download-as-ZIP Button That Crashes Your Users' Browsers

Learn how to implement a robust React download multiple files zip browser feature that handles massive archives with progress tracking and cancellation.

6 min read
ReactJavaScript
Illustration for Vite 8 Migration Guide: Breaking Changes and Upgrade Checklist
Web Development/Aug 8, 2026

Vite 8 Migration Guide: Breaking Changes and Upgrade Checklist

A complete step-by-step developer playbook to upgrade your project to Vite 8, handle the Rolldown/Oxc transition, and fix breaking changes.

7 min read
ViteFrontend
Illustration for Jane Street Built a UI Library in OCaml, Web Developers Should Pay Attention
Web Development/Aug 4, 2026

Jane Street Built a UI Library in OCaml, Web Developers Should Pay Attention

Jane Street just open-sourced Bonsai, their OCaml-based UI library. Sounds irrelevant to web devs? It's actually a signal about where frontend architecture is heading.

5 min read
Web DevelopmentFrontend