Skip to content

Fix: Web Worker Not Working — postMessage Ignored, Cannot Import Module, or Worker Crashes Silently

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Web Worker issues — postMessage data cloning, module workers, error handling, SharedArrayBuffer setup, Comlink, and common reasons workers silently fail.

The Problem

A Web Worker is created but messages are never received:

const worker = new Worker('worker.js');
worker.postMessage({ data: [1, 2, 3] });

worker.onmessage = (e) => {
  console.log(e.data);  // Never fires
};

Or the worker fails to load with a module import:

// worker.js
import { heavyComputation } from './utils.js';  // SyntaxError: Cannot use import statement

Or an error inside the worker crashes silently:

// worker.js
self.onmessage = (e) => {
  const result = JSON.parse(e.data.value);  // Throws — but no error in main thread
  self.postMessage(result);
};

Or postMessage with a non-clonable object fails:

worker.postMessage({ fn: () => 'hello' });
// DataCloneError: function is not transferable

Why This Happens

Web Workers have strict rules that differ from the main thread. A worker is a separate OS-level thread with its own JavaScript event loop, its own heap, and a completely different global object. It cannot touch the DOM, cannot share variables with the page, and cannot block on synchronous calls except through Atomics.wait on a shared buffer. Every value that crosses the boundary is serialized through the structured-clone algorithm, which silently rejects functions, class instances with prototype methods, DOM nodes, and any object containing them.

The silent-failure mode is what makes workers feel broken even when the code is correct. Errors thrown inside a worker do not bubble up to the main thread automatically. You either listen for the error event on the worker handle, or wrap every message handler in try/catch and post the error back yourself. Without one of those guards, an exception inside the worker simply terminates the worker, the main thread keeps waiting for a response, and nothing in the console tells you what happened. Browsers added messageerror for serialization failures, but it still requires you to subscribe.

The module-loading rules also catch teams who copy code from older tutorials. Pre-2018 examples write new Worker('/worker.js') and importScripts(...) inside the worker — that pattern still works but does not let you use import/export. Modern workers should be created as module workers ({ type: 'module' }), bundled with Vite’s ?worker import or webpack 5’s new Worker(new URL(...), import.meta.url), and treated as ESM. Mixing classic workers with bundler-emitted ESM produces immediate SyntaxError: Cannot use import statement outside a module failures that have nothing to do with your application code.

  • Workers run in a separate global scope — no window, no DOM access, no document. Only self (the worker global), fetch, IndexedDB, WebSockets, and Web APIs that are explicitly worker-safe.
  • postMessage uses the structured clone algorithm — only serializable data crosses the worker boundary. Functions, class instances with methods, DOM nodes, and Error objects (partially) are rejected with a DataCloneError.
  • Worker errors don’t propagate to the main thread by default — you must set worker.onerror to catch crashes. An unhandled throw inside a worker kills it silently.
  • Module workers require explicit { type: 'module' }new Worker('worker.js') loads as a classic script. To use import, pass { type: 'module' } as the second argument.
  • CORS rules apply to worker scripts — the worker script URL must be same-origin, or served with appropriate CORS headers. A URL from a CDN without CORS headers silently fails.

Fix 1: Set Up Event Listeners Before postMessage

Register onmessage before sending messages — and always handle errors:

// WRONG — listener registered after postMessage
const worker = new Worker('worker.js');
worker.postMessage('start');
worker.onmessage = (e) => console.log(e.data);  // May miss the response

// CORRECT — register listeners first
const worker = new Worker('worker.js');

worker.onmessage = (e) => {
  console.log('Received:', e.data);
};

worker.onerror = (e) => {
  console.error('Worker error:', e.message, 'in', e.filename, 'line', e.lineno);
  e.preventDefault();  // Prevent the error from propagating further
};

worker.onmessageerror = (e) => {
  console.error('Message deserialization failed:', e);
};

worker.postMessage({ data: [1, 2, 3] });

Worker side — always send responses and handle errors:

// worker.js
self.onmessage = (e) => {
  try {
    const result = processData(e.data);
    self.postMessage({ success: true, result });
  } catch (err) {
    // Don't just throw — send the error back to main thread
    self.postMessage({ success: false, error: err.message });
  }
};

function processData(data) {
  // Heavy computation here
  return data.map(x => x * 2);
}

Fix 2: Use Module Workers for ES Imports

Enable ES module syntax in workers with { type: 'module' }:

// WRONG — classic worker, no import support
const worker = new Worker('./worker.js');

// CORRECT — module worker
const worker = new Worker('./worker.js', { type: 'module' });
// worker.js — now you can use import/export
import { heavyComputation } from './utils.js';
import { process } from 'some-npm-package';  // Works if bundled

self.onmessage = async (e) => {
  const result = await heavyComputation(e.data);
  self.postMessage(result);
};

With Vite — use the ?worker suffix:

// Vite automatically handles worker bundling
import MyWorker from './worker.js?worker';

const worker = new MyWorker();
worker.postMessage('start');

// For inline workers (no separate file):
import MyWorker from './worker.js?worker&inline';

With webpack — use Worker constructor inline:

// webpack 5 — worker URLs are resolved at build time
const worker = new Worker(new URL('./worker.js', import.meta.url), {
  type: 'module'
});

Create a worker from a string (no separate file needed):

const workerCode = `
  self.onmessage = (e) => {
    const result = e.data.map(x => x * x);
    self.postMessage(result);
  };
`;

const blob = new Blob([workerCode], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));

Fix 3: Transfer Ownership Instead of Cloning

For large data like ArrayBuffer, ImageBitmap, or OffscreenCanvas, transfer ownership instead of copying:

// SLOW — copies the entire buffer (structured clone)
const buffer = new ArrayBuffer(100 * 1024 * 1024);  // 100MB
worker.postMessage(buffer);  // Clones: 100MB copied

// FAST — transfers ownership (zero-copy)
worker.postMessage(buffer, [buffer]);
// After transfer, 'buffer' is detached in the main thread — byteLength is 0
console.log(buffer.byteLength);  // 0

// Transfer multiple objects
const imageBuffer = new ArrayBuffer(4 * 1024 * 1024);
const metaBuffer = new ArrayBuffer(1024);
worker.postMessage(
  { imageBuffer, metaBuffer },
  [imageBuffer, metaBuffer]  // Transfer list
);

// Worker side — receives the buffers, can transfer back
self.onmessage = (e) => {
  const { imageBuffer } = e.data;
  // Process imageBuffer...
  const result = processImage(imageBuffer);
  self.postMessage({ result }, [result]);  // Transfer back
};

Transferable types:

  • ArrayBuffer
  • MessagePort
  • ImageBitmap
  • OffscreenCanvas
  • ReadableStream, WritableStream, TransformStream

Non-transferable (must be cloned or avoided):

  • Functions
  • DOM elements
  • Class instances with prototype methods
  • Symbols
  • WeakMap, WeakSet

Comlink removes the need to manually manage postMessage/onmessage and makes worker calls look like regular async function calls:

// worker.js
import * as Comlink from 'https://unpkg.com/comlink/dist/esm/comlink.mjs';

const api = {
  async processData(items) {
    return items.map(x => x * 2);
  },

  async fetchAndProcess(url) {
    const response = await fetch(url);
    const data = await response.json();
    return data.items.filter(item => item.active);
  }
};

Comlink.expose(api);
// main.js
import * as Comlink from 'https://unpkg.com/comlink/dist/esm/comlink.mjs';

const worker = new Worker('./worker.js', { type: 'module' });
const api = Comlink.wrap(worker);

// Call worker functions like async methods — no postMessage needed
const result = await api.processData([1, 2, 3, 4, 5]);
console.log(result);  // [2, 4, 6, 8, 10]

const filtered = await api.fetchAndProcess('/api/data');

Comlink with classes:

// worker.js
import * as Comlink from 'comlink';

class DataProcessor {
  constructor(config) {
    this.config = config;
    this.cache = new Map();
  }

  process(data) {
    const key = JSON.stringify(data);
    if (this.cache.has(key)) return this.cache.get(key);
    const result = heavyCompute(data, this.config);
    this.cache.set(key, result);
    return result;
  }
}

Comlink.expose(DataProcessor);

// main.js
const RemoteProcessor = Comlink.wrap(new Worker('./worker.js', { type: 'module' }));
const processor = await new RemoteProcessor({ threads: 4 });
const result = await processor.process([1, 2, 3]);

Fix 5: Share Memory with SharedArrayBuffer

For maximum performance, share memory directly between the main thread and workers:

// SharedArrayBuffer requires cross-origin isolation headers:
// Cross-Origin-Opener-Policy: same-origin
// Cross-Origin-Embedder-Policy: require-corp

// Check if SharedArrayBuffer is available
if (typeof SharedArrayBuffer === 'undefined') {
  console.error('SharedArrayBuffer not available — check COOP/COEP headers');
}

// main.js — create shared buffer
const shared = new SharedArrayBuffer(4 * 1024);  // 4KB shared memory
const view = new Int32Array(shared);

worker.postMessage({ shared });  // Send buffer (not cloned — it's shared)

// Write to shared memory
view[0] = 42;

// Atomically wait for worker response
Atomics.wait(view, 1, 0);  // Wait until view[1] != 0
console.log('Worker result:', view[2]);

// worker.js
self.onmessage = (e) => {
  const view = new Int32Array(e.data.shared);

  // Read value set by main thread
  const input = Atomics.load(view, 0);

  // Compute result
  const result = input * 2;

  // Write result and signal completion
  Atomics.store(view, 2, result);
  Atomics.store(view, 1, 1);   // Signal: done
  Atomics.notify(view, 1, 1);  // Wake up main thread
};

Required HTTP headers for SharedArrayBuffer:

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

For Vite dev server:

// vite.config.js
export default {
  server: {
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp',
    }
  }
}

Fix 6: Worker Pool Pattern for CPU-Bound Tasks

For tasks that need multiple workers running in parallel:

class WorkerPool {
  constructor(workerUrl, poolSize = navigator.hardwareConcurrency || 4) {
    this.workers = Array.from({ length: poolSize }, () => {
      const w = new Worker(workerUrl, { type: 'module' });
      w.busy = false;
      return w;
    });
    this.queue = [];
  }

  run(data) {
    return new Promise((resolve, reject) => {
      const task = { data, resolve, reject };
      const idle = this.workers.find(w => !w.busy);

      if (idle) {
        this._dispatch(idle, task);
      } else {
        this.queue.push(task);
      }
    });
  }

  _dispatch(worker, task) {
    worker.busy = true;
    worker.onmessage = (e) => {
      worker.busy = false;
      task.resolve(e.data);
      if (this.queue.length > 0) {
        this._dispatch(worker, this.queue.shift());
      }
    };
    worker.onerror = (e) => {
      worker.busy = false;
      task.reject(new Error(e.message));
    };
    worker.postMessage(task.data);
  }

  terminate() {
    this.workers.forEach(w => w.terminate());
  }
}

// Usage
const pool = new WorkerPool('./compute-worker.js');

const results = await Promise.all(
  largeDataset.map(chunk => pool.run(chunk))
);

The browser ships several parallelism primitives that all look similar but solve different problems. Picking the wrong one is responsible for most “my worker doesn’t respond” reports.

Web Workers are short-lived background threads tied to the page that created them. They die when the tab closes. Use them for CPU-bound work — image processing, parsing, encryption, sync diffing — anything that would block the main thread for more than 16 ms. Message passing through postMessage is the default; SharedArrayBuffer plus Atomics is the escape hatch for zero-copy.

Service Workers are long-lived, page-independent, and intercept network requests. They survive page navigation, run without an open tab on push events, and are scoped by URL path. They are the wrong tool for computation — Chrome aggressively throttles or kills idle service workers — but the right tool for offline caching, push notifications, and background sync. If you call postMessage from a page to a service worker expecting CPU offload, you are probably building the wrong thing.

SharedArrayBuffer + Atomics is not a worker but a memory primitive that lets multiple workers share the exact same bytes. Atomics.wait, Atomics.notify, and Atomics.compareExchange give you mutexes, semaphores, and condition variables across threads. The catch is that COOP/COEP headers are required, which breaks most third-party iframes (ads, analytics widgets). Use it only when message-passing overhead actually dominates your profile — for most apps it does not.

Node worker_threads is the server-side cousin of Web Workers. The API is shaped similarly (new Worker(file, { workerData }), parentPort.postMessage) but the worker has full Node access — fs, net, crypto. It is the right place for CPU-bound work in a Node HTTP server. It is not a polyfill for Web Workers; transferable types and structured-clone limits apply, but error semantics and module loading differ.

Comlink is a transparent RPC layer on top of postMessage. You write Comlink.expose(api) in the worker and const api = Comlink.wrap(worker) in the main thread, then call await api.processData(items) as if it were a local async function. It eliminates the manual message-id matching, supports class instances over the boundary, and works with both Web Workers and MessagePort chains. Use it whenever the message protocol becomes a maintenance burden.

Partytown is a different abstraction. It runs synchronous third-party scripts (analytics, A/B testing, chat widgets) in a web worker with a proxied window/document. The goal is not parallelism but main-thread protection — keeping marketing tags off the critical path. You do not write Partytown workers yourself; you mark a script tag with type="text/partytown" and the library handles the rest.

PrimitiveUse caseLifetimeMain-thread accessCross-tabNotes
Web WorkerCPU-bound workPage lifetimeNoNoMost common choice
Service WorkerNetwork interception, offlineIndependent of pageNoYes (per scope)Throttled when idle
SharedArrayBufferZero-copy shared stateUntil detachedShared memoryNoNeeds COOP/COEP
Worker Threads (Node)Server CPU workProcess lifetimeNoN/AFull Node API
Comlink (wrapper)Cleaner RPC over workersWrapped worker’s lifetimeNoNo~3 KB library
PartytownOffload 3rd-party scriptsPage lifetimeProxied (slow)NoUse only for marketing tags

Message passing vs shared memory is also a real choice, not just a preference. postMessage with structured clone is the safe default — predictable, debuggable, and works in every browser since 2012. Transferable objects (ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream) avoid the copy at the cost of detaching the source. SharedArrayBuffer avoids both the copy and the detach but pulls you into the world of atomic operations, memory barriers, and race conditions that JavaScript developers usually never deal with. If you find yourself reaching for SharedArrayBuffer to “speed up communication,” measure first — the structured-clone overhead is rarely the bottleneck.

Still Not Working?

Worker script returns 404 — the worker URL must be accessible from the current origin. With bundlers, the worker file may end up at a different path than expected. Check the Network tab in DevTools to confirm the worker script is loading. With Vite, use ?worker imports; with webpack, use new Worker(new URL('./worker.js', import.meta.url)).

DataCloneError for class instances — only plain objects, arrays, and primitives are cloned. If you have a class with methods, serialize it: worker.postMessage(JSON.parse(JSON.stringify(myInstance))). For complex objects, use structuredClone() on the sending side to validate cloneability before sending.

Service Workers vs Web Workers — Service Workers intercept network requests and run independently of any page. If you accidentally created a Service Worker when you wanted a Web Worker, it won’t respond to onmessage from your page. Use new Worker(url) for computation tasks, navigator.serviceWorker.register(url) for network interception.

Worker messages out of orderpostMessage guarantees delivery order from a single sender, but responses may arrive in a different order if the worker processes them asynchronously. Include a task ID in every message and match responses by ID:

let nextId = 0;
const pending = new Map();

function send(data) {
  const id = nextId++;
  worker.postMessage({ id, data });
  return new Promise((resolve) => pending.set(id, resolve));
}

worker.onmessage = (e) => {
  pending.get(e.data.id)?.(e.data.result);
  pending.delete(e.data.id);
};

Bundler emits the worker as ESM but the browser loads it as a classic script — Vite, esbuild, and webpack 5 default to ESM workers, but if your worker.js file lives outside the build pipeline (for example, fetched from a CDN), the browser falls back to classic mode and your import statements blow up. Always emit the worker through the bundler with ?worker (Vite) or new URL(..., import.meta.url) (webpack), and verify the produced <script> tag carries type="module". Inline workers via Blob URLs are classic by default — pass the worker code as ESM and new Worker(URL.createObjectURL(blob), { type: 'module' }).

Worker keeps the page alive when you want it dead — closing a tab terminates workers, but a Worker instance held in a long-lived module (a service that lives in a singleton, a React context that never unmounts) can keep allocations around. Call worker.terminate() in your component’s cleanup function, and clear the reference (worker = null) so the GC can reclaim the worker’s heap. With Comlink, also call proxy[Comlink.releaseProxy]() to drop the underlying MessagePort.

Cross-origin worker import fails even with CORS — if you load a worker from a CDN, both the worker script and every module it imports must be served with Cross-Origin-Resource-Policy and the right CORS headers. Browsers treat the worker’s import graph as part of the same fetch, so missing CORS on any transitive .js file aborts the whole worker creation. Self-host worker scripts whenever possible to avoid this class of bug.

For related JavaScript performance issues, see Fix: JavaScript Heap Out of Memory, Fix: Node.js Stream Error, Fix: WASM Not Working, and Fix: JavaScript Unhandled Promise Rejection.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles