Creating Workers from Blob URLs Safely
Turning a string into a running thread takes three lines, and every one of them has a way to go wrong: a policy that blocks the URL scheme, an object URL that is never revoked, and a base URL that breaks every relative import inside the worker.
This is the operational detail behind the inline option compared in Inline Workers vs Dedicated Workers, part of Web Workers Architecture & Communication. Blob workers are the right tool in a few specific situations — a library that must ship as one file, code generated at runtime, a worker whose script would otherwise be cross-origin — and the wrong tool for ordinary application code, which should be emitted as its own chunk by the bundler.
The Three Lines, Done Correctly
// inline-worker.ts
export function createInlineWorker(source: string): Worker {
// `type: 'text/javascript'` is not optional in some engines' strict modes.
const blob = new Blob([source], { type: 'text/javascript' });
const url = URL.createObjectURL(blob);
try {
// The constructor begins fetching synchronously against `url`.
return new Worker(url);
} finally {
// Safe here, and necessary: the URL keeps the blob alive until revoked.
URL.revokeObjectURL(url);
}
}
const worker = createInlineWorker(`
//# sourceURL=inline-hash-worker.js
self.onmessage = (event) => {
const { id, values } = event.data;
let sum = 0;
for (const v of values) sum += v;
self.postMessage({ id, sum });
};
`);
Two of the three lines are about lifetime. URL.createObjectURL registers an entry in a per-document map that keeps the blob’s bytes resident; nothing collects it automatically while the document lives. A page that creates a worker per operation and never revokes accumulates the full source text of every worker it ever built — small individually, unbounded over a long session.
The //# sourceURL comment is the difference between a debuggable worker and an anonymous one. Without it, DevTools lists the script as blob:https://example.com/8f3c1a2e-…, which changes on every page load, cannot be bookmarked, and makes a breakpoint impossible to keep. With it, the worker appears under a stable name in the sources tree — the technique covered in Setting Breakpoints in Blob and Inline Workers.
Content Security Policy
A blob worker is created from a blob: URL, and a policy that does not allow that scheme blocks it at construction. The relevant directive is worker-src, falling back to child-src and then script-src:
Content-Security-Policy: worker-src 'self' blob:;
Three details are worth knowing before adopting this pattern in a policy-controlled application.
A nonce does not help. Nonces apply to inline scripts in a document; a worker’s script is fetched as a resource, so the URL scheme must be permitted directly. There is no per-worker exemption.
data: URLs are not an alternative. Some engines reject data: for worker scripts outright, and where they do not, the worker gets an opaque origin — no same-origin fetches, no access to storage tied to the page’s origin. blob: inherits the creating document’s origin, which is why it is the workable choice.
Adding blob: to script-src widens the policy for the whole document, not just for workers. If the policy is a meaningful control in your threat model, add the scheme to worker-src specifically so the document’s own script sources stay narrow.
A blob worker is a string that becomes executable code, which makes string interpolation into it exactly equivalent to eval. Configuration must be sent by postMessage after construction, or passed through JSON.stringify into a single frozen constant — never concatenated into the body of a function. Code that builds a worker from a template containing user-supplied names, filters or expressions has an injection vector with no sandbox around it.
Imports Inside a Blob Worker
This is the constraint that most often makes the pattern unworkable, and it is not obvious from the error message.
A worker’s module resolution is relative to its own script URL. For a blob worker, that URL is blob:https://example.com/8f3c1a2e-… — an opaque identifier, not a path — so importScripts('./util.js') and import { x } from './util.js' both fail. The failure message mentions the blob URL, which reads as a bug in the blob rather than as a base-URL problem.
Three workarounds, in order of preference:
Bundle everything into the string. If the worker is small and self-contained, this is the intended usage. Most bundlers can emit a worker as an inline string for exactly this purpose.
Inject an absolute base and use absolute URLs. The page knows its own origin, so build the source with the base already resolved:
const base = new URL('.', import.meta.url).href; // resolved on the page
const source = `
const BASE = ${JSON.stringify(base)};
importScripts(BASE + 'vendor/decoder.js'); // absolute — works from a blob
self.onmessage = (e) => { /* … */ };
`;
Use a real worker file instead. If the worker needs several modules, dynamic imports, or WebAssembly loaded relative to itself, the blob approach is fighting the platform. Emit it as a chunk — the bundler configurations are in Bundling Module Workers with Vite and webpack.
When a Blob Worker Is Actually the Right Choice
Single-file library distribution. A library published as one ESM file cannot ship a separate worker chunk without complicating every consumer’s build. Embedding the worker source as a string keeps installation to one import, which is why many charting and parsing libraries do exactly this.
Cross-origin script hosting. The Worker constructor requires a same-origin script URL. Code loaded from a CDN on another origin therefore cannot construct a worker from its own URL — but it can fetch its worker source as text and create a blob, which inherits the page’s origin. This is the standard workaround, and it is the reason the pattern survives.
Runtime code generation. A spreadsheet that compiles user formulas, or a visualisation tool that generates a shader-like kernel, produces code that did not exist at build time. Generated code plus a blob URL is the only route — with the injection caution above taken seriously, since here the input genuinely is user-supplied.
For everything else, a bundled worker file is smaller (no base64 or string escaping), cacheable independently, debuggable by default, and free of every constraint on this page.
A Reusable, Safe Factory
Collecting the rules into one helper keeps them from being re-derived at each call site — and makes the constraints visible in one place:
// safe-inline-worker.ts
export interface InlineWorkerInit {
/** The worker body. Must not be built by concatenating untrusted input. */
source: string;
/** Values the worker needs at startup, serialised safely as a frozen constant. */
constants?: Record<string, unknown>;
/** Stable name for DevTools; without it the script is an unnamed blob URL. */
name: string;
type?: WorkerType;
}
export function createInlineWorker({ source, constants = {}, name, type = 'classic' }: InlineWorkerInit): Worker {
const preamble = [
`//# sourceURL=${name}.js`,
`const BASE = ${JSON.stringify(new URL('.', location.href).href)};`,
`const CONFIG = Object.freeze(${JSON.stringify(constants)});`,
].join('\n');
const url = URL.createObjectURL(new Blob([preamble, source], { type: 'text/javascript' }));
try {
return new Worker(url, { type, name });
} finally {
URL.revokeObjectURL(url);
}
}
Everything variable enters through JSON.stringify, so no caller can inject executable text by supplying a clever string. BASE is resolved on the page, so importScripts(BASE + '…') works despite the blob base URL. The name option is passed to the constructor as well as into the sourceURL comment, which makes the thread identifiable in the Performance panel’s track list rather than appearing as an anonymous worker.
The one thing the factory cannot enforce is that source itself is a literal. That has to be a review habit — and a lint rule banning template interpolation in arguments to this function is a cheap way to keep it.
Gotchas
Forgetting type: 'module' when the source uses import. A blob worker is classic by default. Module syntax in a classic worker is a syntax error at parse time, reported as a worker error event with a message that does not mention module type.
Escaping errors in generated source. Building the string with template literals means a backtick or ${ in embedded data breaks the worker in ways that look like corruption. JSON.stringify every embedded value, without exception.
Revoking too early in a helper. A wrapper that creates the URL, returns it, and lets the caller construct the worker later will find the URL revoked or, worse, still alive and leaking. Keep creation, construction and revocation in the same function.
Blob workers and source maps. A generated worker has no source map unless you append one as a data URL comment. For a bundler-inlined worker, check whether the tool emits the map — some do, some silently drop it, and the difference only shows when something throws.
Performance Note
Constructing a worker from a 40 kB blob costs about 0.6 ms more than constructing one from a cached file URL, because the source is parsed from memory rather than served from the HTTP cache — a wash on first load, slower on repeat visits where the file version would have been cached. The larger cost is upstream: an inlined worker adds roughly 4 bytes of bundle for every 3 bytes of source when base64-encoded, and that weight is downloaded by every visitor, including those who never trigger the feature.