Setting Breakpoints in Blob and Inline Workers

A worker built from a string has no path, no stable identity, and no source map — so it appears in DevTools as blob:https://example.com/8f3c1a2e-…, a name that changes on every load and takes every breakpoint with it.

Three additions fix it, and all three belong in the code that generates the worker rather than in the debugger. This guide extends Chrome DevTools Worker Debugging, part of Debugging, Profiling & Production Optimization.


Give the Script a Name

//# sourceURL= is a comment the engine reads. It names the script for the debugger, the console, and every stack trace it appears in:

const source = `//# sourceURL=hash-worker.js
self.onmessage = (event) => {
  const digest = hash(event.data.bytes);
  self.postMessage({ id: event.data.id, digest });
};

function hash(bytes) { /* … */ }
`;

const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
const worker = new Worker(url, { name: 'hash-worker' });   // `name` also labels the thread
URL.revokeObjectURL(url);

Two names, two purposes. The sourceURL comment names the script, so it appears as hash-worker.js in the Sources tree and breakpoints bind to it across reloads. The name option names the thread, which is what appears in the thread selector and in the Performance panel’s track list — with a dozen workers running, that label is the difference between a readable profile and a wall of “Worker”.

Put the comment on the very first line. Some tooling only scans the beginning of a script for it, and a comment placed at the end works in the console but not always in the debugger.

How a blob worker appears in DevTools with and without naming Two Sources-panel listings. Without naming, the worker appears under a blob URL with a random identifier, the thread is labelled simply Worker, breakpoints do not survive a reload, and stack traces cite the blob identifier. With a sourceURL comment and a name option, the script appears as hash-worker.js in a stable position, the thread is labelled hash-worker in both the thread selector and the performance track list, breakpoints rebind after reload, and stack traces name the file and line. The same worker, before and after two lines unnamed Sources: blob:https://…/8f3c1a2e-4b Threads: "Worker", "Worker", "Worker" Breakpoints: lost on every reload Stacks: at blob:https://…:41:17 Profile tracks: indistinguishable every session starts from scratch named Sources: hash-worker.js Threads: "hash-worker", "csv-worker" Breakpoints: rebind after reload Stacks: at hash-worker.js:41:17 Profile tracks: labelled per worker two lines in the generator, permanently Nothing here is a DevTools setting — both improvements come from the code that builds the worker.
The stack-trace row is the one that pays off outside debugging sessions: a production error report from an unnamed blob worker cites an identifier that means nothing.

Source Maps for Generated Code

If the worker source is generated — compiled from TypeScript, assembled from templates, produced by a bundler’s inline mode — a source map restores the original view. The map is attached the same way, as a trailing comment:

// Append after the generated code; the map may be a URL or a base64 data URL.
const source = `${generated}\n//# sourceMappingURL=data:application/json;base64,${btoa(mapJson)}`;

Inline data-URL maps are the practical choice here because the map has to travel with a script that has no directory to be relative to. They are large — often bigger than the code — which is why they belong behind a development flag rather than in a production bundle.

Where the worker source is produced by your own bundler in inline mode, check whether it emits the map at all: several tools drop it silently for inlined workers, and the symptom is a debugger showing generated code that nearly matches the original but with wrong line numbers, which is worse than showing no map at all.


Break Before the First Line

The hardest blob-worker bugs happen during construction — a syntax error in generated source, a missing global, a throw in initialisation — and are over before a breakpoint can be placed. Two ways to stop earlier:

A debugger; statement in the generated source, behind a flag:

const source = [
  '//# sourceURL=hash-worker.js',
  import.meta.env.DEV && new URLSearchParams(location.search).has('debugWorker') ? 'debugger;' : '',
  body,
].join('\n');

DevTools’ pause-on-start for workers, which halts every new worker before it evaluates its module body. Useful when you do not control the generator — a third-party library that inlines its own worker, for instance — and the only way to inspect what that library actually shipped.

Once paused, the Sources panel’s thread selector switches the debugger’s context between the page and each worker. Watch expressions, scope inspection and stepping all work as they do on the main thread; the difference is that self rather than window is the global, and DOM APIs are absent.


Reading Errors From an Unnamed Worker

Production error reports from blob workers are the reason to name them, because a report that says blob:https://app.example.com/2a91… line 41 is unactionable: the identifier is per-session, the line number is of generated code, and no build artefact matches either.

With a sourceURL and a source map, the same failure reports a file and a line in the original source. Wire the worker’s errors deliberately rather than relying on the default:

// inside the generated worker
self.addEventListener('error', (event) => {
  self.postMessage({
    kind: 'FATAL',
    message: event.message,
    file: event.filename,        // 'hash-worker.js' once sourceURL is set
    line: event.lineno,
    column: event.colno,
  });
});

The page then forwards that to the error reporter with its own context — build id, user action, request in flight. Without the sourceURL, event.filename is the blob identifier and the report has nothing to correlate against; the fuller reporting pipeline is in Capturing Worker Stack Traces in Sentry.

Debugging setup for a generated worker, in order Four steps applied to the generated source in order. First, a sourceURL comment on the first line names the script so breakpoints bind and stack traces are readable. Second, the worker constructor's name option labels the thread in the thread selector and the performance panel. Third, an inline source map, enabled only in development, restores the original source view. Fourth, an optional debugger statement or the pause-on-start setting catches failures that occur during module evaluation, before any breakpoint could be placed. Four additions, all in the generator 1 · sourceURL stable script name breakpoints survive 2 · name option labels the thread readable profiles 3 · inline source map original source view development only 4 · break on start catches failures during evaluation The order matters Steps 1 and 2 cost two lines and pay off in production error reports as well as in the debugger. Steps 3 and 4 are development conveniences and should not ship. A generated worker with none of these is effectively undebuggable once it leaves your machine.
Only the first two belong in production builds — and they are the two that make a crash report from a real user mean something.

Debugging a Worker You Did Not Write

Third-party libraries inline their own workers routinely — parsers, PDF renderers, compression tools, analytics SDKs — and when one misbehaves you have none of the hooks above. Four techniques recover enough visibility to diagnose it.

Pause on start, then read the source. With worker pause-on-start enabled, the library’s generated script appears in the Sources panel before it runs, and you can read exactly what shipped. This is the fastest way to answer “is this the version I think it is?”

Instrument the constructor. Patching Worker in development reveals what is being created, from where, and how often:

if (import.meta.env.DEV) {
  const Original = window.Worker;
  window.Worker = class extends Original {
    constructor(url: string | URL, options?: WorkerOptions) {
      console.debug('worker created', String(url), options, new Error().stack);
      super(url, options);
    }
  } as typeof Worker;
}

That one patch answers several questions at once: whether a library creates one worker or one per call, whether it inlines or fetches, and — via the stack — which call site triggered it.

Observe the traffic. A postMessage patch on the instance logs the protocol without touching the library’s code, which is usually enough to tell whether the worker received a malformed request or produced a bad reply.

Check for a non-inlined build. Many libraries ship both an inlined and a file-based worker build, with the second easier to debug and better for caching. Switching to it during investigation often makes the problem visible immediately, and is frequently the better choice to keep afterwards for the reasons in Creating Workers from Blob URLs Safely.


Gotchas

A sourceURL that collides with a real file. Naming a generated worker worker.js when the project also ships worker.js produces two entries with one name, and breakpoints attach to whichever DevTools resolves first. Use a distinctive name.

Blob workers created and destroyed rapidly. A worker per operation means a script per operation in the Sources tree, and the debugger loses the one you were looking at. Keep a single long-lived worker while debugging, even if production creates them per job.

Console logs without a thread label. Worker output is interleaved with the page’s, and identical messages from four pool members are indistinguishable. Prefix logs with the worker’s name, which the worker can read from self.name.

Stepping into code the map does not cover. A partial source map — mapping your code but not an inlined dependency — steps into unmapped generated output without warning. Check that the map’s sources array covers everything you expect to step through.

Assuming other engines behave identically. sourceURL is broadly supported, but worker debugging ergonomics differ; Firefox’s approach and its differences are covered in Firefox Worker Debugging.


Logging that never reaches the console. A worker created and terminated within the same tick can produce console output that DevTools attributes to a thread that no longer exists, and it may be dropped entirely. Post diagnostics to the page instead of logging them in a short-lived worker; the page’s console outlives the thread.

Conditional breakpoints on a hot handler. A conditional breakpoint inside a message handler that runs thousands of times per second evaluates its condition every time, and can slow the worker by an order of magnitude — enough to change the behaviour you were investigating. Prefer a logpoint on a sampled condition, or a counter posted to the page.

Assuming the debugger sees the same self. The worker’s global is not the page’s, so a value inspected in the page console is a different object from the one in worker scope even when both are called config. Switch threads in the selector before evaluating, or the numbers will disagree for reasons that have nothing to do with the bug.


What each debugging addition costs in a production build Four additions with their production cost. A sourceURL comment is about thirty bytes and names the script in every stack trace. The name option on the constructor is free and labels the thread in the profiler. An inline source map is one to two times the size of the generated code and belongs behind a development flag. A debugger statement must never ship, because it halts execution whenever DevTools is open. Which additions belong in the shipped build //# sourceURL comment ≈30 bytes — names the script in every stack trace the name option on the constructor free — labels the thread in the profiler and selector an inline source map 1–2× the code size; development flag only a debugger statement never ships — it halts whenever DevTools is open The two olive rows are the ones that make a production crash report actionable.
Ship the top two always; gate the bottom two behind a flag that cannot leak into a release build.

Performance Note

The debugging additions cost almost nothing in production: a sourceURL comment is about 30 bytes and the name option is free. An inline source map, by contrast, is typically 1.2–2× the size of the generated code and is embedded in the page bundle when the worker is inlined — which is why it belongs behind a development flag, and why shipping one by accident is a bundle-size regression that no dependency audit will explain.

One habit is worth adopting regardless of tooling: name every worker, generated or not, at the moment it is created. The cost is a string; the benefit is that every profile, every console line and every crash report from that point onward identifies which thread produced it.

Frequently Asked Questions

Why do my breakpoints disappear after every reload?
Because a blob URL is a fresh identifier on every page load, DevTools cannot match a breakpoint set on blob:https://…/8f3c to the new blob:https://…/2a91. Add a //# sourceURL=my-worker.js comment to the generated source: the script then has a stable name, breakpoints bind to that name, and they survive reloads.
How do I catch an error that happens before I can attach?
Use break-on-start. In Chrome DevTools, enable the “Pause on start” option for workers (or add a debugger; statement as the first line of the generated source), so the worker halts before executing its module body. Construction-time failures — a syntax error, a missing import, a throw in initialisation — are otherwise over before any breakpoint can be set.

See also