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: Run JavaScript in Background Threads

Use Web Workers for CPU-intensive tasks: dedicated, shared, and service workers.

Web WorkersJavaScriptPerformanceConcurrency

By MinhVo

Introduction

JavaScript is single-threaded by default. When you run a heavy computation—like image processing, data analysis, or physics simulation—the browser UI freezes. Web Workers solve this by providing true background threads that run JavaScript in parallel with the main thread. This guide covers all three types of workers: dedicated workers, shared workers, and service workers, with practical patterns for each.

Background processing

Dedicated Web Workers

Dedicated workers are the simplest type. Each worker is owned by a single page and communicates only with that page.

Basic Worker Setup

// main.js
const worker = new Worker('worker.js');
 
// Send data to worker
worker.postMessage({ numbers: [1, 2, 3, 4, 5] });
 
// Receive results from worker
worker.onmessage = (event) => {
  console.log('Sum:', event.data.sum);
  console.log('Average:', event.data.average);
};
// worker.js
self.onmessage = (event) => {
  const { numbers } = event.data;
  const sum = numbers.reduce((a, b) => a + b, 0);
  const average = sum / numbers.length;
 
  // Send result back to main thread
  self.postMessage({ sum, average });
};

Heavy Computation Example

// prime-worker.js
self.onmessage = (event) => {
  const { start, end } = event.data;
  const primes = [];
 
  for (let n = start; n <= end; n++) {
    if (isPrime(n)) primes.push(n);
 
    // Report progress every 10000 numbers
    if (n % 10000 === 0) {
      self.postMessage({ type: 'progress', current: n, total: end });
    }
  }
 
  self.postMessage({ type: 'result', primes });
};
 
function isPrime(n) {
  if (n < 2) return false;
  for (let i = 2; i <= Math.sqrt(n); i++) {
    if (n % i === 0) return false;
  }
  return true;
}

Abortable Worker Tasks

class AbortableWorker {
  constructor(script) {
    this.worker = new Worker(script);
  }
 
  run(data, signal) {
    return new Promise((resolve, reject) => {
      signal.addEventListener('abort', () => {
        this.worker.terminate();
        reject(new DOMException('Aborted', 'AbortError'));
      });
 
      this.worker.onmessage = (event) => resolve(event.data);
      this.worker.onerror = (error) => reject(error);
      this.worker.postMessage(data);
    });
  }
 
  terminate() {
    this.worker.terminate();
  }
}
 
// Usage
const controller = new AbortController();
const worker = new AbortableWorker('heavy-task.js');
 
try {
  const result = await worker.run({ size: 1000000 }, controller.signal);
  console.log(result);
} catch (e) {
  if (e.name === 'AbortError') console.log('Task was cancelled');
}
 
// Cancel after 5 seconds
setTimeout(() => controller.abort(), 5000);

Shared Web Workers

Shared workers can be accessed by multiple pages (tabs, iframes) from the same origin.

// shared-worker.js
const connections = [];
 
self.onconnect = (event) => {
  const port = event.ports[0];
  connections.push(port);
 
  port.onmessage = (event) => {
    // Broadcast to all connected pages
    connections.forEach((conn) => {
      conn.postMessage({
        from: event.data.user,
        message: event.data.message,
      });
    });
  };
 
  port.onclose = () => {
    const index = connections.indexOf(port);
    if (index > -1) connections.splice(index, 1);
  };
 
  port.start();
};
// page.js
const worker = new SharedWorker('shared-worker.js');
worker.port.onmessage = (event) => {
  console.log(`${event.data.from}: ${event.data.message}`);
};
worker.port.start();
 
// Send message
worker.port.postMessage({ user: 'Alice', message: 'Hello!' });

Service Workers

Service workers act as network proxies, enabling offline support, caching, and push notifications.

Basic Service Worker Registration

// main.js
if ('serviceWorker' in navigator) {
  const registration = await navigator.serviceWorker.register('/sw.js', {
    scope: '/',
  });
  console.log('Service Worker registered:', registration.scope);
}

Cache-First Strategy

// sw.js
const CACHE_NAME = 'v1';
const ASSETS = ['/', '/index.html', '/styles.css', '/app.js'];
 
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
  );
});
 
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) return cached;
 
      return fetch(event.request).then((response) => {
        if (response.ok) {
          const clone = response.clone();
          caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
        }
        return response;
      });
    })
  );
});
 
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name))
      )
    )
  );
});

Network-First Strategy

// Better for dynamic content
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        const clone = response.clone();
        caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
        return response;
      })
      .catch(() => caches.match(event.request))
  );
});

Stale-While-Revalidate

// Serve from cache, update in background
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      const fetchPromise = fetch(event.request).then((response) => {
        caches.open(CACHE_NAME).then((cache) => cache.put(event.request, response.clone()));
        return response;
      });
 
      return cached || fetchPromise;
    })
  );
});

Threading architecture

Comparison: Worker Types

FeatureDedicatedSharedService
ScopeSingle pageMultiple pagesOrigin-wide
CommunicationpostMessageMessagePortFetch events
LifecycleTied to pageIndependentIndependent
Use caseHeavy computationCross-tab syncOffline, caching
Browser supportAllMostAll
DOM accessNoNoNo
Network interceptionNoNoYes
Push notificationsNoNoYes

Dedicated workers are the go-to choice for offloading CPU-intensive work from the main thread. They are simple to create, have straightforward communication via postMessage, and are automatically terminated when the page closes. Shared workers are useful when multiple tabs need to share state or coordinate actions — for example, maintaining a single WebSocket connection across tabs. Service workers are fundamentally different: they don't help with computation but instead act as programmable network proxies. They enable offline-first architectures, background sync, and push notifications. All three types run in separate JavaScript contexts without DOM access, which is why communication happens exclusively through message passing.

Best Practices

  1. Keep messages small: Use transferable objects for large data
  2. Use worker pools: Reuse workers for repeated tasks
  3. Handle errors: Always add error handlers
  4. Terminate when done: Free resources by terminating workers
  5. Use module workers: type: 'module' enables imports in workers

Common Pitfalls

PitfallImpactSolution
Copying large ArrayBuffersSlow, uses memoryUse transferable objects
No error handlingSilent failuresAdd onerror handlers
Creating too many workersCPU overheadUse a worker pool
Service worker caching stale dataUsers see old contentImplement cache versioning

Worker Communication Patterns

Promise-Based Worker Wrapper

Wrapping worker communication in Promises makes the API easier to use with async/await. This pattern eliminates callback nesting and enables standard error handling with try/catch:

class WorkerClient {
    constructor(script) {
        this.worker = new Worker(script);
        this.callbacks = new Map();
        this.id = 0;
 
        this.worker.onmessage = (event) => {
            const { id, result, error } = event.data;
            const callback = this.callbacks.get(id);
            if (callback) {
                this.callbacks.delete(id);
                if (error) {
                    callback.reject(new Error(error));
                } else {
                    callback.resolve(result);
                }
            }
        };
    }
 
    execute(type, payload) {
        return new Promise((resolve, reject) => {
            const id = ++this.id;
            this.callbacks.set(id, { resolve, reject });
            this.worker.postMessage({ id, type, payload });
        });
    }
 
    terminate() {
        this.worker.terminate();
    }
}
 
// Usage with async/await
const worker = new WorkerClient('compute-worker.js');
try {
    const result = await worker.execute('factorial', { n: 100 });
    console.log(result);
} catch (err) {
    console.error('Worker error:', err.message);
}

Broadcast Channel for Cross-Worker Communication

When multiple workers need to communicate with each other or with the main thread, BroadcastChannel provides a simple pub/sub mechanism that works across tabs and workers:

// Main thread or any worker
const channel = new BroadcastChannel('data-sync');
 
// Send updates
channel.postMessage({ type: 'update', data: processedData });
 
// Listen for updates from other workers
channel.onmessage = (event) => {
    if (event.data.type === 'update') {
        updateLocalCache(event.data.data);
    }
};
 
// Clean up when done
channel.close();

Practical Use Cases

Web Workers excel in several practical scenarios. Image processing applications use workers to apply filters, resize images, and generate thumbnails without freezing the interface. Data visualization tools use workers to process large datasets, compute aggregations, and generate chart data. Code editors use workers for syntax highlighting, linting, and autocompletion on background threads. Encryption and decryption operations use workers to process data without blocking user input. Scientific computing applications use workers for numerical simulations, data analysis, and machine learning inference. In each case, the worker handles the CPU-intensive work while the main thread remains responsive to user interactions.

Worker Lifecycle Management

Managing worker lifecycles is important for application performance and resource usage. Create workers when they are needed rather than at application startup, because each worker consumes memory and a system thread. Terminate workers when they are no longer needed using the terminate method, which immediately stops the worker without giving it a chance to clean up. Implement graceful shutdown by sending a message to the worker that triggers cleanup logic before the worker calls self.close. Monitor worker memory usage using the performance API and implement recycling strategies for long-running workers that may accumulate memory leaks. Balance the number of active workers against the available CPU cores to avoid thread contention.

Transferable Objects and SharedArrayBuffer

By default, data sent via postMessage is cloned using the structured clone algorithm, which copies the data. For large ArrayBuffers, this is expensive. Transferable objects avoid the copy by transferring ownership:

// Main thread: transfer ArrayBuffer to worker (zero-copy)
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
const view = new Uint8Array(buffer);
view.fill(42);
 
// Transfer the buffer — main thread loses access
worker.postMessage({ buffer }, [buffer]);
console.log(buffer.byteLength); // 0 — buffer was transferred
 
// Worker receives the buffer
self.onmessage = (event) => {
  const view = new Uint8Array(event.data.buffer);
  console.log(view[0]); // 42
};

SharedArrayBuffer goes further — it allows both the main thread and worker to access the same memory simultaneously:

// Main thread
const shared = new SharedArrayBuffer(1024);
const sharedArray = new Int32Array(shared);
sharedArray[0] = 100;
 
worker.postMessage({ shared });
 
// Both threads can read/write sharedArray[0] simultaneously
// Use Atomics for thread-safe operations
Atomics.add(sharedArray, 0, 1); // Atomic increment
Atomics.store(sharedArray, 1, 42);
const value = Atomics.load(sharedArray, 1); // 42
 
// Wait/notify for synchronization
Atomics.wait(sharedArray, 0, 100); // Blocks until value != 100
Atomics.notify(sharedArray, 0, 1); // Wake one waiting thread

SharedArrayBuffer requires specific HTTP headers for security: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp.

Worker Pool Implementation

Creating a new worker for each task is expensive. A worker pool reuses workers:

class WorkerPool {
  constructor(size, script) {
    this.workers = [];
    this.queue = [];
    this.activeWorkers = 0;
 
    for (let i = 0; i < size; i++) {
      this.workers.push(new Worker(script));
    }
  }
 
  run(data) {
    return new Promise((resolve, reject) => {
      const worker = this.workers.find((w) => !w._busy);
 
      if (worker) {
        this._execute(worker, data, resolve, reject);
      } else {
        this.queue.push({ data, resolve, reject });
      }
    });
  }
 
  _execute(worker, data, 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(data);
  }
 
  _processQueue() {
    if (this.queue.length === 0) return;
 
    const worker = this.workers.find((w) => !w._busy);
    if (!worker) return;
 
    const { data, resolve, reject } = this.queue.shift();
    this._execute(worker, data, resolve, reject);
  }
 
  terminate() {
    this.workers.forEach((w) => w.terminate());
  }
}
 
// Usage: 4-worker pool for parallel image processing
const pool = new WorkerPool(4, 'image-worker.js');
const results = await Promise.all(
  images.map((img) => pool.run({ imageData: img }))
);
pool.terminate();

Module Workers

Modern browsers support module workers that can import ES modules directly. Create a module worker by passing the type: 'module' option to the Worker constructor. Module workers support import statements, top-level await, and the full ES module system. This makes it easier to share code between the main thread and workers without duplicating code or using bundler-specific workarounds. Module workers also support dynamic import for lazy-loading worker code. However, module workers may have slightly higher startup latency due to module resolution, and they require the same CORS headers as regular ES modules when loaded from a different origin.

Performance Optimization

Optimize worker performance by minimizing data transfer between threads. Use structured cloning efficiently by avoiding circular references and using Transferable objects for large binary data. Batch multiple small messages into a single larger message to reduce the overhead of the message passing system. Use SharedArrayBuffer for data that both the main thread and worker need to access frequently. Profile worker performance using the Performance API within the worker context to identify bottlenecks. Avoid unnecessary serialization by sending only the data that the worker actually needs, rather than entire objects that contain extraneous properties.

Debugging Workers

Debugging workers requires specific techniques because they run in a separate JavaScript context. In Chrome DevTools, each worker appears as a separate target that you can inspect independently. Set breakpoints in worker code, step through execution, and inspect variables just like you would in the main thread. Use console.log within workers to output debug information, which appears in the worker's console context. Add source maps to your worker bundles so that stack traces and debugger statements reference your original source files. For production debugging, implement a logging system in your workers that sends log messages to the main thread through postMessage, where they can be collected and analyzed.

Web Worker Libraries

Several libraries simplify working with web workers by providing higher-level abstractions. Comlink uses Proxy to make worker communication feel like calling local functions, eliminating the need for manual message passing. Workerize automatically wraps exported functions in a worker, providing a promise-based API for calling worker functions. Greenlet provides a simple way to run async code in a worker without creating separate worker files. These libraries reduce boilerplate code and make workers accessible to developers who are not familiar with the low-level worker API. Evaluate these libraries for your use case, but understand the underlying worker API so you can debug issues and optimize performance when needed.

Security Considerations

Web Workers run in a separate security context with important implications for your application. Workers cannot access the DOM directly, which prevents them from modifying the user interface. Workers have their own origin, which affects how they can make cross-origin requests. Workers loaded from data URLs or blob URLs inherit the origin of the creating document. Content Security Policy headers apply to workers, restricting what scripts they can load and what resources they can access. Ensure that your CSP headers include the worker-src directive to control which origins can load workers. When loading workers from a CDN or different origin, configure appropriate CORS headers to allow the cross-origin loading.

Integration with Modern Frameworks

Modern JavaScript frameworks provide built-in support for web workers through various abstractions. React provides the useWorker hook for integrating workers with React components. Vue Worker provides a Vue-specific wrapper around the worker API. Angular uses web workers through the Web Worker API directly or through libraries like worker-plugin. Next.js supports API routes that run in worker threads for CPU-intensive server operations. When using workers with frameworks, follow the framework's recommended patterns for worker integration to ensure compatibility with hot module replacement, code splitting, and other framework features. Test worker integration thoroughly because framework-specific build tools may have different requirements for worker bundling.

Future of Threading on the Web

The web platform continues to evolve its threading capabilities. The Task Scheduling API proposal provides a standardized way to prioritize tasks across threads. The Structured Clone improvements proposal adds support for more data types in postMessage. The SharedArrayBuffer proposals for expanding shared memory support are progressing through the standards process. WebAssembly threads provide shared-memory parallelism for compiled code. These improvements will make web workers more powerful and easier to use for a wider range of applications. Stay informed about these proposals and experiment with them as they become available in browsers to prepare for the future of web threading.

Practical Example: Image Processing Pipeline

Building an image processing pipeline with web workers demonstrates the practical benefits of background threading. The main thread handles user interaction and image display. A dedicated worker handles image decoding, resizing, and filter application. The worker receives image data through Transferable ArrayBuffer, processes it using pixel manipulation, and sends the result back. Implement a processing queue that handles multiple images concurrently using a worker pool. This architecture keeps the user interface responsive while processing large batches of images, which would freeze the main thread if done synchronously.

Web Workers enable true parallelism in JavaScript by running scripts in separate threads, keeping the main thread responsive during heavy computation.

Conclusion

Web Workers enable true parallel processing in JavaScript. Dedicated workers handle heavy computation for a single page, shared workers enable cross-tab communication, and service workers provide offline support and caching. Understanding when and how to use each type is essential for building performant web applications.

Key takeaways:

  1. Dedicated workers run background tasks for a single page
  2. Shared workers communicate across multiple tabs
  3. Service workers provide offline support and network interception
  4. Transferable objects enable efficient data transfer
  5. Worker pools manage multiple workers for parallel processing