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 guide is a specialisation of Debugging, Profiling & Production Optimization 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 collector, enriching and sending events to a telemetry backend, controlling volume with fingerprinting and rate-limiting, and uploading source maps so the stack frames you receive are readable.

The Problem: A Failure Class With No Reporting Path

The symptom is specific and it is always the same shape. A spreadsheet import runs in a worker: the main thread hands over a 40 MB CSV, the worker parses it and posts back a typed array of parsed rows. In production, roughly 0.4% of imports never complete. The progress bar freezes at whatever percentage it last reported, the promise the UI is awaiting never settles, and support tickets say “it just hangs”. Your error backend shows nothing for those sessions — not a single event — because every reporting path the app has is attached to the wrong thread.

Three separate mechanics conspire here:

  1. A worker has its own global scope and its own error surface. An uncaught exception inside the worker fires an ErrorEvent on the Worker object on the main thread. It does not bubble to window, so a global window.addEventListener('error', …) — the thing most error SDKs install for you — never sees it.
  2. Rejected promises inside the worker stay inside the worker. An unhandled rejection fires unhandledrejection on the worker’s self. Nothing crosses the boundary at all. If your worker uses async message handlers (most do), this is the most common way a failure disappears.
  3. A crashed worker looks identical to a slow worker. From the main thread there is no signal distinguishing “still computing” from “threw 200 ms ago and will never reply”. Without telemetry the failure is indistinguishable from a long task, which is why these bugs survive for months.

The fix is a pipeline with two ends: global handlers inside the worker that catch everything and forward a serialized payload, and a single main-thread collector that enriches, deduplicates, rate-limits and dispatches. The rest of this guide builds that pipeline. Recovery policy — restart, backoff, checkpoint rehydration — is a separate concern covered in Error Handling & Crash Recovery; this guide is about making sure the failure is observed in the first place.

The same worker failure with and without a telemetry pipeline Left column: an uncaught throw inside the worker fires an ErrorEvent on the Worker object, nothing is listening, window.onerror and the global SDK handler never fire, so the backend records zero events and the awaiting promise never settles. Right column: self.onerror and unhandledrejection capture the failure, serializeError produces a plain object, postMessage delivers it to a main-thread collector that fingerprints and rate-limits, and the backend receives one event with a resolved source frame. Unobserved — no worker telemetry Instrumented — the pipeline below Uncaught throw / rejected promise inside the worker's own scope ErrorEvent fires on the Worker object nothing is listening for it window.onerror · global SDK handler never fires for worker errors Backend: 0 events 0.4 % of imports, no report UI: the promise never settles frozen progress bar, support ticket self.onerror + unhandledrejection registered at the top of the module serializeError(err) { name, message, stack, cause } postMessage → main-thread collector fingerprint · dedupe · token bucket POST /api/errors release · breadcrumbs · workerName Backend: 1 event, 1,471 repeats parse.ts:118 — a frame in your source
The same 40 MB import failure, twice. Without a pipeline the only signal is an ErrorEvent on the Worker object that nobody listens to; with one, every throw and rejection leaves the worker as a serialized payload and lands in the backend as a single deduplicated event.

Prerequisites

Before wiring telemetry, confirm these are in place:

  • Workers are built as separate entry-point chunks rather than inline blob strings, so each has a stable URL and its own source map — see Bundling Module Workers with Vite and webpack for the config that emits them.
  • Your bundler emits //# sourceMappingURL= comments or an equivalent SourceMap: header for each worker bundle, and your CI has credentials to upload the maps.
  • You have an error backend that accepts a JSON envelope: Sentry, Datadog, Rollbar, Bugsnag, or your own HTTP endpoint.
  • The main thread already has a global error handler for non-worker errors, so worker events land in the same stream and share a release identifier.
  • A stable, CI-generated release string (VITE_APP_VERSION, GIT_SHA, or similar) is available both at build time and at runtime — the source-map lookup fails silently without it.
  • You have read the onerror contract in Error Handling & Crash Recovery, and — if you are still reproducing the bug locally — the thread-switching workflow in Chrome DevTools Worker Debugging.

Building the Telemetry Pipeline

Six steps, in dependency order: catch inside the worker, serialize, collect on the main thread, enrich and send, control volume, and make the stacks readable. Each step notes what it costs you.

Step 1 — Register global handlers at the top of the worker module

Every worker script should register two global handlers before any other code runs — in particular before any await, dynamic import(), or WASM instantiation:

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

Returning true from self.onerror marks the event as handled, which suppresses the browser’s default console output and stops the error propagating to the Worker object’s error event on the main thread. That is a deliberate trade: you get exactly one report per error instead of two, at the cost of losing the browser-generated ErrorEvent. If you would rather keep both signals and deduplicate in the collector (Step 5), return false instead.

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. In a module worker, remember that static import statements are hoisted above this code — a throw inside an imported module's top level happens before your handler exists, so keep worker entry modules free of side-effecting imports.

Step 2 — Serialize the Error before it crosses the boundary

This is the most common production surprise. The structured clone algorithm can transfer Error objects — the HTML spec added serialization steps for them in 2021 — but real-world behaviour 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 an Error, because stack is not part of the spec’s serialization steps — it is an engine extension. So a Firefox user’s crash arrives at your backend with a type and a message and no frames at all, which is enough to know that something broke and never enough to know where. Extracting stack into a plain string before cloning locks in the raw stack regardless of the engine’s clone behaviour, and costs nothing.

DOMException deserves its own branch: it is cloneable, but instanceof Error is false for it in some engines, so the serializeError above would classify a QuotaExceededError from IndexedDB as UnknownError. The full type-preserving pair — DOMException, AggregateError, custom subclasses, and the matching deserializeError — is built in Structured Error Serialization Across Threads. The shared type contract both ends agree on:

// 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 well under 0.1 ms — three to four orders of magnitude below the clone cost of the data payloads these workers usually carry. This is not a hot path and needs no micro-optimization; correctness wins every trade here.

What survives the clone: live Error versus pre-serialized object The same TypeError is posted two ways. Posting the live Error object relies on each engine's structured-clone steps: Chrome 124 delivers name, message, stack and cause; Firefox 119 delivers everything except the stack, which it drops; Safari 17 delivers the stack but only partial cause support. Posting the output of serializeError sends four plain strings, so Chrome, Firefox and Safari all receive four of four fields with the full six-frame stack. TypeError: rows is undefined stack: 6 frames · cause set postMessage(err) — clone the live Error stack is an engine extension, not a spec clone step postMessage(serializeError(err)) plain strings before the boundary — nothing to drop name message stack cause name message stack cause Chrome 124+ Firefox 119+ Safari 17+ Chrome 124+ Firefox 119+ Safari 17+ Firefox: error.stack === undefined you learn that it broke, never where Every engine: 4 / 4 fields, 6 frames one payload shape, no engine branches arrives intact dropped partial
Cloning a live Error makes stack survival an engine decision — Firefox drops it. Serializing to four plain strings first makes the payload identical everywhere, at a cost of well under 0.1 ms.

Step 3 — Collect errors in a single main-thread pipeline

Centralize all worker error ingestion in one module so sampling, enrichment and backend dispatch can change without touching individual worker setups:

// error-pipeline.ts
import type { SerializedError, WorkerErrorMessage } from './types/worker-messages';

export interface WorkerErrorReport {
  serialized: SerializedError;
  workerName: string;
  filename: string;
  lineno: number;
  colno: number;
  timestamp: number;
  repeats?: number; // filled in by the admission step (Step 5)
}

type TelemetryHandler = (report: WorkerErrorReport) => void;

const handlers: TelemetryHandler[] = [];

export function addTelemetryHandler(fn: TelemetryHandler): void {
  handlers.push(fn);
}

export function dispatchWorkerError(msg: WorkerErrorMessage, workerName: string): void {
  const report: WorkerErrorReport = {
    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) {
    try {
      h(report);
    } catch {
      /* a telemetry handler must never break the app */
    }
  }
}

// Wire up a worker instance
export function attachWorkerErrorPipeline(worker: Worker, workerName: string): void {
  worker.addEventListener('error', (e: ErrorEvent) => {
    // Browser-generated: script failed to load, parse error, or an unhandled throw
    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,
    );
  });

  worker.addEventListener('messageerror', (e: MessageEvent) => {
    // The message arrived but could not be deserialized (uncloneable payload)
    dispatchWorkerError(
      {
        type: 'WORKER_ERROR',
        error: { name: 'MessageError', message: 'Failed to deserialize worker message', stack: '', cause: String(e.data ?? '') },
        context: { filename: '', lineno: 0, colno: 0, timestamp: Date.now() },
      },
      workerName,
    );
  });

  worker.addEventListener('message', (e: MessageEvent) => {
    if (e.data?.type === 'WORKER_ERROR') {
      dispatchWorkerError(e.data as WorkerErrorMessage, workerName);
    }
  });
}

There are three separate error surfaces on the main side, and production coverage needs all three:

  1. worker.onerror — fires when the worker script itself fails to load (404, MIME-type refusal, parse error, CSP block) or when the worker throws without a handled self.onerror. Note that for cross-origin worker scripts the browser sanitizes this event to the bare string "Script error." with empty filename and zero line numbers, so same-origin (or correctly CORS-configured) worker URLs are a hard requirement for useful reports.
  2. worker.onmessageerror — fires when a message arrives but cannot be deserialized. In practice this means someone tried to post an uncloneable value; it is rare, silent, and worth a distinct event name in your backend.
  3. The WORKER_ERROR message branch — everything self.onerror and unhandledrejection forwarded from inside the worker. This is the path that carries real stacks.
Use addEventListener, not the onmessage property

The pipeline above adds its own message listener instead of overwriting worker.onmessage. Assigning to the property clobbers whatever the application already set — and wrapping it (calling the previous handler from the new one) breaks the moment application code reassigns onmessage after your pipeline attached. Multiple addEventListener('message', …) registrations coexist safely; the trade-off is that your handler now sees every application message, so keep the WORKER_ERROR type check as the first statement and return immediately.

If you run a pool rather than a single worker, call attachWorkerErrorPipeline at the point of construction inside the pool so every replacement worker is instrumented too — see Worker Pool Management for where that hook belongs in the pool lifecycle.

Step 4 — Enrich and dispatch to the telemetry backend

Once an error reaches the collector, attach the context that makes it actionable: which worker, which release, what the user was doing:

// telemetry-handler.ts
import { addTelemetryHandler } from './error-pipeline';
import type { WorkerErrorReport } from './error-pipeline';

const ENDPOINT = '/api/errors';
const APP_VERSION = import.meta.env.VITE_APP_VERSION ?? 'dev';

addTelemetryHandler((report: WorkerErrorReport) => {
  const payload = {
    exception: {
      values: [{
        type: report.serialized.name,
        value: report.serialized.message,
        stacktrace: { raw: report.serialized.stack },
      }],
    },
    tags: {
      workerName: report.workerName,
      sourceFile: report.filename,
      thread: 'worker',
      repeats: String(report.repeats ?? 1),
    },
    contexts: {
      worker: {
        name: report.workerName,
        lineno: report.lineno,
        colno: report.colno,
      },
      runtime: {
        name: navigator.userAgent,
        hardwareConcurrency: navigator.hardwareConcurrency,
      },
    },
    release: APP_VERSION,
    breadcrumbs: collectBreadcrumbs(),
    timestamp: report.timestamp,
  };

  const body = JSON.stringify(payload);

  // Fire-and-forget; sendBeacon survives page teardown, fetch gives you a status code
  if (document.visibilityState === 'hidden' && navigator.sendBeacon) {
    navigator.sendBeacon(ENDPOINT, new Blob([body], { type: 'application/json' }));
  } else {
    fetch(ENDPOINT, {
      method: 'POST',
      body,
      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();
}

The single most valuable breadcrumb for worker errors is the message that caused the work: push a crumb with the message type and payload size every time you call worker.postMessage. When the crash report arrives you can then see “worker received PARSE_CSV, 41.2 MB, 180 ms before it died”, which usually identifies the input class immediately. If you are running the Sentry SDK rather than a hand-rolled endpoint, the equivalent wiring — SDK init inside the worker versus forwarding to the main thread’s client — is covered in Capturing Worker Stack Traces in Sentry.

sendBeacon has a size ceiling

fetch calls started during visibilitychange or page teardown can be cancelled; navigator.sendBeacon hands the request to the browser's network stack and survives the page. The trade-off: the spec-mandated beacon payload limit is 64 KB, the call is fire-and-forget with no response, and a false return means the queue was full. Use beacon only on the unload path, keep fetch with keepalive: true for live pages, and truncate stacks to a few kilobytes so neither transport rejects the body.

Step 5 — Fingerprint, deduplicate and rate-limit

A misbehaving worker that throws inside a for loop over 100,000 rows can generate a five-figure error count in under a second. Sending each one blows your backend quota, costs real money, and buries the other errors in the same window. Fingerprint first so repeats collapse, then apply a token bucket:

// rate-limiter.ts
import type { WorkerErrorReport } from './error-pipeline';

interface TokenBucket {
  tokens: number;
  lastRefill: number;
}

const buckets = new Map<string, TokenBucket>();
const seen = new Map<string, { count: number; firstSeen: number }>();

const RATE = 10;           // burst capacity, errors per worker
const WINDOW_MS = 60_000;  // rolling refill window
const SAMPLE_RATE = 0.1;   // 10 % sample once the bucket is empty
const DEDUPE_MS = 30_000;  // collapse identical errors seen within this window

/** Stable identity for an error: type + message + the top frames of the stack. */
export function fingerprint(report: WorkerErrorReport): string {
  const topFrames = report.serialized.stack.split('\n').slice(0, 3).join('|');
  return `${report.workerName}:${report.serialized.name}:${report.serialized.message}:${topFrames}`;
}

/** Returns the repeat count to attach to the event, or null if it should be dropped. */
export function admit(report: WorkerErrorReport): number | null {
  const now = Date.now();
  const fp = fingerprint(report);

  const prior = seen.get(fp);
  if (prior && now - prior.firstSeen < DEDUPE_MS) {
    prior.count++;
    return null; // already reported this exact failure in the current window
  }
  seen.set(fp, { count: 1, firstSeen: now });

  let bucket = buckets.get(report.workerName);
  if (!bucket) {
    bucket = { tokens: RATE, lastRefill: now };
    buckets.set(report.workerName, bucket);
  }

  // Refill proportionally to elapsed time
  const refill = Math.floor(((now - bucket.lastRefill) / WINDOW_MS) * RATE);
  if (refill > 0) {
    bucket.tokens = Math.min(RATE, bucket.tokens + refill);
    bucket.lastRefill = now;
  }

  if (bucket.tokens > 0) {
    bucket.tokens--;
    return prior?.count ?? 1;
  }

  // Beyond the limit: probabilistic sampling so pathological bursts still leave a trace
  return Math.random() < SAMPLE_RATE ? (prior?.count ?? 1) : null;
}

Wire it into the collector so every handler benefits:

// error-pipeline.ts (updated dispatchWorkerError)
import { admit } from './rate-limiter';

export function dispatchWorkerError(msg: WorkerErrorMessage, workerName: string): void {
  const report: WorkerErrorReport = { /* … as before … */ };

  const repeats = admit(report);
  if (repeats === null) return; // deduplicated, rate-limited, or sampled out

  for (const h of handlers) {
    try { h({ ...report, repeats }); } catch { /* never throw from telemetry */ }
  }
}

Deduplicating before the bucket check is the ordering that matters: a single recurring error otherwise consumes the whole token budget in its first 10 milliseconds and masks the genuinely new failure that arrives a second later. Flush the seen map periodically (or cap it at a few hundred entries) so a long-lived tab does not accumulate fingerprints indefinitely — this is exactly the kind of unbounded map that shows up later as a slow leak, as catalogued in Identifying Memory Leaks in Workers.

Admission funnel: 12,000 throws to 14 events Twelve thousand throws in under a second enter the fingerprint step, which collapses them to 47 distinct fingerprints. The token bucket admits 10 per worker per 60 seconds and pushes the remaining 37 into a 10 percent sample, which lets roughly 4 through. The two paths merge into 14 events delivered to the backend. 12,000 raw 47 distinct 10 admitted 12,000 throws one loop, under 1 s fingerprint() name + top 3 frames token bucket 10 per worker / 60 s 14 events sent to the backend 37 over budget 10 % sample of the overflow + 4 sampled 0.12 % of the raw volume reaches the backend — and no distinct failure is lost.
Fingerprinting runs before the bucket, so a tight loop collapses to one event per distinct failure; the bucket then caps the burst and the sample keeps a trace of what it rejected.

Step 6 — Upload source maps for minified worker bundles

Without source maps the frames you receive read at e (worker.min.js:1:28493), which tells you nothing. The build side of the pipeline is what makes Step 2’s stack strings useful.

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 },
      sourcemaps: { filesToDeleteAfterUpload: ['./dist/**/*.map'] },
    }),
  ],
});

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,
      release: { name: process.env.APP_VERSION },
    }),
  ],
};

The crucial detail: the worker bundle, its .map, and the release field in your telemetry payload must all agree. If the bundle ships as compute-worker.abc123.js while the runtime reports release v1.2.3 and the upload tagged the maps abc123, resolution fails and the backend shows minified frames with no error — it simply has nothing to match. Use one CI-generated version string, injected into the build and read back at runtime from the same variable.

Workers created from a Blob URL are the hard case: the stack frames point at blob:https://example.com/8f3c…, an identifier that changes every page load and matches no uploaded artifact. If you must ship an inline worker, append a //# sourceURL=compute-worker.js comment to the worker source string so frames carry a stable name; better, build workers as real entry points and keep blobs for prototypes only.

Never serve .map files publicly

Public source maps hand your original TypeScript to anyone who opens DevTools. Upload them to the error backend during CI and delete them from the deployed artifact — the Vite config above does this with filesToDeleteAfterUpload; webpack users can add a clean step after the plugin runs. Sentry, Datadog, Rollbar and Bugsnag all accept authenticated uploads designed for exactly this workflow.

Wire Format: Clone, Transfer or Share

Error reporting has an unusual profile compared with the rest of a worker’s traffic: payloads are tiny (single kilobytes), frequency is low in the healthy case and pathological in the broken case, and delivery must never block the work the worker is actually doing. That combination decides the transport:

Transfer method Viable here? Reason
Structured clone of a live Error Partial stack missing in Firefox; DOMException identity varies — use a plain object instead
Plain { name, message, stack, cause } object Yes Reliable in every engine, ~1–2 KB, no special handling on either side
JSON.stringify in the worker, JSON.parse on main Redundant Structured clone already handles plain objects; adds a serialize/parse round-trip for nothing
Transferable ArrayBuffer of encoded text No Zero-copy pays off in megabytes, not kilobytes; the encode step costs more than the clone saves
SharedArrayBuffer ring buffer No Errors are infrequent and bursty; shared memory adds cross-origin isolation requirements for no gain
Dedicated MessageChannel port Optional Worth it when the main data channel is saturated and error frames would queue behind gigabytes of payload

The SharedArrayBuffer row deserves its reasoning spelled out, because it looks superficially attractive for a high-frequency error stream. Shared memory requires the document to be cross-origin isolated: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, with self.crossOriginIsolated === true verified inside the worker. Taking on those headers — which break third-party embeds, ad tags and unadorned cross-origin images — to move a few kilobytes of error text is a bad trade. If your app is already isolated for other reasons, the lock-free patterns in SharedArrayBuffer & Atomics still do not help here: errors arrive too rarely to amortize the coordination cost. Keep telemetry on postMessage.

The one refinement worth making is the dedicated port. If a worker streams large results continuously, an error frame posted on the same channel queues behind the data already in flight, and arrival can lag the failure by hundreds of milliseconds — long enough for the tab to close first. Give errors their own MessageChannel:

// main.ts
const { port1, port2 } = new MessageChannel();
port1.onmessage = (e: MessageEvent) => {
  if (e.data?.type === 'WORKER_ERROR') dispatchWorkerError(e.data, 'compute-worker');
};
port1.start();
worker.postMessage({ type: 'INIT', errorPort: port2 }, [port2]); // port is transferred

// worker.ts
let errorPort: MessagePort | null = null;
self.addEventListener('message', (e: MessageEvent) => {
  if (e.data?.type === 'INIT') {
    errorPort = e.data.errorPort as MessagePort;
  }
});
// In onerror / unhandledrejection: (errorPort ?? self).postMessage(msg)

MessagePort is itself a transferable, so the handover is zero-copy and the port lives for the worker’s lifetime. The trade-off is one more object to tear down: call port1.close() when you terminate the worker, or the channel keeps both ends reachable. The broader taxonomy of when to clone, transfer or share is covered in Message Passing Strategies.

Error frame latency: shared channel versus dedicated port A worker throws at 8 milliseconds. On a shared channel the error frame queues behind three 8 MB result payloads and only reaches the main thread around 300 milliseconds, roughly 290 milliseconds after the throw. With a dedicated MessageChannel the data payloads continue on their own port while the error frame arrives on the error port about one millisecond after the throw. error thrown at 8 ms Shared channel data + errors share it 8 MB result 8 MB result 8 MB result the error frame lands ~290 ms after the throw Dedicated MessageChannel a port of its own 8 MB result 8 MB result 8 MB result data port error frame — arrives +1 ms error port 0 ms 100 ms 200 ms 300 ms
Error frames are tiny, but on a saturated channel they inherit the queue in front of them. A transferred MessagePort costs one object to tear down and removes the delay entirely.

Verification & Measurement

An error pipeline that is never tested is indistinguishable from no pipeline at all — and it fails silently by construction. Verify all five layers before deploying:

  1. Unit-test the serializer. Assert that serializeError(new TypeError('boom')) returns non-empty name, message and stack strings, that a nested cause recurses, and that a thrown non-Error (a string, undefined) still yields a valid SerializedError rather than throwing inside the handler.
  2. Unit-test the admission logic. Feed admit() 1,000 identical reports and assert exactly one is admitted inside the dedupe window; feed it 1,000 distinct fingerprints and assert the count admitted is close to RATE + SAMPLE_RATE * (1000 − RATE). Seeding Math.random (or injecting it) makes this deterministic.
  3. Integration-test the collector. Dispatch a synthetic WORKER_ERROR message into a stub Worker (an EventTarget is enough) and assert your telemetry handler receives the enriched report with the right workerName, release and breadcrumb tail.
  4. Smoke-test through a real worker. Ship a dev-only branch that throws on a __telemetry_smoke_test__ message, trigger it in a staging build, and confirm the event appears in the backend dashboard with resolved frames. This is the only step that proves the source-map upload worked, so run it on every release, not once.
  5. Measure the overhead. Wrap the dispatch path and confirm it stays off the critical path:
// Instrument the collector itself — telemetry must not become the bottleneck
const t0 = performance.now();
dispatchWorkerError(msg, 'compute-worker');
const cost = performance.now() - t0;
if (cost > 1) console.warn(`[telemetry] dispatch took ${cost.toFixed(2)} ms`);

On a mid-range laptop the synchronous portion — fingerprint, bucket check, payload construction — measures in the tens of microseconds; a 20-entry breadcrumb tail and a 4 KB stack keep JSON.stringify well under 0.5 ms. Everything after that is the browser’s network stack and costs the page nothing. If dispatch ever exceeds 1 ms you are almost certainly stringifying an oversized breadcrumb buffer.

Confirm the worker-side half in the Performance panel rather than by eye: record a trace, trigger the failure, and check that the postMessage from self.onerror appears in the worker’s track with no long task after it. The thread-switching workflow is in Chrome DevTools Worker Debugging, the equivalent per-thread view in Firefox Worker Debugging, and if the error path itself starts showing up as a cost, postMessage Bottleneck Analysis has the method for attributing it.

Verification ladder for the telemetry pipeline Five rungs, each with the assertion it makes and the failure it catches. One: serializer unit test, serializeError on a new TypeError returns name, message and stack, catching a thrown non-Error crashing the handler. Two: admission unit test, 1,000 identical reports admit exactly one, catching one bug spending the whole token budget. Three: collector integration test, a synthetic WORKER_ERROR message produces an enriched report, catching a wrong workerName or missing release tag. Four: staging smoke test, a telemetry smoke-test throw produces a backend event with resolved frames, catching a source-map upload that never resolved. Five: overhead measurement, performance.now around dispatch stays under one millisecond, catching an oversized breadcrumb buffer. step and assertion what it catches 1 Serializer unit test serializeError(new TypeError('boom')) → name, message, stack a thrown non-Error crashing the handler 2 Admission unit test 1,000 identical reports → exactly one admitted one bug spending the whole token budget 3 Collector integration test synthetic WORKER_ERROR → enriched report wrong workerName, missing release tag 4 Staging smoke test — every release __telemetry_smoke_test__ → event with resolved frames a source-map upload that never resolved 5 Overhead measurement performance.now() around dispatch → under 1 ms an oversized breadcrumb buffer
Rungs 1–3 run in CI on every commit; rung 4 is the only one that proves the source-map upload worked, so it belongs in the release pipeline rather than in a one-off checklist.

Failure Modes & Error Handling

Every entry below has been observed in a real deployment. The last two are the ones that make a working pipeline look broken.

Failure Cause Fix
Events arrive with no stack Firefox structured-clone drops stack from cloned Error objects Serialize error.stack to a string before postMessage (Step 2)
No worker errors at all No listener attached to the Worker object; window.onerror never fires for worker throws Call attachWorkerErrorPipeline immediately after new Worker(…), including inside pools
Message is "Script error.", no filename Worker script served cross-origin without CORS, so the browser sanitizes the event Serve worker bundles same-origin, or add Access-Control-Allow-Origin and construct with { credentials: 'same-origin' }
Async failures silently lost Rejections in async message handlers never reach self.onerror Register unhandledrejection on self (Step 1); never leave a floating promise in a handler
Duplicate events per failure self.onerror returned false, so the browser also fires error on the Worker object Return true from self.onerror, or let both fire and collapse them by fingerprint (Step 5)
Frames stay minified Maps not uploaded, or release tag mismatch between build and runtime Use one CI version string in the bundler, the upload, and the payload release field
Blob-worker frames unresolvable The blob: URL changes on every load and matches no uploaded artifact Build workers as real entry points; add //# sourceURL= if a blob is unavoidable
First error after deploy is dropped Cold bucket plus a burst of the same new bug Seed the bucket at full capacity per worker (as above) and always admit the first occurrence of a fingerprint
Telemetry crashes the app A handler throws — fetch rejection, JSON.stringify on a circular payload Wrap every handler in try/catch (Step 3); telemetry is best-effort by contract
unhandledrejection misses early failures Handler registered after a top-level await or side-effecting import Move registration to the first statements of the worker entry module
Events stop after a worker restart The replacement Worker object never got listeners Re-attach in the pool’s spawn path; see Error Handling & Crash Recovery

Two of these deserve a policy rather than a patch. First, telemetry must never throw — the pipeline exists to observe failures, and a collector that adds a second failure mode is worse than none; that is why both the dispatch loop and every handler are wrapped. Second, reporting is not recovery. A worker that threw is usually still alive but wedged, holding a promise the UI awaits forever. Pair every report with a supervisor that terminates and respawns, and reconcile the in-flight requests; the concrete state machine, backoff and checkpoint patterns are in Error Handling & Crash Recovery, and the narrow case of a synchronous throw in a dedicated worker is walked through in Fixing Uncaught Exceptions in Dedicated Workers.

Diagnosing a missing worker error event Starting from no event in the backend although the worker threw, four checks branch to a cause and a fix. Did the collector see it: if not, nothing is listening on the Worker object, so attach the pipeline right after new Worker. Is the message Script error: if yes, the cross-origin worker script was sanitized, so serve it same-origin or add CORS headers. Did admit drop it: if yes, dedupe, bucket or sampling removed it, so log the fingerprint and always admit the first hit. Did the request leave the page: if not, transport lost it on the unload path, so keep beacons under 64 KB and truncate the stack. If every check passes, the event reaches the backend but frames stay minified, which means the release string must match across the bundler, the map upload and the payload. No event in the backend but the worker definitely threw Did the collector see it? log inside dispatchWorkerError No Nothing is listening on the Worker object fix: attach the pipeline right after new Worker() Is the message 'Script error.'? filename empty, lineno 0 Yes Cross-origin worker script, event sanitized fix: serve it same-origin or add CORS headers Did admit() drop it? dedupe window · bucket · sample Yes Admission dropped it before dispatch fix: log the fingerprint; always admit the first hit Did the request leave? check the Network panel on unload No Transport lost it on the unload path fix: sendBeacon under 64 KB, truncate the stack It reaches the backend, but the frames are minified fix: one CI release string in the bundler, the map upload and the payload
Work down the spine in order: each check is cheap to run, and the first one that answers wrong names both the cause and the fix.

Browser Compatibility

Feature Chrome Firefox Safari Edge
self.onerror in Worker 4+ 3.5+ 4+ 12+
unhandledrejection on WorkerGlobalScope 49+ 69+ 11.1+ 79+
Error structured-clone (with stack) 98+ 93+ (no stack) 15.4+ 98+
Error.prototype.cause 93+ 91+ 15+ 93+
navigator.sendBeacon 39+ 31+ 11.1+ 14+
fetch with keepalive 66+ 99+ 13+ 79+
messageerror event on Worker 60+ 57+ 12+ 79+
MessageChannel in Workers 4+ 41+ 5+ 12+
Module Workers (type: 'module') 80+ 114+ 15+ 80+

Two rows drive design decisions. Error structured-clone with an intact stack bottoms out at Chrome/Edge 98 and is never available in Firefox — which is exactly why Step 2 pre-serializes instead of relying on it, making the pipeline correct on every engine in the table. And fetch with keepalive only reached Firefox 99, so the unload path needs the sendBeacon branch rather than assuming keepalive is universal.

Worker error telemetry pipeline Inside the worker thread a throw or rejection reaches self.onerror and unhandledrejection, then serializeError turns it into a plain object. postMessage carries it to the main thread, where a message listener passes it to admit for deduplication and the token bucket, then enrichment adds release and breadcrumbs before sendBeacon or fetch dispatches it. The telemetry backend receives the event and resolves it against the source maps that CI uploaded under the same release identifier. Worker Thread throw / reject in a message handler self.onerror unhandledrejection serializeError() { name, message, stack } postMessage Main Thread message listener WORKER_ERROR branch admit() dedupe · token bucket enrich release · breadcrumbs sendBeacon / fetch keepalive: true CI / Build source-map upload compute-worker.js.map release: v1.2.3 Telemetry Backend Sentry / Datadog parse.ts:118 resolved
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.

With the pipeline in place, a worker failure is no longer a hung progress bar and a support ticket: it is an event with a typed exception, a resolved frame in your own source, the message that triggered the work, and a repeat count that tells you how many users hit it. For the Sentry-specific wiring — SDK initialization inside the worker versus forwarding to the main-thread client, and typed captureException calls — see Capturing Worker Stack Traces in Sentry. For the production-grade serializer that preserves DOMException, AggregateError and custom subclasses in both directions, 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