Skip to content

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

FixDevs ·

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:

  • 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))
);

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);
};

For related JavaScript performance issues, see Fix: JavaScript Heap Out of Memory and Fix: Node.js Stream Error.

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