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.
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:
- 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. - 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.
- 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. - Running the top-level module graph. Static
importstatements 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
Workerconstructor 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 withfetch()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-srcvalues, not the dev-server defaults. Inline workers requireblob:to be allowed. - A decision on the worker type. Classic workers get
importScripts(); module workers ({ type: 'module' }) get staticimportand 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 aNetworkError. - 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()orself.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.
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));
});
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.
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');
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' }
);
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)}`);
}
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.
| 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.
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.
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
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
Reach for an inline worker when:
- The script is small β under roughly 10 KB β and the saved request outweighs the lost caching.
- The worker body is generated at runtime (a compiled expression, a user-supplied transform, a code path assembled from feature flags).
- You ship a library that must be a single file, and forcing consumers to host a second asset is unacceptable.
- 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.
- The deployment pipeline cannot emit or version an extra chunk reliably.
Reach for a dedicated worker file when:
- The script is large, or the same worker is used across multiple page loads and should hit the HTTP and code caches.
- You want ordinary tooling β type checking, linting, source maps, minification, tree shaking β applied to the worker as real source.
- The application runs under a strict CSP that will not allow
blob:. - The worker uses static ES module imports and you want the bundler to flatten and hash the graph.
- 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.