Capturing Worker Stack Traces in Sentry
Sentry’s browser SDK instruments the window context only — an exception thrown inside a Web Worker never reaches it unless you wire the two threads together yourself, and the stack trace you eventually receive is only as useful as the source maps that were uploaded under the matching release.
This page solves that one narrow problem end to end. It builds directly on the serialization envelope described in Production Error Telemetry for Web Workers, the parent guide for this topic, which itself sits under the Debugging, Profiling & Production Optimization reference. If you need the full typed serializer — cause chains, DOMException, custom Error subclasses — read Structured Error Serialization Across Threads alongside this page; the code below uses a deliberately minimal version so the Sentry wiring stays visible.
Minimal Reproducible Example
The shortest path that produces a symbolicated worker stack in Sentry: serialize the error where it is thrown, forward it over postMessage, and reconstruct it on the main thread before calling Sentry.captureException.
Error on the main thread; script load, parse and CSP failures never produce a cross-thread Error at all, so they arrive as a synthetic one from the Worker object’s own error event.// worker.ts
interface SerializedError {
name: string;
message: string;
stack: string;
}
function serialize(err: unknown): SerializedError {
if (err instanceof Error) {
return { name: err.name, message: err.message, stack: err.stack ?? '' };
}
return { name: 'UnknownError', message: String(err), stack: '' };
}
self.onerror = (event: ErrorEvent): boolean => {
self.postMessage({
type: 'WORKER_ERROR',
error: serialize(event.error ?? new Error(event.message)),
workerLabel: 'compute-worker',
});
return true;
};
self.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
self.postMessage({
type: 'WORKER_ERROR',
error: serialize(event.reason),
workerLabel: 'compute-worker',
});
event.preventDefault();
});
// main.ts
import * as Sentry from '@sentry/browser';
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
release: import.meta.env.VITE_APP_VERSION,
tracesSampleRate: 0.2,
});
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
worker.addEventListener('message', (e: MessageEvent) => {
if (e.data?.type !== 'WORKER_ERROR') return;
const { error, workerLabel } = e.data as {
error: { name: string; message: string; stack: string };
workerLabel: string;
};
// Reconstruct a real Error so Sentry's stack parser has something to chew on
const reconstructed = new Error(error.message);
reconstructed.name = error.name;
reconstructed.stack = error.stack; // raw stack string captured inside the worker
Sentry.withScope((scope) => {
scope.setTag('worker', workerLabel);
scope.setContext('worker_context', {
workerLabel,
userAgent: navigator.userAgent,
});
Sentry.captureException(reconstructed);
});
});
// Script load / parse failures never reach the postMessage path
worker.addEventListener('error', (e: ErrorEvent) => {
Sentry.captureException(new Error(`Worker load failed: ${e.message}`), {
tags: { worker: 'compute-worker', errorType: 'load_failure' },
});
});
Step-by-Step Walkthrough
name and message decide what the issue is called, stack decides whether it symbolicates, workerLabel decides whether you can filter it, and type never leaves the message channel.serialize() flattens the error before it crosses the boundary. The structured clone algorithm can copy Error objects, but the stack property is implementation-defined: Chromium preserves it, Gecko does not. Copying name, message and stack into plain strings first removes the engine dependency entirely — a string always clones. This is the same reason the parent guide insists on an explicit envelope rather than posting the Error itself.
self.onerror returns true. Returning true marks the error as handled and suppresses the default console report, so the exception is not logged twice. During development, return false if you would rather keep the browser’s own console output next to your captured event.
event.preventDefault() on unhandledrejection. Rejections your pipeline has already claimed do not need the browser’s “Uncaught (in promise)” warning as well. Drop the call if you want both.
Reconstructing a real Error on the main thread. Sentry.captureException runs its stack parser over error.stack. Handed a plain object, the SDK falls back to a much weaker path — a serialized event with no frames, or frames pointing at your own message handler. Assigning the raw worker stack string to a genuine Error instance means the frames Sentry sees are at processRow (compute-worker.abc123.js:1:4821) — worker frames, worker file, worker line numbers, which is exactly what the uploaded worker source map can resolve.
Sentry.withScope keeps the tagging local. The scope callback applies the worker tag and worker_context to this one event only, leaving the global scope untouched so unrelated main-thread errors are not mislabelled. In the Issues view, worker:compute-worker becomes a first-class filter, which matters once several worker types report into the same project.
The error listener on the Worker object is a different failure class. It fires when the script fails to load, fails to parse, or is blocked by CSP. Note that event.error is null on that event and only message, filename and lineno are populated — there is no cross-thread Error object to recover, which is why it is captured as a synthetic error with its own errorType tag rather than folded into the runtime path.
Alternative: Running the SDK Inside the Worker
For a long-lived module worker you can initialize a second SDK instance in the worker scope. The gain is worker-local breadcrumbs — the worker’s own fetch calls and console output — recorded in the order they actually happened, instead of a main-thread breadcrumb trail that says nothing about what the worker was doing.
// worker.ts (SDK-inside approach)
import * as Sentry from '@sentry/browser';
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
release: import.meta.env.VITE_APP_VERSION,
// Default integrations assume a document: pass an explicit list so nothing
// touches window/document inside the worker scope.
defaultIntegrations: false,
integrations: [
Sentry.dedupeIntegration(),
Sentry.functionToStringIntegration(),
],
});
self.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
Sentry.withScope((scope) => {
scope.setTag('thread', 'worker');
Sentry.captureException(event.reason);
});
event.preventDefault();
});
Two details decide whether this is worth it. First, session tracking belongs to the page, not the worker: never enable the browser-session integration in worker scope or every worker start will look like a new user session. Second, a second SDK means a second ingest connection and a second stream of events against the same quota — with dedupeIntegration only deduplicating within its own instance, an error captured in both scopes counts twice. Pick one capture path per error, not both.
Uploading Worker Source Maps
A worker built as a separate entry point produces a separate chunk and a separate .map. Sentry resolves them only if the release name at runtime matches the release name at upload time exactly.
dist too, if you set one — or Sentry has no artifact to look the worker chunk up in and leaves the frames minified.// vite.config.ts
import { defineConfig } from 'vite';
import { sentryVitePlugin } from '@sentry/vite-plugin';
export default defineConfig({
build: {
sourcemap: true,
rollupOptions: {
input: {
main: 'src/main.ts',
'compute-worker': 'src/workers/compute.ts',
},
output: {
// Predictable chunk names keep source-map filenames greppable
entryFileNames: '[name].[hash].js',
},
},
},
plugins: [
sentryVitePlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
release: { name: process.env.VITE_APP_VERSION ?? 'dev' },
sourcemaps: {
assets: './dist/**',
// Strip maps from the deployed artifact once they are uploaded
filesToDeleteAfterUpload: './dist/**/*.map',
},
}),
],
});
Declaring the worker as its own Rollup input is what makes this predictable; if you instead let the bundler inline the worker or emit it under a rotating internal name, the artifact list becomes hard to verify. The trade-offs of each bundling style are covered in Bundling Module Workers with Vite and webpack.
After deploying, open Project Settings → Source Maps and confirm the worker bundle — compute-worker.abc123.js — appears in the uploaded artifacts for that release. If it is missing, no amount of runtime configuration will de-minify the frames.
Gotchas and Edge Cases
Blob-URL workers break source-map lookup. A worker created from new Worker(URL.createObjectURL(blob)) reports frames as blob:https://example.com/8f3a…, a URL that exists only for that tab and matches no uploaded artifact. Sentry will show raw minified frames no matter how correct your release tags are. If you need telemetry-grade stacks, ship the worker as a real file with a stable URL; if the inline form is non-negotiable, append a //# sourceURL=compute-worker.js comment to the worker source so the frames carry a name you can at least group on.
The stack contains worker frames only, and that is correct. The stack string is captured at throw time inside the worker, so it reflects the worker call stack and stops there — there are no main-thread frames because the main thread was not on that stack. Do not splice in caller frames from the message handler; you would be inventing a call path that never executed. Attach the calling context as scope data instead (job id, input size, queue depth), which is what worker_context above is for.
Double reporting when both paths fire. Attaching a listener to the Worker object’s error event and forwarding over postMessage can produce two issues for one fault. Keep the two responsibilities separate: the Worker object’s error event owns load and parse failures, the postMessage envelope owns runtime exceptions. The dedup pass, rate limiting and fingerprinting for the combined stream are described in the parent guide; see also Fixing Uncaught Exceptions in Dedicated Workers for the handler-level view.
Script error. with no stack. A classic worker that pulls in a third-party script with importScripts() will surface sanitized errors — message Script error., empty stack — when that script is served cross-origin without Access-Control-Allow-Origin. Serve the dependency with CORS headers, or self-host it, before concluding the pipeline is broken.
CSP blocks the worker’s own transport. If the SDK runs inside the worker, its fetch to the ingest endpoint is governed by the document’s connect-src directive. Add https://*.sentry.io (or your self-hosted ingest host) or the events are dropped silently in the worker with nothing in the main-thread console to hint at it.
Undefined release in CI. The most common source-map failure has nothing to do with workers: VITE_APP_VERSION is unset in the CI environment, so Sentry.init reports dev while the plugin uploads under a git SHA. Set it explicitly from git rev-parse --short HEAD or your deploy tag, and fail the build if it is empty.
Performance Note
beforeSend is the cost, which is why deduplication in front of the dispatch beats micro-optimizing the copy.On a mid-range laptop, the main-thread capture path costs roughly 0.5–2 ms per error event: structured-cloning a ~2 KB envelope is a fraction of that (well under 0.1 ms), and the bulk is Sentry building the event object and running beforeSend. Transport is not on that path — the SDK sends the envelope asynchronously.
Measure it in your own app rather than trusting the range:
worker.addEventListener('message', (e: MessageEvent) => {
if (e.data?.type !== 'WORKER_ERROR') return;
const t0 = performance.now();
captureWorkerError(e.data); // the withScope + captureException block above
performance.measure('worker-error-capture', { start: t0, end: performance.now() });
});
The rule of thumb: at under 2 ms this is comfortably inside a single 16.7 ms frame, so a handful of errors costs nothing visible. It stops being free when a tight loop throws hundreds of times a second — each capture is a synchronous main-thread block, and the aggregate does drop frames. That is the argument for fingerprint-based deduplication and a token-bucket rate limiter in front of the dispatch, both detailed in the parent guide, rather than for micro-optimizing the capture itself. If you want to see the cost in a real profile, the technique is the same one used in Profiling Worker CPU Usage with the Chrome Performance Tab.