Production Error Telemetry for Web Workers
Errors thrown inside a Web Worker are invisible to window.onerror β and in production, that means they silently disappear unless you build an explicit pipeline to capture and forward them. This page is part of the Debugging, Profiling & Production Optimization reference and walks through every layer of that pipeline: attaching global listeners inside the worker, serializing Error objects correctly across the thread boundary, wiring a main-thread error collector, sending enriched events to a telemetry backend, and controlling volume with sampling and rate-limiting.
The core problem: workers have isolated JavaScript heaps. An uncaught exception in a worker posts an ErrorEvent to the Worker object instance on the main thread β not to window. If you never attached a worker.onerror listener, that event is silently dropped. Promises that reject without a handler inside a worker fire unhandledrejection on self inside that worker, again invisible to the main thread. In a well-instrumented production app, these gaps mean whole categories of runtime failures go unreported.
Prerequisites
Before wiring telemetry, confirm these are in place:
- Workers are built as separate entry-point chunks (not blobbed inline strings) so source maps are possible.
- Your bundler emits
//# sourceMappingURL=comments or equivalent headers for each worker bundle. - You have an error backend: Sentry, Datadog, Rollbar, or your own HTTP endpoint.
- The main thread already has a global error handler pattern for non-worker errors.
- You have read the general Error Handling & Crash Recovery guide for the foundational
onerrorcontract.
Step 1 β Attach global error listeners inside the worker
Every worker script should register two global handlers at the very top, before any async code runs:
// worker.ts (top of file, before any imports or async setup)
import type { SerializedError, WorkerErrorMessage } from '../types/worker-messages';
function serializeError(err: unknown): SerializedError {
if (err instanceof Error) {
return {
name: err.name,
message: err.message,
stack: err.stack ?? '',
cause: err.cause instanceof Error
? serializeError(err.cause)
: String(err.cause ?? ''),
};
}
return { name: 'UnknownError', message: String(err), stack: '', cause: '' };
}
self.onerror = (event: ErrorEvent): boolean => {
const msg: WorkerErrorMessage = {
type: 'WORKER_ERROR',
error: serializeError(event.error ?? new Error(event.message)),
context: {
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
timestamp: Date.now(),
},
};
self.postMessage(msg);
return true; // prevent default browser console logging if desired
};
self.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
const msg: WorkerErrorMessage = {
type: 'WORKER_ERROR',
error: serializeError(event.reason),
context: {
filename: '',
lineno: 0,
colno: 0,
timestamp: Date.now(),
},
};
self.postMessage(msg);
event.preventDefault(); // suppress uncaught-rejection console noise
});
If your worker runs async initialization (importing a WASM module, fetching config), a rejection during that setup fires unhandledrejection before any business-logic handler is attached. Registering at the top of the module guarantees coverage from the first microtask.
Step 2 β Why Error objects donβt structured-clone with their stack
This is the most common production surprise. The structured clone algorithm can transfer Error objects β the spec added this in 2021 β but real-world behavior across engines is inconsistent:
| Engine | error.name |
error.message |
error.stack |
error.cause |
|---|---|---|---|---|
| Chrome 124+ | β | β | β | β |
| Firefox 119+ | β | β | β (omitted) | β |
| Safari 17+ | β | β | β | partial |
| Edge 124+ | β | β | β | β |
Firefox strips the stack property entirely when cloning Error. Even in Chrome, minified stacks survive the clone but are useless without source-map expansion β and source-map expansion happens on the main thread (or in Sentry), not inside the worker. Extracting stack to a plain string before cloning locks in the raw stack regardless of the engineβs clone behavior.
The shared type contract:
// types/worker-messages.ts
export interface SerializedError {
name: string;
message: string;
stack: string;
cause: string | SerializedError;
}
export interface WorkerErrorMessage {
type: 'WORKER_ERROR';
error: SerializedError;
context: {
filename: string;
lineno: number;
colno: number;
timestamp: number; // performance epoch ms
};
}
A SerializedError plain object is typically under 2 KB including a full stack trace. Structured-cloning it takes under 0.05 ms β this is not a hot path and needs no micro-optimization.
Step 3 β Build a main-thread error pipeline
Centralize all worker error ingestion in one place so you can add sampling, enrichment, and backend dispatch without touching individual worker setups:
// error-pipeline.ts
import type { SerializedError, WorkerErrorMessage } from './types/worker-messages';
export interface ErrorEvent {
serialized: SerializedError;
workerName: string;
filename: string;
lineno: number;
colno: number;
timestamp: number;
}
type TelemetryHandler = (event: ErrorEvent) => void;
const handlers: TelemetryHandler[] = [];
export function addTelemetryHandler(fn: TelemetryHandler): void {
handlers.push(fn);
}
export function dispatchWorkerError(msg: WorkerErrorMessage, workerName: string): void {
const event: ErrorEvent = {
serialized: msg.error,
workerName,
filename: msg.context.filename,
lineno: msg.context.lineno,
colno: msg.context.colno,
timestamp: msg.context.timestamp,
};
for (const h of handlers) h(event);
}
// Wire up a worker instance
export function attachWorkerErrorPipeline(worker: Worker, workerName: string): void {
worker.addEventListener('error', (e) => {
// Synchronous errors reported by the browser before the worker script ran
dispatchWorkerError(
{
type: 'WORKER_ERROR',
error: { name: 'WorkerLoadError', message: e.message, stack: '', cause: '' },
context: { filename: e.filename, lineno: e.lineno, colno: e.colno, timestamp: Date.now() },
},
workerName,
);
});
const originalOnMessage = worker.onmessage;
worker.onmessage = (e: MessageEvent) => {
if (e.data?.type === 'WORKER_ERROR') {
dispatchWorkerError(e.data as WorkerErrorMessage, workerName);
return;
}
if (originalOnMessage) originalOnMessage.call(worker, e);
};
}
There are two separate error surfaces on the main side:
worker.onerrorβ fires when the worker script itself fails to load (network error, parse error) or when the worker throws synchronously without aself.onerrorhandler. This is a browser-generatedErrorEventon theWorkerobject.worker.onmessage(ourWORKER_ERRORbranch) β fires whenself.onerror/unhandledrejectioninside the worker forwarded a serialized error throughpostMessage.
Both paths must be handled to get full coverage.
The pipeline wraps worker.onmessage to intercept WORKER_ERROR frames before passing other messages to the original handler. If your codebase uses addEventListener('message', β¦) instead of worker.onmessage, add a separate addEventListener for the WORKER_ERROR type and leave existing listeners untouched.
Step 4 β Sending to a telemetry backend with breadcrumbs and worker context
Once an error arrives at the main-thread pipeline, enrich it with context before forwarding:
// telemetry-handler.ts
import { addTelemetryHandler } from './error-pipeline';
import type { ErrorEvent } from './error-pipeline';
const ENDPOINT = '/api/errors';
const APP_VERSION = import.meta.env.VITE_APP_VERSION ?? 'dev';
addTelemetryHandler(async (event: ErrorEvent) => {
const payload = {
exception: {
values: [{
type: event.serialized.name,
value: event.serialized.message,
stacktrace: { raw: event.serialized.stack },
}],
},
tags: {
workerName: event.workerName,
sourceFile: event.filename,
},
contexts: {
worker: {
name: event.workerName,
lineno: event.lineno,
colno: event.colno,
},
runtime: {
name: navigator.userAgent,
},
},
release: APP_VERSION,
breadcrumbs: collectBreadcrumbs(),
timestamp: event.timestamp,
};
// Non-blocking fire-and-forget; use sendBeacon for page-unload scenarios
if (navigator.sendBeacon) {
navigator.sendBeacon(ENDPOINT, JSON.stringify(payload));
} else {
fetch(ENDPOINT, {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}).catch(() => {/* swallow β telemetry must never throw */});
}
});
// Minimal breadcrumb ring-buffer (replace with your own instrumentation)
const crumbs: Array<{ time: number; msg: string }> = [];
function collectBreadcrumbs() { return crumbs.slice(-20); }
export function addBreadcrumb(msg: string): void {
crumbs.push({ time: Date.now(), msg });
if (crumbs.length > 100) crumbs.shift();
}
fetch calls initiated during beforeunload or page teardown may be cancelled by the browser. navigator.sendBeacon queues the request into the browser's network stack with a guarantee to deliver even if the page closes β prefer it for all error telemetry where latency doesn't matter.
Step 5 β Sampling and rate-limiting
A misbehaving worker that throws in a tight loop can generate thousands of error events per second. Never send every one to a telemetry backend β that blows quota and costs money. Use a token-bucket on the main thread:
// rate-limiter.ts
interface TokenBucket {
tokens: number;
lastRefill: number;
}
const buckets = new Map<string, TokenBucket>();
const RATE = 10; // max errors per window
const WINDOW_MS = 60_000; // 60 s rolling window
const SAMPLE_RATE = 0.1; // 10 % sample beyond the hard limit
export function shouldReport(workerName: string): boolean {
const now = Date.now();
let bucket = buckets.get(workerName);
if (!bucket) {
bucket = { tokens: RATE, lastRefill: now };
buckets.set(workerName, bucket);
}
// Refill proportionally to elapsed time
const elapsed = now - bucket.lastRefill;
const refill = Math.floor((elapsed / WINDOW_MS) * RATE);
if (refill > 0) {
bucket.tokens = Math.min(RATE, bucket.tokens + refill);
bucket.lastRefill = now;
}
if (bucket.tokens > 0) {
bucket.tokens--;
return true; // within rate limit
}
// Beyond limit: probabilistic sampling
return Math.random() < SAMPLE_RATE;
}
Wire it into the pipeline dispatcher:
// error-pipeline.ts (updated dispatchWorkerError)
import { shouldReport } from './rate-limiter';
export function dispatchWorkerError(msg: WorkerErrorMessage, workerName: string): void {
if (!shouldReport(workerName)) return; // drop or sample
const event: ErrorEvent = { /* β¦ as before β¦ */ };
for (const h of handlers) h(event);
}
Apply deduplication by error fingerprint (name + message + stack.slice(0, 200)) before the rate check so a single recurring error doesn't consume your entire token budget and mask genuinely new failures.
Step 6 β Source maps for minified worker bundles
Without source maps, the stack traces you receive look like at e (worker.min.js:1:28493) β useless for debugging. The upload workflow depends on your bundler:
Vite:
// vite.config.ts
import { defineConfig } from 'vite';
import { sentryVitePlugin } from '@sentry/vite-plugin';
export default defineConfig({
build: {
sourcemap: true, // emit .map alongside every chunk
rollupOptions: {
input: {
main: 'src/main.ts',
'compute-worker': 'src/workers/compute.ts', // separate entry
},
},
},
plugins: [
sentryVitePlugin({
org: 'your-org',
project: 'your-project',
authToken: process.env.SENTRY_AUTH_TOKEN,
release: { name: process.env.VITE_APP_VERSION },
}),
],
});
webpack:
// webpack.config.js
const { sentryWebpackPlugin } = require('@sentry/webpack-plugin');
module.exports = {
devtool: 'source-map',
entry: {
main: './src/main.ts',
'compute-worker': './src/workers/compute.ts',
},
plugins: [
sentryWebpackPlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
}),
],
};
The crucial detail: the worker bundle and its .map file must share the same release identifier that you report in the error payload. If the bundle is named compute-worker.abc123.js at build time but the runtime reports release v1.2.3, the source-map lookup will fail. Use a stable version string from your CI pipeline in both places.
Never serve .map files publicly β they expose your original source. Upload them to your error backend during CI and delete them from the public build artifact. Sentry, Datadog, and Rollbar all support authenticated source-map upload APIs that work this way.
Data and Serialization Strategy
The serialization choices in this pipeline match the constraints of the use-case:
| Transfer method | Viable here? | Reason |
|---|---|---|
Structured clone Error |
Partial | stack missing in Firefox; use plain object instead |
Plain { name, message, stack, cause } object |
Yes | Reliable across all engines, tiny, no special handling |
JSON.stringify in worker, JSON.parse on main |
Overkill | Adds ~0.1 ms per event; not worth it for errors |
SharedArrayBuffer ring buffer |
No | Errors are infrequent; SAB adds COOP/COEP complexity for no gain |
MessageChannel dedicated port |
Optional | Useful if errors must bypass a congested main message channel |
For the rare scenario where a workerβs postMessage channel is saturated (e.g., streaming gigabytes of data), create a dedicated MessageChannel for error messages at worker startup and pass one MessagePort into the worker:
// main.ts
const { port1, port2 } = new MessageChannel();
port1.onmessage = (e) => {
if (e.data?.type === 'WORKER_ERROR') dispatchWorkerError(e.data, 'compute-worker');
};
worker.postMessage({ type: 'INIT', errorPort: port2 }, [port2]);
// worker.ts
let errorPort: MessagePort | null = null;
self.onmessage = (e) => {
if (e.data.type === 'INIT') {
errorPort = e.data.errorPort;
}
};
// In onerror / unhandledrejection: use errorPort.postMessage instead of self.postMessage
Verification and Measurement
After wiring the pipeline, verify it end-to-end before deploying:
- Unit test the serializer. Call
serializeError(new TypeError('test'))and assertname,message, andstackare non-empty strings in the result. - Integration test the pipeline. Post a fake
WORKER_ERRORmessage toattachWorkerErrorPipelineβs wrapped handler and assert the telemetry handler receives the enriched event. - Smoke test with a real worker. Inject a
throw new Error('telemetry-smoke-test')into a dev-only worker build and verify the event appears in your backend dashboard with a resolved stack frame. - Measure overhead. Use
performance.now()arounddispatchWorkerErrorβ it should be under 1 ms even with breadcrumb collection. The actual HTTP/sendBeacon dispatch is async and off the critical path. - Verify source maps. Check that the resolved stack frame in your backend points to the original TypeScript line, not the minified bundle.
Use the Chrome DevTools Worker Debugging panel (Sources β Workers) to manually trigger exceptions and confirm they propagate through the pipeline in development.
Failure Modes
| Failure | Cause | Fix |
|---|---|---|
| Errors arrive with no stack | Firefox structured-clone strips stack |
Serialize error.stack to a string before postMessage (Step 2) |
| No errors reported at all | worker.onerror not attached on main thread |
Use attachWorkerErrorPipeline immediately after new Worker(β¦) |
| Duplicate errors in backend | Both worker.onerror and postMessage path fire |
Deduplicate by fingerprint; worker.onerror fires for load failures only, postMessage for runtime errors |
| Stack shows minified frames | Source maps not uploaded or release tag mismatch | Fix release tag to match bundler output; verify upload in CI |
| Rate-limit drops important first-seen errors | Bucket starts empty after a cold deploy with latent bugs | Seed bucket with half capacity on first event per worker to absorb cold-start bursts |
| Telemetry throws and crashes the app | fetch rejection in handler propagates |
Wrap every handler in try/catch; telemetry must never throw |
unhandledrejection not caught |
Handler added after async worker init | Move listener registration to top of worker module, before any await |
Browser Compatibility
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
self.onerror in Worker |
4+ | 3.5+ | 4+ | 12+ |
self.addEventListener('unhandledrejection') |
49+ | 69+ | 11.1+ | 79+ |
Error structured-clone (with stack) |
98+ | 93+ (no stack) | 15.4+ | 98+ |
navigator.sendBeacon |
39+ | 31+ | 11.1+ | 14+ |
MessageChannel in Workers |
4+ | 41+ | 5+ | 12+ |
Module Workers (type: 'module') |
80+ | 114+ | 15+ | 80+ |
The lowest common denominator for full stack preservation is Chrome/Edge 98+. Firefox users get errors reported but without stack traces unless you pre-serialize (which this guide does). The serialization approach in Step 2 is the compatibility-safe default for all browsers in the table.
For the Sentry-specific wiring with SDK initialization and typed captures, see Capturing Worker Stack Traces in Sentry. For the general serialization pattern including DOMException and custom error subclasses, see Structured Error Serialization Across Threads.