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.

Lifetime of the blob, the object URL and the worker Three lifetimes drawn as bars. The blob is created first and holds the source bytes. The object URL is created next and is the only thing keeping the blob alive; it is revoked immediately after the Worker constructor returns, which ends both its own lifetime and the blob's. The worker itself starts when the constructor runs and continues until terminate is called or the page goes away — long after the URL and blob are gone, because the script was already fetched. A note records that revoking before construction fails, and never revoking pins the source bytes for the life of the document. Three lifetimes, only one of which is long blob source bytes resident object URL created → revoked worker runs until terminate() — the script was already fetched new Worker(url) returns here Revoking before construction: the fetch fails and the worker never starts. Never revoking: the source bytes stay resident for the life of the document, per worker created.
The worker outliving its URL is not a hack — the constructor has already read the script, and the blob exists only to transport it.

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.

Never interpolate untrusted input into worker source

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.

How a relative specifier resolves in a file worker versus a blob worker Two resolution chains for the same import of a relative path. In a file worker served from a URL under the site's assets directory, the relative specifier resolves against that directory and produces a fetchable URL. In a blob worker, the base is the blob URL itself, which is an opaque identifier rather than a directory, so the resolved specifier is not fetchable and the import fails. A third row shows the workaround: an absolute base injected into the generated source resolves correctly regardless of what the worker's own URL is. Where './util.js' actually points file worker base: /assets/w-a1b2.js './util.js' /assets/util.js fetchable blob worker base: blob:https://…/8f3c1a2e './util.js' not a directory fetch fails blob + injected base BASE resolved on the page BASE + 'util.js' /assets/util.js fetchable again The blob URL is an identifier, not a location — which is why only absolute specifiers survive it.
Injecting the base is a small amount of string building in exchange for imports that behave; anything more complex is a signal to use a real file.

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.


The three cases where a blob worker is the right tool Three legitimate uses and one exclusion. A single-file library cannot ship a separate worker chunk without complicating every consumer build. A bundle served from another origin cannot construct a worker from its own URL, but can build a blob that inherits the page origin. Runtime code generation produces code that did not exist at build time. Everything else should be a bundled worker file, which is smaller, cacheable and debuggable by default. When a string is genuinely the right source single-file library one import for consumers no build changes cross-origin bundle a CDN-hosted widget blob inherits the page origin generated at runtime compiled formulas, kernels no build-time file exists everything else use a bundled file smaller and debuggable The pumpkin column is the default, and it is where most blob workers should not have been. Each step is cheap; skipping one is what makes the next expensive.
Three narrow cases justify the pattern; the fourth column is the answer for ordinary application code.

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.

Frequently Asked Questions

When can I revoke the object URL?
As soon as the Worker constructor has returned. The constructor starts fetching the script synchronously against the URL, so revoking on the next line is safe and is the only reliable way to avoid leaking the blob for the page’s lifetime. Revoking before construction fails; keeping the URL alive afterwards pins the blob’s bytes in memory for nothing.
Why do imports fail inside a blob worker?
A blob worker’s base URL is the blob: URL itself, so a relative specifier like ./util.js resolves against blob:https://example.com/8f3c…, which is not a directory and cannot be fetched. Use absolute URLs — build them from a base you inject into the source — or bundle everything the worker needs into the single string.

See also