Capturing Worker Stack Traces in Sentry

Sentry’s browser SDK has no automatic instrumentation for Web Workers — errors thrown inside a worker do not reach the SDK unless you explicitly wire them up. This page shows how to do that concretely, starting from the Production Error Telemetry serialization pattern established in the parent guide. It is part of the broader Debugging, Profiling & Production Optimization reference.

Minimal Reproducible Example

The fastest path: serialize the error inside the worker, forward it over postMessage, and call Sentry.captureException on the main thread with worker context attached as tags.

// 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 can parse the stack
  const reconstructed = new Error(error.message);
  reconstructed.name = error.name;
  reconstructed.stack = error.stack; // raw stack string from the worker

  Sentry.withScope((scope) => {
    scope.setTag('worker', workerLabel);
    scope.setContext('worker_context', {
      workerLabel,
      userAgent: navigator.userAgent,
    });
    Sentry.captureException(reconstructed);
  });
});

// Also handle script-load failures (network / parse errors)
worker.addEventListener('error', (e: ErrorEvent) => {
  Sentry.captureException(new Error(`Worker load failed: ${e.message}`), {
    tags: { worker: 'compute-worker', errorType: 'load_failure' },
  });
});

Line-by-Line Walkthrough

Serialization in the worker (serialize function): Extracts name, message, and stack to plain strings before the postMessage call. This is necessary because Firefox 119 and earlier strip the stack property when structured-cloning Error objects across thread boundaries. Extracting it to a plain string value locks it in before the clone happens.

self.onerror returning true: Returning true from onerror prevents the browser from logging the uncaught exception to the console a second time. In development you may want to return false to preserve the console output; in production, returning true is cleaner.

event.preventDefault() on unhandledrejection: Suppresses the UnhandledPromiseRejection browser warning for rejections that your pipeline is now handling explicitly. Omit this if you want both the pipeline capture and the browser warning.

Sentry.withScope: Creates a temporary scope that adds the worker tag and context to only this one event, without polluting the global Sentry scope. This ensures worker errors appear in Sentry with a worker:compute-worker tag that you can filter on in the Issues dashboard.

Reconstructing a real Error on the main thread: Sentry’s captureException parses error.stack using its own stack-trace parser. If you pass a plain object, Sentry falls back to less precise parsing. Reconstructing a real Error instance with the raw stack string gives Sentry the best chance of applying source maps correctly — the stack string points to the worker bundle’s minified lines, and source-map lookup happens inside Sentry.

Alternative: Initializing the Sentry SDK Inside the Worker

For Module Workers (type: 'module') with their own long-lived session, you can initialize a second SDK instance inside the worker. This captures breadcrumbs that accumulate inside the worker (e.g. calls to internal functions) rather than only main-thread breadcrumbs.

// 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,
  // Disable session tracking inside workers — only the main thread owns sessions
  autoSessionTracking: false,
  integrations: [],
});

self.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
  Sentry.withScope((scope) => {
    scope.setTag('thread', 'worker');
    Sentry.captureException(event.reason);
  });
  event.preventDefault();
});

This approach opens a second HTTP connection from the worker to Sentry’s ingest endpoint, which adds a small network overhead and doubles your event quota usage if errors fire frequently. Use it only when worker-local breadcrumbs are genuinely valuable for debugging.

Uploading Worker Source Maps

Source maps must be uploaded under the same release name that Sentry.init reports at runtime. For a Vite project with workers as separate entry points:

// 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: {
        // Stable chunk name so source-map filenames are predictable
        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/**',
        // Delete source maps from the public artifact after upload
        filesToDeleteAfterUpload: './dist/**/*.map',
      },
    }),
  ],
});

After deployment, verify the upload succeeded under Project Settings → Source Maps in the Sentry dashboard. Look for the worker bundle filename (e.g. compute-worker.abc123.js) in the uploaded artifacts list.

Gotchas

Stack loses frames across postMessage. The stack string is captured at the moment the error is thrown, inside the worker. It reflects the worker’s call stack — at processRow (compute-worker.abc123.js:1:4821) — not anything on the main thread. This is correct and expected. Sentry will map compute-worker.abc123.js:1:4821 back to your TypeScript source using the uploaded .map file. Do not attempt to append main-thread frames; that would be misleading.

Double reporting. If you both attach worker.onerror on the main thread and forward via postMessage, the same error can appear twice in Sentry: once from the browser-generated ErrorEvent on the main thread and once from your WORKER_ERROR message. The worker.onerror path fires for script-load failures; the postMessage path fires for runtime exceptions. Keep them separate: use worker.onerror only for load-failure capture and the postMessage path for everything else.

Source-map release naming. A very common failure: VITE_APP_VERSION is undefined in CI, so Sentry.init receives 'dev' as the release, but the plugin uploads maps under a git SHA. Fix by setting VITE_APP_VERSION explicitly in your CI environment from git rev-parse --short HEAD or your deployment tag.

SDK inside worker hitting CORS. Sentry’s ingest endpoint (sentry.io) must be reachable from the worker’s fetch context. If you use a connect-src CSP directive, ensure https://*.sentry.io or your self-hosted ingest domain is included. Workers use the same CSP as the document for fetch requests.

Performance rule of thumb. The main-thread capture path (postMessage + captureException) adds roughly 0.5–2 ms per error event on the main thread. This is well below any frame budget concern — error capture is a cold path that fires rarely in healthy production apps.

Frequently Asked Questions

Should I initialize the Sentry SDK inside the worker or only on the main thread?
Both approaches work but have different trade-offs. Initializing on the main thread only is simpler: forward serialized errors via postMessage and call Sentry.captureException there. Initializing inside the worker adds a second SDK instance and a second network connection, which is justified when workers run in Module Worker contexts with their own long-lived sessions, or when you need worker-specific breadcrumbs. For most apps, main-thread capture is sufficient.
Why does Sentry show a minified stack trace even though I uploaded source maps?
The most common cause is a release tag mismatch. The release name you pass to Sentry.init({ release }) must exactly match the name you used when uploading source maps with sentry-cli or the Sentry build plugin. Also verify the dist field matches if you use it. Check under Project Settings → Source Maps in the Sentry dashboard for upload errors.

See also