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.
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 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.
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.