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.

The two capture paths from a Web Worker into Sentry Three bands. In the worker scope band, a throw or rejected promise reaches self.onerror or the unhandledrejection listener, which calls serialize to produce a plain object holding name, message and stack. That envelope crosses the thread boundary by postMessage into the main thread band, where the worker.onmessage handler filters on the WORKER_ERROR type, rebuilds a genuine Error and assigns the raw worker stack string to it, then Sentry.withScope tags the event and captures it. A second, separate branch in the main thread band shows the Worker object error event catching script load, parse and CSP failures, which carry no Error object, joining the same withScope capture. The bottom band shows Sentry ingest receiving one event tagged worker colon compute-worker, and the Issues view displaying a de-minified frame at processRow in compute.ts line 118. runtime exception path load / parse failure path Worker scope Throw / rejected promise in the worker’s own scope self.onerror + unhandledrejection listener serialize(err) { name, message, stack } postMessage Main thread worker.onmessage type === 'WORKER_ERROR' new Error(message) err.stack = worker stack Sentry.withScope setTag · setContext · capture worker.addEventListener('error') load / parse / CSP failure — no Error object a synthetic Error, its own tag Sentry Ingest one event, tagged worker:compute-worker Issues view — de-minified frame at processRow (compute.ts:118:11)
Two capture paths, deliberately kept apart. Runtime exceptions are serialized inside the worker and rebuilt as a real 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

Anatomy of the WORKER_ERROR envelope On the left, the posted message object broken into four rows. The type field, WORKER_ERROR, routes the message and nothing else. error.name and error.message become the Sentry issue title, shown as RangeError colon row 812 out of range. error.stack feeds the SDK stack parser and therefore the source-map lookup, shown as at processRow in compute-worker.abc123.js. workerLabel becomes scope.setTag worker, which makes worker colon compute-worker a filter in the Issues view. A note records that every field is copied as a plain string because structured clone does not preserve Error.prototype.stack in every engine. Arrows link each row to its destination on the right. The posted WORKER_ERROR envelope Where each field lands in Sentry type: 'WORKER_ERROR' the discriminator your handler switches on error.name + error.message 'RangeError', 'row 812 out of range' error.stack 'at processRow (compute-worker.abc123.js:1:4821)' workerLabel 'compute-worker' All four are plain strings on purpose: structured clone does not preserve Error.prototype.stack in every engine. Message routing only never reaches the Sentry event Issue title and grouping the headline in the Issues list Stack parser → source maps the frames that get de-minified scope.setTag('worker', …) filter: worker:compute-worker Drop a field here and you lose exactly one capability there — none of them degrades gracefully.
Four fields, four jobs. 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.

One SDK on the main thread versus an SDK in each scope Left topology: the worker scope only serializes the error and posts it; a single Sentry SDK on the main thread calls captureException, producing one ingest connection and one event stream tied to the page session, with breadcrumbs recorded on the main thread only. Right topology: Sentry.init runs in both scopes, so two ingest connections and two event streams draw on the same quota, and because dedupe is per instance an error captured in both scopes is billed and displayed twice. Trade-off notes under each column cover quota, connection count and breadcrumb fidelity. A · one SDK, on the main thread B · an SDK in each scope postMessage Worker scope serialize() no SDK Main thread Sentry SDK captureException 1 ingest connection one event stream, one page session Breadcrumbs: main thread only the worker’s own fetch and console are lost Quota: one stream. Connections: one. Right choice for short-lived, task-shaped workers. Worker scope Sentry.init() local breadcrumbs Main thread Sentry.init() page session 2 ingest connections two event streams, one quota dedupe is per-instance capturing in both scopes counts twice Quota: two streams. Connections: two. Breadcrumbs recorded in the order they happened.
The trade is breadcrumb fidelity against a second transport. Column A keeps one SDK, one connection and one session; column B buys worker-local breadcrumbs at the price of a second event stream against the same quota — and per-instance dedupe, so an error must be captured in exactly one scope.
// 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.

Why a worker frame de-minifies: the release name has to match Step one, the build emits compute-worker with a content hash plus its map. Step two, the Sentry Vite plugin uploads those artifacts under a release name, here v1.4.2. Step three, the shipped bundle calls Sentry.init with a release name at runtime, also v1.4.2. Both names flow into a comparison node where Sentry matches them byte for byte, and dist must match as well. When they are identical the frame resolves to at processRow in compute.ts line 118. When they differ, or when the frame is a blob URL that matches no artifact path, the issue keeps the minified frame at n in compute-worker.abc123.js. 1 · build 2 · upload 3 · runtime vite build compute-worker.[hash].js + .map sentryVitePlugin uploads the chunk and its map Sentry.init({ release }) what the shipped bundle reports release at upload v1.4.2 release at runtime v1.4.2 compared byte-for-byte release and dist must both match identical differs Match — the map is applied at processRow (compute.ts:118:11) worker frames resolve to your source Mismatch — no artifact found at n (compute-worker.abc123.js:1:4821) blob: frames land here too — no path to match
The release name is the join key. Upload and runtime have to agree byte for byte — and 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

Triaging a worker stack that is wrong or still minified Four branches from one symptom. If the frames point at a blob URL, the cause is an inline blob worker whose URL exists only in that tab and matches no uploaded artifact; ship the worker as a real file, or add a sourceURL comment so the frames at least group. If the stack stops at the message handler, a plain object was handed to captureException; assign the raw worker stack string to a genuine Error instance first. If the message is Script error with an empty stack, importScripts pulled a cross-origin file served without CORS headers; serve it with Access-Control-Allow-Origin or self-host it. If everything is minified although maps were uploaded, the release at init differs from the release at upload, or dist differs; set the version explicitly from the deploy tag and fail the build when it is empty. symptom root cause fix Worker stack is wrong or minified frames point at blob:https://… the stack stops at the message handler message reads 'Script error.' all frames minified though maps uploaded that URL lives in one tab only, so it matches no artifact a plain object was handed to captureException importScripts pulled a cross-origin file served without CORS release at init is not the release at upload (or dist is) ship it as a real file with a stable URL, or add a //# sourceURL line assign the raw stack string to a genuine Error, then capture serve it with an Access-Control- Allow-Origin header, or self-host it set the version from the deploy tag and fail the build when it is empty
Four symptoms, four different causes. Only the last one is really about source maps — the first three are wiring faults that no amount of upload configuration will fix.

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

What one captured worker error costs a frame A full-width bar represents one 16.7 millisecond frame at 60 frames per second. The whole capture path occupies a narrow slice at its left edge, roughly 0.5 to 2 milliseconds, leaving the rest of the frame free. Below, the same three steps are redrawn on a 0 to 2 millisecond scale: structured-cloning the roughly 2 kilobyte envelope takes about 0.03 milliseconds, rebuilding the Error and tagging the scope about 0.10 milliseconds, and building the Sentry event and running beforeSend takes 0.5 to 1.5 milliseconds, which dominates the total. One 60 fps frame, and where the capture path sits in it capture ≈ 0.5–2 ms the rest of the frame is still yours 0 16.7 ms the same three steps, redrawn on a 0–2 ms scale structured clone (~2 KB) 0.03 ms Error rebuild + scope tagging 0.10 ms Sentry event build + beforeSend 0.5–1.5 ms 0 0.5 1.0 1.5 2.0 ms
Drawn to scale against a 16.7 ms frame, the whole capture path is a sliver — and inside that sliver the structured clone barely registers. Building the event and running 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.

Frequently Asked Questions

Should I initialize the Sentry SDK inside the worker or only on the main thread?
Both work, with different trade-offs. Main-thread-only capture is simpler: serialize the error, forward it over postMessage, and call Sentry.captureException there — one SDK instance, one transport, one session. Running the SDK inside the worker adds a second instance and a second ingest connection, which pays off when the worker is long-lived and you want breadcrumbs recorded from inside it (its own fetch calls, its own console output) rather than only main-thread breadcrumbs. For most applications, main-thread capture is enough.
Why does Sentry show a minified stack trace even though I uploaded source maps?
The usual cause is a release mismatch. The value passed to Sentry.init({ release }) must match byte-for-byte the release used when uploading maps with sentry-cli or the Sentry bundler plugin, and the dist value must match too if you set one. The second most common cause is a worker created from a blob URL: its frames are attributed to blob:https://…, which never matches an uploaded artifact path. Check Project Settings → Source Maps for the worker chunk filename before suspecting anything else.

See also