Inline Workers vs Dedicated Workers

Every Web Worker you create starts as a URL. Whether that URL points at a real file on your origin or at a blob: handle synthesised in memory is a small syntactic difference with large consequences for startup latency, caching, Content Security Policy, module support and debuggability. This page is part of the Web Workers Architecture & Communication reference and covers the two instantiation strategies side by side, with the measurements and failure modes that decide between them in production.

Inline worker vs dedicated worker instantiation paths Dedicated workers fetch a cached script file over the network; inline workers convert a JavaScript string into a Blob URL entirely on the main thread before handing it to the Worker constructor. Dedicated Worker new Worker('./worker.js') HTTP fetch β†’ browser cache Script parse (parallel, cached) V8 isolate ready (5–15 ms cold) Inline Worker new Blob([script]) on main thread URL.createObjectURL() β†’ no cache String parse (synchronous, main thread) V8 isolate ready (no network, but no cache)
Dedicated workers pay a one-time network cost recovered by the HTTP cache; inline workers skip the network entirely but parse synchronously on the main thread and are never cached.

The Bottleneck: Startup Cost You Pay Per Instantiation

The symptom that sends teams to this comparison is almost always the same: a spreadsheet view instantiates a 180 KB parser worker every time the user switches sheets, and the Performance panel shows a 20–40 ms gap between new Worker() and the first onmessage callback. Nothing is blocking the main thread for that whole window, but the UI cannot show parsed rows until the worker is alive, so the delay lands squarely inside the interaction.

That gap decomposes into four costs, and inline and dedicated workers pay them in different proportions:

  1. Acquiring the script bytes. A dedicated worker issues a real HTTP request. Cold, that is one RTT plus transfer time; warm, it is a memory- or disk-cache hit measured in tenths of a millisecond. An inline worker has the bytes already β€” but the main thread must build the source string and copy it into a Blob, which is a memcpy on the order of 0.1 ms per megabyte.
  2. Spinning up the agent. Creating the worker’s event loop, isolate and global scope costs roughly 1–4 ms in Chrome on desktop hardware and is identical for both strategies.
  3. Compiling the script. V8 compiles worker scripts off the main thread, but a script loaded from a real URL can populate and later reuse the code cache, so repeat visits skip most of the parse and compile work. Bytes that arrive through a blob: URL are never code-cached, so a large inline worker recompiles from source on every single page load.
  4. Running the top-level module graph. Static import statements in a module worker trigger further fetches before your first message is handled β€” a hidden serial cost that hits dedicated module workers unless the bundler has already flattened the graph.

The practical consequence: inline workers are fastest when the script is small and short-lived, because they delete step 1 entirely. Dedicated workers are fastest when the script is large or reused across page loads, because steps 1 and 3 both collapse to near zero once the browser caches are warm. Sizing this correctly matters more than any micro-optimisation inside the worker itself; see Main Thread vs Worker Thread Lifecycle for how instantiation fits into the wider spawn-work-terminate cycle.


Prerequisites

Before implementing either pattern, confirm the following:

  • Same-origin script hosting. The Worker constructor rejects cross-origin script URLs outright β€” there is no CORS opt-in equivalent to <script crossorigin>. A worker script on a CDN on another origin must be fetched with fetch() and re-wrapped as a Blob, which is one of the legitimate reasons to reach for the inline pattern.
  • A known Content Security Policy. You need the deployed worker-src / child-src / default-src values, not the dev-server defaults. Inline workers require blob: to be allowed.
  • A decision on the worker type. Classic workers get importScripts(); module workers ({ type: 'module' }) get static import and strict mode by default. The choice changes how both strategies behave.
  • Correct MIME types from the server. A dedicated worker script served as text/html β€” the classic single-page-app 404 fallback β€” fails the module script type check with a NetworkError.
  • A bundler that understands worker syntax, if you use one. Vite and webpack 5 both recognise new Worker(new URL('./worker.ts', import.meta.url)); string-literal paths are silently left unrewritten and 404 in production.
  • A termination policy. Both worker types leak a thread and its heap until terminate() or self.close() runs.

Step-by-Step Implementation

Each step below implements one half of the comparison, then the last step folds both behind a single call site so the choice becomes a configuration value rather than an architectural commitment.

Instantiation timeline: dedicated worker compared with inline worker Two timelines drawn on one shared millisecond axis. The dedicated worker returns from the constructor after about 0.3 ms, then spends roughly 8 ms fetching the script over the network before the agent starts and the script compiles off the main thread, answering the first message at about 17.8 ms; on a warm reload both the bytes and the compiled code come from cache. The inline worker spends its first 1.5 ms on the main thread building the source string, copying it into a Blob and creating the object URL, which can be revoked immediately afterwards, then starts its agent and compiles from the source string, answering at about 11.5 ms. That compile repeats on every page load, because bytes delivered through a blob URL are never code-cached. main thread (blocking) network worker agent (off main thread) Dedicated worker cold load, 180 KB new Worker(url) returns in ~0.3 ms script fetch over the network agent + scope compile off-thread β‰ˆ17.8 ms warm reload: cached bytes, cached compiled code first postMessage round-trip Inline worker blob: URL, same source string build + Blob copy run on the main thread agent + scope compile from source β‰ˆ11.5 ms URL.revokeObjectURL() is safe here first postMessage round-trip repeats on every page load β€” never code-cached 0 5 10 15 20 milliseconds after new Worker() β€” illustrative desktop-Chrome figures
The inline worker reaches its first message sooner on a cold load because it never touches the network β€” but the first 1.5 ms of that timeline is main-thread work, and the compile segment is paid again on every page load. Only the dedicated worker's timeline shrinks on the second visit.

1. Ship the dedicated worker file as the default

Write the worker as an ordinary module and let the bundler emit it as a hashed chunk. The new URL(..., import.meta.url) form is the portable spelling: Vite, webpack 5, Rollup and Parcel all statically detect it, and it also works unbundled in browsers with native module workers.

// parser.worker.ts β€” dedicated worker, its own file
export interface ParseRequest { id: number; csv: string; }
export interface ParseResult { id: number; rows: number; buffer: ArrayBuffer; }

self.onmessage = (event: MessageEvent<ParseRequest>) => {
  const { id, csv } = event.data;
  const values = parseCsvToFloat64(csv);
  // Transfer the buffer instead of cloning it β€” see the data-transfer section.
  const message: ParseResult = { id, rows: values.length, buffer: values.buffer };
  (self as DedicatedWorkerGlobalScope).postMessage(message, [values.buffer]);
};

function parseCsvToFloat64(csv: string): Float64Array {
  const cells = csv.split(/[\n,]/);
  const out = new Float64Array(cells.length);
  for (let i = 0; i < cells.length; i++) out[i] = Number(cells[i]);
  return out;
}
// main.ts
const worker = new Worker(
  new URL('./parser.worker.ts', import.meta.url), // statically analysable β€” do not build this string dynamically
  { type: 'module', name: 'csv-parser' }          // `name` shows up in DevTools and in worker.onerror reports
);

worker.addEventListener('message', (event: MessageEvent<ParseResult>) => {
  renderRows(new Float64Array(event.data.buffer));
});
Trade-off

You pay one extra HTTP request on the cold path, and in exchange the script becomes cacheable at two levels: the HTTP cache stores the bytes and V8 stores the compiled code. For a script above roughly 50 KB, or one instantiated on more than one page view, that is the cheaper side of the trade. The cost is deployment surface: a second file that must be served from the same origin with a JavaScript MIME type and a cache-busting hash.

2. Build the inline variant from a Blob

An inline worker turns a source string into an object URL. The Blob inherits the creating document’s origin, so the resulting worker is same-origin and can be created even when the original code came from a cross-origin CDN.

// inline-worker.ts
const source = `
  self.onmessage = (event) => {
    const { id, csv } = event.data;
    const cells = csv.split(/[\\n,]/);
    const out = new Float64Array(cells.length);
    for (let i = 0; i < cells.length; i++) out[i] = Number(cells[i]);
    self.postMessage({ id, rows: out.length, buffer: out.buffer }, [out.buffer]);
  };
  //# sourceURL=csv-parser.inline.js
`;

// 1. 'text/javascript' is the type browsers expect; 'text/plain' is rejected for module workers.
const blob = new Blob([source], { type: 'text/javascript' });
// 2. The object URL is a handle into the blob store, scoped to this document.
const blobUrl = URL.createObjectURL(blob);
// 3. The constructor begins loading the script before it returns...
const worker = new Worker(blobUrl, { name: 'csv-parser-inline' });
// 4. ...so the handle can be released immediately. Skipping this leaks the blob for the document's lifetime.
URL.revokeObjectURL(blobUrl);

Note the doubled backslash in /[\\n,]/: inside a template literal the worker source is just text, so every escape sequence you want the worker to see must survive one round of string escaping first. This is the single most common source of β€œworks when I paste it into a file, breaks inline” bugs.

Trade-off

You remove a network request and a deployment artefact, and you gain the ability to synthesise worker code at runtime. You lose the HTTP cache, the V8 code cache, ordinary source-level tooling (linting, type checking and minification all treat the source as an opaque string) and, unless you are careful, sane relative-URL resolution: inside a blob: worker, relative specifiers resolve against the opaque blob URL rather than your site's path structure. Use absolute URLs built from location.origin for anything the inline worker imports or fetches.

3. Keep inline source honest and debuggable

Hand-written source strings rot. Two techniques keep an inline worker close to real code. The first is function stringification β€” write a normal function, then serialise it at build or runtime. The second is the //# sourceURL comment, which gives the worker a stable, searchable name in the DevTools Sources tree instead of a blob UUID that changes on every load.

// Write the worker body as a real function so editors and type checkers see it.
function workerBody(): void {
  self.onmessage = (event: MessageEvent<{ id: number; values: number[] }>) => {
    const { id, values } = event.data;
    const sorted = values.filter((v) => v > 0).sort((a, b) => a - b);
    (self as unknown as DedicatedWorkerGlobalScope).postMessage({ id, sorted });
  };
}

// IIFE-wrap the stringified function; append a sourceURL so DevTools names the script.
export function inlineWorkerFrom(fn: () => void, name: string): Worker {
  const source = `(${fn.toString()})();\n//# sourceURL=${name}.js\n`;
  const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
  try {
    return new Worker(url, { name });
  } finally {
    URL.revokeObjectURL(url); // runs even if the constructor throws
  }
}

const worker = inlineWorkerFrom(workerBody, 'sort-worker');
Trade-off

Stringifying a function keeps the code type-checked and lint-clean, but it silently breaks closure capture: fn.toString() copies the function text, so any variable, import or helper from the surrounding module is undefined inside the worker. Pass configuration through the first postMessage instead of closing over it, and keep helper functions nested inside the worker body. Minifiers that mangle names also rewrite the stringified body β€” verify the production bundle, not just the dev build.

4. Decide between classic and module workers

Both strategies support both worker types, but the failure modes differ. A classic inline worker can pull in dependencies with importScripts(), which accepts absolute URLs and executes them synchronously in the worker scope. A module inline worker supports static import, but relative specifiers resolve against the blob URL and fail β€” so every specifier has to be absolute.

// Classic inline worker: importScripts with an absolute URL works reliably.
const classicSource = `
  importScripts('${location.origin}/vendor/decoder.umd.js');
  self.onmessage = (e) => self.postMessage(self.Decoder.decode(e.data));
`;
const classicWorker = new Worker(
  URL.createObjectURL(new Blob([classicSource], { type: 'text/javascript' }))
);

// Module inline worker: `import './decoder.js'` would resolve against the blob URL and 404.
const moduleSource = `
  import { decode } from '${location.origin}/vendor/decoder.mjs';
  self.onmessage = (e) => self.postMessage(decode(e.data));
`;
const moduleWorker = new Worker(
  URL.createObjectURL(new Blob([moduleSource], { type: 'text/javascript' })),
  { type: 'module' }
);
Trade-off

Module workers give you strict mode, top-level await and tree-shakeable imports, but each static import is a separate fetch that must complete before your first message is processed. Classic workers with importScripts() block the worker thread rather than the main thread, which is usually acceptable, but they cannot be tree-shaken and pollute the worker's global scope. When a bundler is available, prefer a bundled module worker so the graph is flattened to one request β€” Bundling Module Workers with Vite and webpack shows the configuration for both tools.

5. Wrap both behind one factory with a CSP fallback

In production the useful shape is a single factory that prefers the dedicated file and degrades to inline (or the reverse, if the policy allows blob: but you cannot deploy a second file). Because a blocked Worker constructor throws synchronously, the fallback is a plain try/catch.

type WorkerStrategy = 'dedicated' | 'inline';

interface WorkerSpec {
  /** URL of the deployed worker chunk, e.g. new URL('./parser.worker.ts', import.meta.url) */
  url: URL;
  /** Equivalent source string used when the dedicated file is unavailable. */
  inlineSource: string;
  name: string;
  type?: WorkerType; // 'classic' | 'module'
}

export function createWorker(spec: WorkerSpec, prefer: WorkerStrategy = 'dedicated'): Worker {
  const options: WorkerOptions = { type: spec.type ?? 'module', name: spec.name };
  const attempts: WorkerStrategy[] = prefer === 'dedicated'
    ? ['dedicated', 'inline']
    : ['inline', 'dedicated'];

  let lastError: unknown;
  for (const strategy of attempts) {
    try {
      if (strategy === 'dedicated') return new Worker(spec.url, options);

      const source = `${spec.inlineSource}\n//# sourceURL=${spec.name}.inline.js\n`;
      const blobUrl = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
      try {
        return new Worker(blobUrl, options);
      } finally {
        URL.revokeObjectURL(blobUrl);
      }
    } catch (error) {
      // SecurityError under a restrictive CSP, NetworkError on a bad MIME type.
      lastError = error;
    }
  }
  throw new Error(`Could not start worker "${spec.name}": ${String(lastError)}`);
}
Trade-off

A factory costs you a small amount of dead code in every bundle β€” both the URL reference and the inline source ship β€” but it makes the strategy a runtime decision, which is what you want when the same library runs inside a strict-CSP host application and a permissive one. If you build a worker pool on top of this factory, create the Blob URL once and construct all N workers from it before revoking; see Worker Pool Management for sizing heuristics.


Data-Transfer Strategy: Clone, Transfer or Share

Instantiation strategy and data-transfer strategy are orthogonal. A blob: worker and a file-backed worker use exactly the same messaging surface, run the same structured clone algorithm, and β€” because a Blob URL inherits the creating document’s origin and its isolation state β€” have exactly the same access to shared memory. Choose the transfer mechanism from the payload, not from how the worker was created.

Who owns the bytes after a clone, a transfer and a shared allocation Three rows crossing the thread boundary with a ten megabyte Float64Array. Structured clone leaves the sender's buffer untouched and materialises an independent copy in the worker, costing twelve to eighteen milliseconds and peaking at twice the memory. Transfer detaches the sender's buffer, so its byteLength becomes zero while the worker receives the same bytes in under a millisecond with no copy and only one allocation live. Shared memory places a single SharedArrayBuffer that both sides read and write concurrently under Atomics, and requires cross-origin isolation with COOP and COEP headers. mechanism main thread (sender) worker (receiver) cost Structured clone postMessage(obj) 10 MB Float64Array untouched by the send deep copy ~12–18 ms an independent copy sender keeps its own peak memory 2 Γ— 10 MB Transfer postMessage(o, [buf]) detached byteLength === 0 ownership moves under 1 ms the same bytes no copy is made peak memory 1 Γ— 10 MB Shared memory SharedArrayBuffer main thread reads + writes one allocation, two views Atomics coordinate access worker reads + writes requires COOP + COEP Inline and dedicated workers share one messaging surface β€” the instantiation strategy never changes which of these three applies.
Ownership, not syntax, is what separates the three mechanisms: a clone duplicates the bytes, a transfer moves them and leaves a detached husk behind, and a shared buffer is never handed over at all.
Mechanism Call Copy cost for 10 MB Sender state after send Requirements
Structured clone postMessage(obj) ~12–18 ms, peak memory doubles Untouched None
Transfer postMessage(obj, [buf]) Under 1 ms Buffer detached (byteLength === 0) Payload must be a transferable object
Shared memory postMessage({ sab }) None β€” same pages Still readable, concurrently Cross-origin isolation (COOP + COEP)

For the CSV pipeline above, the rule of thumb is: clone anything under ~100 KB and stop thinking about it; transfer anything approaching or above 1 MB.

// Transfer: ownership moves, no copy. Both worker strategies behave identically here.
const samples = new Float64Array(1_250_000); // ~10 MB
worker.postMessage({ id: 1, buffer: samples.buffer }, [samples.buffer]);
console.assert(samples.buffer.byteLength === 0, 'buffer is detached on the sender after transfer');

Transfer is the right default for a one-shot producer/consumer hop β€” the mechanics and the detached-buffer pitfalls are covered in Transferable Objects & Zero-Copy, and message shape conventions such as correlation IDs are covered in Message Passing Strategies.

COOP / COEP for shared memory

If the workload needs concurrent read/write access rather than a hand-off, SharedArrayBuffer & Atomics is the mechanism β€” but it is gated on cross-origin isolation. Your document must be served with both Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and you should assert crossOriginIsolated === true before allocating. This applies equally to inline and dedicated workers: a blob: worker inherits the creating document's isolation state, so if the headers are missing, SharedArrayBuffer is undefined in both. Note that under require-corp every cross-origin subresource also needs CORP headers β€” a dedicated worker script on your own origin is unaffected.


Verification & Measurement

Do not choose on intuition. The two strategies are close enough that the answer flips with script size, and the only way to know which side you are on is to measure cold and warm paths separately.

Cold and warm startup cost, and the script size where the two strategies cross over Left panel: stacked bars of time to first message for a 180 KB worker script, split into acquiring the script bytes, agent and isolate startup, compilation, and the first message. The dedicated worker takes about 17.5 milliseconds cold and 5.3 milliseconds warm, because the HTTP cache and the V8 code cache both hit on the second load. The inline worker takes about 11.2 milliseconds cold and exactly the same 11.2 milliseconds warm, because a blob URL has no cache to warm. Right panel: time to first message plotted against script size. The inline curve starts slightly lower but climbs steeply with size, while the cached dedicated curve is almost flat, so the two cross at roughly twelve kilobytes. Time to first message β€” 180 KB worker script dedicated Β· cold 17.5 ms dedicated Β· warm 5.3 ms inline Β· cold 11.2 ms inline Β· warm 11.2 ms no cache to warm β€” identical each load 0 5 10 15 20 ms to first message acquire script bytes agent + isolate compile / code-cache first message Illustrative desktop-Chrome figures β€” run the harness above on your own script. Crossover as the script grows ms to first message 0 10 20 0 100 200 300 400 worker script size (KB) β‰ˆ12 KB crossover inline dedicated (cached)
Only one of these four bars changes between the first and second page load. Because the inline compile is repeated verbatim every time, the advantage it holds at tiny script sizes disappears within a few kilobytes of growth.

Time construction and first response

interface StartupSample { construct: number; firstMessage: number; }

async function measureStartup(factory: () => Worker): Promise<StartupSample> {
  const t0 = performance.now();
  const worker = factory();
  const t1 = performance.now();               // constructor return β€” script load is still in flight
  await new Promise<void>((resolve) => {
    worker.addEventListener('message', () => resolve(), { once: true });
    worker.postMessage({ ping: true });        // queued until the worker's script has evaluated
  });
  const t2 = performance.now();
  worker.terminate();
  return { construct: t1 - t0, firstMessage: t2 - t0 };
}

The construct number is what the main thread actually spends: for a dedicated worker it is close to zero because loading is asynchronous, while for an inline worker it includes building the source string and copying it into the Blob. The firstMessage number is the latency the user perceives, because messages posted before the worker’s script has finished evaluating are queued, not dropped. Run the harness ten times, discard the first result, and compare medians.

Confirm the caching behaviour you assumed

// Dedicated worker scripts appear in Resource Timing; blob: workers do not.
const entries = performance
  .getEntriesByType('resource')
  .filter((e) => e.name.includes('parser.worker'));

for (const entry of entries as PerformanceResourceTiming[]) {
  // transferSize === 0 with a non-zero decodedBodySize means it came from cache.
  console.log(entry.name, entry.transferSize === 0 ? 'cache hit' : `${entry.transferSize} B over the wire`);
}

A reload that still reports bytes over the wire means your worker chunk is missing far-future cache headers β€” the main advantage of the dedicated strategy is not being realised, and the comparison you ran is unfair to it.

Confirm the main thread stayed free

In the Chrome Performance panel, record an interaction that spawns the worker and look for two things: a long task straddling new Worker() (inline workers with multi-hundred-kilobyte sources can produce one from the string and Blob build), and the worker’s own track showing when script evaluation actually starts. The dedicated worker’s script fetch appears in the Network panel with an initiator of Other. For a deeper workflow β€” including attaching to worker scopes and setting breakpoints in blob-backed scripts β€” see Chrome DevTools Worker Debugging.


Failure Modes & Error Handling

Triage table for the four ways worker construction fails Four failures traced from symptom to cause to fix. A SecurityError from the constructor on an inline worker means the Content Security Policy has no blob: in worker-src, child-src or default-src, so add worker-src 'self' blob: or catch it and fall back to the file. A NetworkError while a dedicated script loads means the wrong MIME type, usually a single-page-app 404 fallback serving text/html, so serve the chunk as text/javascript and exempt it from the rewrite. A silent 404 in production means the path was a plain string the bundler never saw, so use new URL with import.meta.url. A SecurityError for a cross-origin script URL means the constructor only accepts same-origin URLs, so fetch the source, wrap it in a Blob and construct from the object URL. what you observe why it happens the fix SecurityError from the Worker constructor inline only no blob: in the CSP worker-src, then child-src, then default-src β€” 'self' blocks it add worker-src 'self' blob: or catch it and use the file NetworkError while the script loads dedicated only the wrong MIME type an SPA 404 fallback served text/html and module workers check strictly serve it as text/javascript and exempt it from the rewrite 404, no chunk emitted only in the production build both worker types the path was a plain string so the bundler emitted no chunk and the hashed asset never existed use new URL(..., import.meta.url) so the bundler emits the chunk SecurityError for a CDN script URL either strategy only same-origin URLs load there is no crossorigin opt-in for the Worker constructor fetch() it, wrap it in a Blob, then construct from that URL Both SecurityErrors throw synchronously β€” try/catch can recover. A bad MIME type surfaces later, on the worker's error event.
Three of the four failures are invisible on a dev server: no CSP is applied, the dev server infers the right MIME type, and the unbundled path still resolves. Reproduce them against a production build with the real policy attached.

SecurityError from the constructor (inline only). The CSP does not allow blob: for workers. Worker creation is checked against worker-src, falling back to child-src and then default-src, so a policy of default-src 'self' blocks inline workers even though it never mentions workers. Fix it by adding worker-src 'self' blob:, or catch the error and fall back to the dedicated file as in step 5.

NetworkError when the script loads (dedicated only). Almost always a MIME type problem: the file is served as text/html because a single-page-app fallback rewrote a 404, or as text/plain by a static host that does not know the extension. Module workers apply a strict script type check and reject anything that is not a JavaScript MIME type.

Silent 404 on a bundled worker path. Passing a plain string (new Worker('./parser.worker.js')) means the bundler never sees a static reference, so no chunk is emitted and the hashed asset never exists. Always use the new URL(..., import.meta.url) form.

Uncaught errors inside the worker. Handle error on the worker object for uncaught exceptions and messageerror for payloads that fail to deserialise. Inline workers report an opaque blob: URL in event.filename, which is why the //# sourceURL comment from step 3 matters for triage.

worker.addEventListener('error', (event: ErrorEvent) => {
  // filename is the blob: URL for inline workers unless //# sourceURL is present
  report({ kind: 'worker-error', message: event.message, file: event.filename, line: event.lineno });
});

worker.addEventListener('messageerror', (event: MessageEvent) => {
  // Fired when a posted value could not be structured-cloned (e.g. a function or a DOM node)
  report({ kind: 'worker-messageerror', origin: event.origin });
});

Rejected promises inside the worker. Async failures never reach the main thread’s error handler. Catch them in the worker and forward a serialised error, because Error objects lose subclass identity and custom fields when cloned:

// inside the worker (classic or module, inline or dedicated)
self.addEventListener('unhandledrejection', (event) => {
  event.preventDefault();
  self.postMessage({ type: 'error', error: toSerializableError(event.reason) });
});

function toSerializableError(reason) {
  if (reason instanceof Error) {
    return { name: reason.name, message: reason.message, stack: reason.stack };
  }
  return { name: 'NonError', message: String(reason), stack: null };
}

The wire format above is deliberately plain β€” see Structured Error Serialization Across Threads for a fuller schema including cause chains.

Restart storms. A worker that crashes during startup will crash again immediately if you restart it unconditionally. Cap restarts and back off:

let restarts = 0;
function respawn(create: () => Worker): Worker | null {
  if (restarts >= 3) return null;                    // give up; fall back to main-thread work
  const delay = 250 * 2 ** restarts++;               // 250 ms, 500 ms, 1000 ms
  setTimeout(() => attach(create()), delay);
  return null;
}

Leaked object URLs. Every createObjectURL without a matching revokeObjectURL pins its Blob for the lifetime of the document. In a view that rebuilds inline workers on each render, this shows up as steadily growing retained memory with no obvious JavaScript owner β€” see Identifying Memory Leaks in Workers for the heap-snapshot workflow.

Zombie workers. Neither strategy garbage-collects a worker that still has a live reference or an outstanding task. Call terminate() from the owner, or self.close() from inside, whenever the unit of work is done.


Choosing Between Them in Production

Decision tree for choosing an inline or a dedicated worker Three questions asked in order. First, does the deployed Content Security Policy allow blob: for workers through worker-src, child-src or default-src? If not, the dedicated worker file is forced. If it does, ask whether the worker body is generated at runtime or its source served from another origin; if so, a Blob is the only route and the inline worker is forced. Otherwise ask whether the script is under about ten kilobytes and used on a single page view; if so, the saved request outweighs the lost caching and the inline worker wins. If none of those apply, ship the dedicated worker file β€” the default β€” because both its bytes and its compiled code are cacheable. Does the deployed CSP allow blob: for workers β€” worker-src, child-src or default-src? no Dedicated worker file blob: would be blocked yes Is the worker body generated at runtime, or is its source served from another origin? yes Inline worker a Blob is the only route no Is the script under about 10 KB and used on a single page view? yes Inline worker the saved request wins no Dedicated worker file β€” the default cacheable bytes, cacheable compiled code Every branch stays reachable at runtime if you keep both paths behind the factory from step 5.
The policy question comes first because it is the only one that can veto a choice outright; size is the last question, not the first, because it only decides the cases where both strategies are actually available.

Reach for an inline worker when:

  1. The script is small β€” under roughly 10 KB β€” and the saved request outweighs the lost caching.
  2. The worker body is generated at runtime (a compiled expression, a user-supplied transform, a code path assembled from feature flags).
  3. You ship a library that must be a single file, and forcing consumers to host a second asset is unacceptable.
  4. The worker source originates cross-origin: fetch it, wrap it in a Blob, and construct from the object URL, since the constructor will not accept a cross-origin URL directly.
  5. The deployment pipeline cannot emit or version an extra chunk reliably.

Reach for a dedicated worker file when:

  1. The script is large, or the same worker is used across multiple page loads and should hit the HTTP and code caches.
  2. You want ordinary tooling β€” type checking, linting, source maps, minification, tree shaking β€” applied to the worker as real source.
  3. The application runs under a strict CSP that will not allow blob:.
  4. The worker uses static ES module imports and you want the bundler to flatten and hash the graph.
  5. You need readable stack traces in production telemetry without extra plumbing.

When the two are genuinely close, ship the dedicated file: it is the strategy with fewer sharp edges, and the factory from step 5 keeps the inline path available for the hosts that need it.


Browser Compatibility

Feature Chrome Firefox Safari Edge
Dedicated workers (new Worker(url)) 4 3.5 4 12
Workers from a blob: URL 23 21 6 12
URL.createObjectURL / revokeObjectURL 23 19 6 12
Module workers ({ type: 'module' }) 80 114 15 80
WorkerOptions.name 70 55 15 79
importScripts() in classic workers 4 3.5 4 12
Transferable ArrayBuffer in postMessage 17 18 6 12

The binding constraint for most teams is module worker support: Firefox only shipped it in version 114 (June 2023), so a classic-worker fallback β€” or a bundler configured to emit an IIFE-format worker chunk β€” was standard practice until recently. Blob-URL workers themselves are effectively universal, and the name option is worth setting on both strategies because it labels the thread in DevTools and in ErrorEvent reports.


Going Further

Where the inline route is the right one β€” a single-file library, a cross-origin bundle, code generated at runtime β€” the operational details are easy to get wrong: an object URL that is never revoked, a policy that blocks the blob: scheme, and relative imports that cannot resolve against a blob base. Creating Workers from Blob URLs Safely covers each of them, with a factory that keeps the rules in one place.

Frequently Asked Questions

When should I use an inline worker instead of a dedicated worker file?
Inline workers win for small scripts (roughly under 10 KB), for code you generate at runtime, and for distributable libraries that must not ship a second file. For everything else β€” reusable pipelines, large parsers, ES module workers β€” a dedicated file wins, because its bytes go through the HTTP cache and the V8 compilation cache while a blob: URL is recompiled from scratch on every page load.
Can inline workers use ES module imports?
Yes, if you pass { type: 'module' } to the Worker constructor and the Blob contains valid static import statements. The catch is specifier resolution: relative specifiers resolve against the opaque blob: URL, not against your document, so they break. Use absolute URLs built from import.meta.url or location.origin, or let a bundler emit the worker chunk for you.
Does revoking the Blob URL immediately after construction cause problems?
No. The Worker constructor starts the fetch of the Blob before it returns, so the object URL is no longer needed afterwards. Calling URL.revokeObjectURL() on the next line is safe and correct. The one exception is a worker pool: if you construct several workers from the same URL, revoke only after the last constructor call.
Why does my inline worker throw a SecurityError in production but work locally?
Your production Content Security Policy is missing worker-src blob:. Worker creation checks worker-src, falling back to child-src and then default-src. A local dev server usually sends no CSP at all, so the failure only appears once the real policy is applied. Either add blob: to the directive or fall back to a dedicated worker file.
How do bundlers handle module worker bundling for production?
Vite splits the worker into its own chunk and rewrites new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) automatically; adding the ?worker&inline suffix instead emits a base64 Blob worker. webpack 5 understands the same new URL pattern natively β€” worker-loader is no longer needed. See Bundling Module Workers with Vite and webpack for step-by-step configuration.

See also