MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Web Workers: Offloading Heavy Computation

Use Web Workers for background threads: message passing, SharedArrayBuffer, and patterns.

Web WorkersPerformanceJavaScriptFrontend

By MinhVo

Introduction

JavaScript runs on a single thread by default. Long-running computations block the main thread, causing the UI to freeze and become unresponsive. Web Workers solve this by running JavaScript in background threads, keeping the main thread free for user interactions. This guide covers dedicated workers, shared workers, and advanced patterns like SharedArrayBuffer and transferable objects.

Threading model

Dedicated Web Workers

Creating a Worker

// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url));
 
worker.postMessage({ type: 'calculate', data: [1, 2, 3, 4, 5] });
 
worker.onmessage = (event) => {
  console.log('Result:', event.data);
};
 
worker.onerror = (error) => {
  console.error('Worker error:', error);
};
// worker.js
self.onmessage = (event) => {
  const { type, data } = event.data;
 
  if (type === 'calculate') {
    const result = data.reduce((sum, n) => sum + n * n, 0);
    self.postMessage({ type: 'result', data: result });
  }
};

Using addEventListener

// More flexible than onmessage
worker.addEventListener('message', (event) => {
  console.log('Received:', event.data);
});
 
worker.addEventListener('error', (event) => {
  console.error('Error:', event.message);
});

Practical Patterns

Image Processing in Worker

// image-worker.js
self.onmessage = (event) => {
  const { imageData, filter } = event.data;
  const data = imageData.data;
 
  switch (filter) {
    case 'grayscale':
      for (let i = 0; i < data.length; i += 4) {
        const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
        data[i] = data[i + 1] = data[i + 2] = avg;
      }
      break;
 
    case 'invert':
      for (let i = 0; i < data.length; i += 4) {
        data[i] = 255 - data[i];
        data[i + 1] = 255 - data[i + 1];
        data[i + 2] = 255 - data[i + 2];
      }
      break;
 
    case 'brightness':
      const factor = event.data.factor || 1.2;
      for (let i = 0; i < data.length; i += 4) {
        data[i] = Math.min(255, data[i] * factor);
        data[i + 1] = Math.min(255, data[i + 1] * factor);
        data[i + 2] = Math.min(255, data[i + 2] * factor);
      }
      break;
  }
 
  self.postMessage({ imageData }, [imageData.data.buffer]);
};

Worker Pool

class WorkerPool {
  constructor(size, workerScript) {
    this.workers = [];
    this.queue = [];
    this.activeWorkers = 0;
 
    for (let i = 0; i < size; i++) {
      this.workers.push(new Worker(new URL(workerScript, import.meta.url)));
    }
  }
 
  async execute(task) {
    return new Promise((resolve, reject) => {
      const worker = this.workers.find((w) => !w.busy);
 
      if (worker) {
        this.runTask(worker, task, resolve, reject);
      } else {
        this.queue.push({ task, resolve, reject });
      }
    });
  }
 
  runTask(worker, task, resolve, reject) {
    worker.busy = true;
    this.activeWorkers++;
 
    worker.onmessage = (event) => {
      worker.busy = false;
      this.activeWorkers--;
      resolve(event.data);
      this.processQueue();
    };
 
    worker.onerror = (error) => {
      worker.busy = false;
      this.activeWorkers--;
      reject(error);
      this.processQueue();
    };
 
    worker.postMessage(task);
  }
 
  processQueue() {
    if (this.queue.length > 0) {
      const { task, resolve, reject } = this.queue.shift();
      const worker = this.workers.find((w) => !w.busy);
      if (worker) this.runTask(worker, task, resolve, reject);
    }
  }
 
  terminate() {
    this.workers.forEach((w) => w.terminate());
  }
}

Transferable Objects

Transfer ownership of data to workers without copying.

// Transfer ArrayBuffer instead of copying
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
const data = new Uint8Array(buffer);
 
// Fill with data
for (let i = 0; i < data.length; i++) {
  data[i] = Math.random() * 255;
}
 
// Transfer ownership (zero-copy)
worker.postMessage({ buffer }, [buffer]);
 
// After transfer, buffer.byteLength === 0 in the sender
console.log(buffer.byteLength); // 0

Parallel processing

SharedArrayBuffer

Share memory between the main thread and workers.

// main.js - requires Cross-Origin-Isolation headers
// Response headers must include:
// Cross-Origin-Opener-Policy: same-origin
// Cross-Origin-Embedder-Policy: require-corp
 
const sharedBuffer = new SharedArrayBuffer(1024);
const sharedArray = new Int32Array(sharedBuffer);
 
// Pass the same buffer to multiple workers
const worker1 = new Worker(new URL('./worker1.js', import.meta.url));
const worker2 = new Worker(new URL('./worker2.js', import.meta.url));
 
worker1.postMessage({ buffer: sharedBuffer });
worker2.postMessage({ buffer: sharedBuffer });
// worker1.js
self.onmessage = (event) => {
  const array = new Int32Array(event.data.buffer);
 
  // Use Atomics for safe concurrent access
  Atomics.add(array, 0, 1);
  Atomics.store(array, 1, 42);
 
  // Notify waiting workers
  Atomics.notify(array, 0);
};
// worker2.js
self.onmessage = (event) => {
  const array = new Int32Array(event.data.buffer);
 
  // Wait for a notification
  Atomics.wait(array, 0, 0);
 
  console.log('Value:', Atomics.load(array, 1));
};
// Using Comlink library for cleaner worker APIs
import * as Comlink from 'comlink';
 
// worker.js
import * as Comlink from 'comlink';
 
const api = {
  fibonacci(n) {
    if (n <= 1) return n;
    return this.fibonacci(n - 1) + this.fibonacci(n - 2);
  },
 
  async processData(data) {
    return data.map((x) => x * 2);
  },
};
 
Comlink.expose(api);
// main.js
import * as Comlink from 'comlink';
 
const worker = new Worker(new URL('./worker.js', import.meta.url));
const api = Comlink.wrap(worker);
 
// Use like a regular async function
const result = await api.fibonacci(40);
const processed = await api.processData([1, 2, 3]);

Module Workers

Workers can use ES modules in modern browsers.

// Create a module worker
const worker = new Worker(new URL('./worker.js', import.meta.url), {
  type: 'module',
});
// worker.js (module)
import { heavyComputation } from './utils.js';
 
self.onmessage = (event) => {
  const result = heavyComputation(event.data);
  self.postMessage(result);
};

Best Practices

  1. Use transferable objects: Transfer ArrayBuffers instead of copying them
  2. Implement worker pools: Reuse workers instead of creating new ones
  3. Keep workers busy: Batch work to minimize message passing overhead
  4. Use SharedArrayBuffer: For high-performance shared state (requires COOP/COEP headers)
  5. Terminate when done: Call worker.terminate() to free resources

Common Pitfalls

PitfallImpactSolution
Copying large dataSlow, uses memoryUse transferable objects
Creating too many workersCPU overheadUse a worker pool
SharedArrayBuffer without headersFails silentlySet COOP/COEP headers
Not handling errorsSilent failuresAdd error event listeners

Worker Communication Patterns

Effective communication between the main thread and workers requires careful design. The postMessage API uses structured cloning to transfer data, which is efficient for most data types but can be slow for large arrays. Use Transferable objects to transfer ownership of large buffers without copying. ArrayBuffer objects can be transferred by passing them in the transfer array of postMessage. After transfer, the buffer is no longer accessible on the sending thread, which prevents concurrent access issues. For streaming data, use MessageChannel to create a dedicated communication channel between the main thread and a worker. This approach avoids the overhead of serializing and deserializing messages through the global postMessage mechanism.

SharedArrayBuffer and Atomics

SharedArrayBuffer allows multiple workers and the main thread to share the same memory buffer. Unlike regular ArrayBuffer transfer, SharedArrayBuffer does not transfer ownership but provides true shared memory. Use the Atomics API to perform atomic operations on shared buffers, preventing race conditions when multiple threads read and write the same memory. SharedArrayBuffer requires specific security headers on your server: Cross-Origin-Opener-Policy set to same-origin and Cross-Origin-Embedder-Policy set to require-corp. These headers enable the Spectre mitigations required by browsers for safe shared memory usage. SharedArrayBuffer is particularly useful for parallel computation, multi-threaded game engines, and real-time audio processing.

Error Handling in Workers

Workers run in a separate context, so errors in workers do not propagate to the main thread automatically. Listen for the error event on the Worker object to catch unhandled errors in the worker. Use the messageerror event to catch deserialization errors when receiving messages. Implement structured error handling within workers using try-catch blocks and send error details back to the main thread through postMessage. Use the online and offline events to handle network-related errors in workers that perform fetch requests. Consider implementing a health check mechanism where the main thread periodically pings the worker to ensure it is responsive, and restart the worker if it becomes unresponsive.

Worker Pools

For applications that frequently need to perform background computation, implementing a worker pool is more efficient than creating and destroying workers for each task. A worker pool maintains a fixed set of worker threads and distributes incoming tasks among them. Implement a task queue that holds pending work items and dispatches them to available workers. When a worker completes a task, it picks up the next item from the queue. Size the pool based on the number of available CPU cores, typically using navigator.hardwareConcurrency minus one to leave a core for the main thread. Worker pools are particularly valuable for server-side rendering, image processing pipelines, and batch data processing applications.

OffscreenCanvas in Workers

OffscreenCanvas allows you to perform canvas rendering in a worker thread, keeping the main thread free for user interactions. Transfer a canvas to a worker using canvas.transferControlToOffscreen, then use the OffscreenCanvas API to draw on it from the worker. This is particularly valuable for applications that perform continuous rendering like data visualizations, games, or animation tools. The worker can run requestAnimationFrame independently from the main thread, maintaining smooth animations even when the main thread is busy. Combine OffscreenCanvas with WebGL or WebGPU in workers for high-performance graphics rendering that does not impact the responsiveness of your user interface.

// main.js - Transfer canvas to worker
const canvas = document.getElementById('canvas');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker(new URL('./render-worker.js', import.meta.url));
 
worker.postMessage({ canvas: offscreen }, [offscreen]);
 
// Main thread is now free for user interactions
document.getElementById('controls').addEventListener('input', (e) => {
  worker.postMessage({ type: 'update', value: e.target.value });
});
// render-worker.js
let ctx;
 
self.onmessage = (event) => {
  if (event.data.canvas) {
    ctx = event.data.canvas.getContext('2d');
    startRendering();
  }
};
 
function startRendering() {
  function frame(time) {
    // Draw complex scene without blocking main thread
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    ctx.fillStyle = `hsl(${time / 50 % 360}, 70%, 50%)`;
    ctx.fillRect(50, 50, 200, 200);
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}

Service Workers and Background Sync

Service workers are a special type of worker that acts as a programmable network proxy. They can intercept network requests, cache responses, and serve content from the cache when the network is unavailable. Use service workers to implement offline-first applications that work reliably regardless of network conditions. The Background Sync API allows service workers to defer operations until the user has a stable network connection. The Periodic Background Sync API enables the service worker to periodically fetch fresh content in the background. These APIs combined with regular web workers provide a comprehensive background processing architecture for modern web applications.

Streaming with ReadableStream

Use ReadableStream to establish a streaming data channel between the main thread and workers. This pattern is ideal for processing large datasets that do not fit in memory or for real-time data processing. Create a ReadableStream in the worker that produces processed chunks, and consume it on the main thread using a reader. The stream backpressure mechanism automatically slows down production when the consumer cannot keep up, preventing memory exhaustion. Use TransformStream to create processing pipelines within a worker, where each transform step processes the data before passing it to the next step. This streaming architecture enables processing of arbitrarily large datasets without loading them entirely into memory.

// worker.js - Stream processed data to main thread
self.onmessage = async (event) => {
  const { dataUrl } = event.data;
 
  const response = await fetch(dataUrl);
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
 
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
 
    const chunk = decoder.decode(value, { stream: true });
    const processed = processChunk(chunk);
    self.postMessage({ type: 'chunk', data: processed });
  }
 
  self.postMessage({ type: 'done' });
};
// main.js - Consume streamed data
const worker = new Worker(new URL('./stream-worker.js', import.meta.url));
const results = [];
 
worker.onmessage = (event) => {
  if (event.data.type === 'chunk') {
    results.push(...event.data.data);
    updateProgressBar(results.length);
  } else if (event.data.type === 'done') {
    console.log(`Processed ${results.length} records`);
  }
};
 
worker.postMessage({ dataUrl: '/api/large-dataset.csv' });

Parallel Processing Patterns

Implement parallel processing patterns using multiple workers to process data concurrently. The fork-join pattern splits work into equal chunks, distributes them to workers, and combines the results. The map-reduce pattern applies a transformation to each element using workers, then reduces the results to a final value. The pipeline pattern chains workers together, where the output of one worker becomes the input of the next. Choose the pattern that best fits your data and computation characteristics. For embarrassingly parallel problems like image processing or data transformation, the fork-join pattern provides near-linear speedup with the number of workers. For problems with dependencies between steps, the pipeline pattern maintains throughput while respecting those dependencies.

// Fork-join pattern: split work across N workers
async function parallelMap(items, workerScript, concurrency = navigator.hardwareConcurrency) {
  const pool = new WorkerPool(concurrency, workerScript);
  const chunkSize = Math.ceil(items.length / concurrency);
  const chunks = [];
 
  for (let i = 0; i < items.length; i += chunkSize) {
    chunks.push(items.slice(i, i + chunkSize));
  }
 
  const results = await Promise.all(
    chunks.map(chunk => pool.execute({ type: 'process', data: chunk }))
  );
 
  pool.terminate();
  return results.flatMap(r => r.data);
}
 
// Usage: process 1M records across 8 cores
const data = Array.from({ length: 1_000_000 }, (_, i) => i);
const processed = await parallelMap(data, './transform-worker.js');

Memory Management

Workers have their own memory space, which means they do not share memory with the main thread by default. This isolation prevents many concurrency bugs but can lead to increased memory usage when multiple workers need the same data. Use SharedArrayBuffer to share read-only data between workers without duplication. Implement memory pooling within workers to reuse allocations rather than creating new objects for each task. Monitor worker memory usage and implement memory limits to prevent individual workers from consuming excessive memory.

// Memory-conscious worker with pooling
class PooledWorker {
  constructor() {
    this.bufferPool = [];
  }
 
  getBuffer(size) {
    // Reuse existing buffers when possible
    const existing = this.bufferPool.find(b => b.byteLength >= size);
    if (existing) {
      this.bufferPool.splice(this.bufferPool.indexOf(existing), 1);
      return existing;
    }
    return new ArrayBuffer(size);
  }
 
  releaseBuffer(buffer) {
    if (this.bufferPool.length < 10) {
      this.bufferPool.push(buffer);
    }
  }
 
  process(data) {
    const buffer = this.getBuffer(data.byteLength);
    // Process data using the buffer
    const result = transform(data, buffer);
    this.releaseBuffer(buffer);
    return result;
  }
}

WebGPU Compute in Workers

WebGPU enables GPU-accelerated computation directly from workers, opening up massive parallelism for tasks like matrix operations, physics simulations, and data processing. Combining WebGPU compute shaders with workers keeps both the main thread and the GPU free for rendering while performing heavy computation in the background.

// gpu-worker.js — WebGPU compute in a worker
self.onmessage = async (event) => {
  const { data, operation } = event.data;
 
  const adapter = await navigator.gpu.requestAdapter();
  const device = await adapter.requestDevice();
 
  const inputBuffer = device.createBuffer({
    size: data.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
    mappedAtCreation: true
  });
  new Float32Array(inputBuffer.getMappedRange()).set(data);
  inputBuffer.unmap();
 
  const shaderModule = device.createShaderModule({
    code: `
      @group(0) @binding(0) var<storage, read> input: array<f32>;
      @group(0) @binding(1) var<storage, read_write> output: array<f32>;
 
      @compute @workgroup_size(256)
      fn main(@builtin(global_invocation_id) id: vec3<u32>) {
        let i = id.x;
        if (i < arrayLength(&input)) {
          output[i] = input[i] * input[i]; // Square each element
        }
      }
    `
  });
 
  const outputBuffer = device.createBuffer({
    size: data.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
  });
 
  const bindGroup = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      { binding: 0, resource: { buffer: inputBuffer } },
      { binding: 1, resource: { buffer: outputBuffer } }
    ]
  });
 
  const commandEncoder = device.createCommandEncoder();
  const passEncoder = commandEncoder.beginComputePass();
  passEncoder.setPipeline(pipeline);
  passEncoder.setBindGroup(0, bindGroup);
  passEncoder.dispatchWorkgroups(Math.ceil(data.length / 256));
  passEncoder.end();
 
  device.queue.submit([commandEncoder.finish()]);
 
  const result = await readBuffer(device, outputBuffer);
  self.postMessage({ result }, [result.buffer]);
};

Conclusion

Web Workers enable true parallel processing in the browser. By offloading heavy computations to background threads, you can keep your UI responsive and performant. Combined with transferable objects and SharedArrayBuffer, workers can handle even the most demanding workloads. The key is choosing the right communication pattern for your use case: simple postMessage for basic tasks, transferable objects for large binary data, SharedArrayBuffer for shared state, and streaming for continuous data processing.

Modern tooling has also made workers easier to use. Libraries like Comlink eliminate the boilerplate of message passing, module workers enable clean code sharing between threads, and bundlers like Webpack and Vite handle worker bundling automatically. The browser ecosystem continues to evolve with features like OffscreenCanvas and the upcoming Web Locks API, making workers more powerful with each release.

Key takeaways:

  1. Dedicated workers run JavaScript in separate threads
  2. Transferable objects enable zero-copy data transfer
  3. SharedArrayBuffer allows shared memory between threads
  4. Worker pools reuse workers for better performance
  5. Comlink simplifies worker communication with async/await syntax
  6. OffscreenCanvas enables rendering in workers without blocking the UI

Web Workers are no longer an advanced technique—they are a fundamental tool for building responsive web applications that handle complex computation without sacrificing user experience. Start by identifying the heaviest computations in your application and move them to a worker; the performance improvement is immediate and significant.