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 onerror contract.

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
});
Place these handlers before async setup

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
  };
}
Serialization cost is negligible

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:

  1. worker.onerror β€” fires when the worker script itself fails to load (network error, parse error) or when the worker throws synchronously without a self.onerror handler. This is a browser-generated ErrorEvent on the Worker object.
  2. worker.onmessage (our WORKER_ERROR branch) β€” fires when self.onerror / unhandledrejection inside the worker forwarded a serialized error through postMessage.

Both paths must be handled to get full coverage.

Don't replace onmessage blindly

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();
}
Use sendBeacon for unload-time errors

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);
}
Fingerprint before sampling

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.

Serving source maps in production

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:

  1. Unit test the serializer. Call serializeError(new TypeError('test')) and assert name, message, and stack are non-empty strings in the result.
  2. Integration test the pipeline. Post a fake WORKER_ERROR message to attachWorkerErrorPipeline’s wrapped handler and assert the telemetry handler receives the enriched event.
  3. 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.
  4. Measure overhead. Use performance.now() around dispatchWorkerError β€” it should be under 1 ms even with breadcrumb collection. The actual HTTP/sendBeacon dispatch is async and off the critical path.
  5. 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.


Worker error telemetry pipeline A worker error flows through self.onerror, is serialized to a plain object, sent via postMessage, received by the main-thread pipeline, rate-limited, enriched with context, and forwarded to the telemetry backend. Worker Thread throw / reject self.onerror / unhandledrejection serializeError() {name,message,stack} postMessage Main Thread worker.onmessage Rate limiter / token bucket Enrich context breadcrumbs Β· release sendBeacon / fetch (keepalive) Telemetry Backend Sentry / Datadog Source-map resolve CI / Build Source maps upload worker.min.js.map
Worker errors are serialized inside the worker, forwarded via postMessage, rate-limited and enriched on the main thread, then dispatched to the telemetry backend β€” where source maps uploaded at build time resolve minified frames.

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.

Frequently Asked Questions

Why don't worker errors appear in my main-thread error handler?
Workers run in isolated execution contexts. An uncaught exception inside a worker dispatches an ErrorEvent on the Worker object on the main thread, but window.onerror and window.addEventListener('error', …) on the main thread do not receive it. You must attach a dedicated worker.onerror listener on the main thread, or forward errors over postMessage from inside the worker.
Can I send an Error object directly via postMessage?
Structured clone can transfer Error objects, but the stack property is not guaranteed to survive the boundary in all browsers β€” Firefox omits it, and minified stacks are unreliable across engines. Always serialize to a plain object { name, message, stack, cause } before calling postMessage, then reconstruct on the main thread.
How do I attach source maps to minified worker bundles?
Bundlers like Vite and webpack emit a .map file alongside each chunk. For workers built as separate entry points, configure your bundler to output a named chunk and upload the .map to your error backend (e.g. Sentry’s sentry-cli sourcemaps upload) referencing the same release tag your runtime reports. Set //# sourceMappingURL= at the end of each bundle or serve the header SourceMap: <url>.
How do I avoid flooding my telemetry backend from a busy worker?
Add a client-side token-bucket rate limiter: allow N errors per rolling window (e.g. 10 per 60 s) and drop or sample the rest. Also deduplicate by error fingerprint (name + truncated stack) before sending, so a tight loop reporting the same error thousands of times costs only one event in your backend.

See also