Fixing Uncaught Exceptions in Dedicated Workers

Uncaught exceptions in Dedicated Workers silently terminate the thread, corrupting long-running data visualization pipelines and leaving the main thread unaware. This guide builds a deterministic, low-overhead capture pipeline that intercepts synchronous throws and unhandled Promise rejections without introducing GC pressure or blocking the event loop — a foundational component of any Error Handling & Crash Recovery setup within the broader Debugging, Profiling & Production Optimization discipline.

Pre-flight Diagnostics

  • Confirm exception origin: synchronous compute vs. async fetch/parse.
  • Validate that self.onerror and unhandledrejection are registered before heavy computation begins.
  • Check the browser console for Uncaught (in promise) messages — these indicate a missing unhandledrejection listener in the worker.

Step 1: Isolate the Exception Source in Chrome DevTools

Standard console logging fails when a worker crashes before flushing output. Use the Sources panel’s thread selector to attach directly to the worker’s execution context. Enable “Pause on caught/uncaught exceptions” and blackbox async library frames to isolate the exact line of failure.

To identify active worker contexts in the DevTools console, switch to the worker’s context via the Threads dropdown in the Sources panel. The worker’s console.log output appears in the main Console tab but can be filtered by context.

// Main thread: confirm the worker is alive before attaching debugger
const worker = new Worker('./compute.js');
worker.postMessage({ type: 'PING' });
worker.onmessage = (e) => {
  if (e.data.type === 'PONG') console.log('Worker is alive, safe to debug.');
};

Step 2: Implementing the Dual-Listener Boundary

A single try/catch block cannot span thread boundaries or catch async rejections. Register both self.onerror and unhandledrejection at the top of the worker script, before any other code runs. This ensures no exception escapes capture.

Returning true from onerror prevents the browser from logging the error to the console and suppresses the default termination behavior, giving you a window to flush state.

// worker.js — Top-level registration (must be before any other code)
self.onerror = (msg, source, lineno, colno, error) => {
  self.postMessage({
    type: 'FATAL_SYNC',
    payload: error ? error.stack : `${msg} at ${source}:${lineno}:${colno}`
  });
  return true; // Suppress default browser termination; allows state flush
};

self.addEventListener('unhandledrejection', (event) => {
  self.postMessage({
    type: 'FATAL_ASYNC',
    payload: event.reason?.stack || String(event.reason)
  });
  event.preventDefault(); // Keep thread alive for teardown
});
Register listeners before any other worker code

If any synchronous code at the top level of the worker script throws before your listeners are registered, neither self.onerror nor unhandledrejection will fire. Move all computation into message handlers or lazy-init functions, and place the listener registrations as the first two statements in the file.

Step 3: Serialize Stack Traces Without Memory Bloat

Error objects can be structured-cloned in modern engines (Chrome 72+, Firefox 103+), but only name, message, and stack are preserved — prototype methods and custom properties are lost. For maximum compatibility and to avoid surprises, extract only diagnostic primitives manually. Cap stack depth to five frames to prevent transmitting massive library traces.

// worker.js
function sanitizeError(err) {
  if (!(err instanceof Error)) return { message: String(err), type: typeof err };
  return {
    name: err.name,
    message: err.message,
    stack: err.stack?.split('\n').slice(0, 5).join('\n') ?? '',
    timestamp: performance.now()
  };
}

Step 4: Memory & Serialization Trade-offs

Error reporting during high-throughput processing can saturate the postMessage queue and trigger main-thread jank. Avoid transmitting large objects or deeply nested state with error payloads. Prefer manual field extraction over JSON.stringify(err), which traverses prototype chains and can expose unexpected data. Implement a short debounce if the worker can emit many errors in rapid succession.

Strategy Latency Impact Memory Footprint Recommendation
JSON.stringify(err) High (traverses prototype) Potentially unbounded Avoid
Manual Field Extraction Low (<0.1ms) Bounded (plain objects) Production standard
Debounced postMessage Medium (500ms buffer) Low (queue batching) Use for error storms
Transferable Metadata High (unnecessary copy) Medium Never use for errors

Step 5: Graceful Worker Restart & State Recovery

Upon receiving FATAL_SYNC or FATAL_ASYNC, the main thread must terminate the compromised worker immediately, clear its message listeners, and spawn a fresh instance with exponential backoff to prevent restart loops.

// main.js
let worker = null;
let restartAttempts = 0;
const MAX_RESTARTS = 5;

function spawnWorkerWithBackoff() {
  if (restartAttempts >= MAX_RESTARTS) {
    console.error('Worker restart limit reached. Falling back to main thread.');
    return;
  }

  const delay = Math.min(100 * Math.pow(2, restartAttempts), 2000);
  setTimeout(() => {
    worker = new Worker('./worker.js');
    worker.onerror = (e) => {
      console.error('Worker failed to initialize:', e.message);
      restartAttempts++;
      spawnWorkerWithBackoff();
    };
    // Reset restart count on successful first message
    worker.onmessage = (e) => {
      if (e.data.type === 'READY') restartAttempts = 0;
      handleMessage(e);
    };
  }, delay);
  restartAttempts++;
}

function handleMessage({ data }) {
  if (data.type === 'FATAL_SYNC' || data.type === 'FATAL_ASYNC') {
    console.warn('Worker crashed:', data.payload);
    if (worker) {
      worker.onmessage = null;
      worker.onerror = null;
      worker.terminate();
      worker = null;
    }
    spawnWorkerWithBackoff();
    return;
  }
  // Handle normal data flow...
}

// Initialize
spawnWorkerWithBackoff();

Frequently Asked Questions

Why doesn't try/catch inside a worker catch async rejections?
A try/catch only covers synchronous throws within its block. Promises that reject outside an await chain — such as a fire-and-forget fetch() or a rejected Promise returned by an event handler — bypass the catch block entirely. You must register self.addEventListener('unhandledrejection', handler) at the top of the worker script to intercept these, and call event.preventDefault() to keep the thread alive for state flush.
Can Error objects be sent directly over postMessage?
Chrome 72+ and Firefox 103+ can structured-clone Error objects, preserving name, message, and stack. However, custom properties and prototype methods are silently dropped. For maximum compatibility and predictable output, manually extract the fields you need — name, message, and the first five stack frames — into a plain object before calling postMessage.

See also