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.onerrorandunhandledrejectionare registered before heavy computation begins. - Check the browser console for
Uncaught (in promise)messages — these indicate a missingunhandledrejectionlistener 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
});
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();