skip to content

In the browser, what changes when you construct a Web Worker as `new Worker(url, { type: 'module' })` instead of the default `new Worker(url)`, and why does `importScripts()` stop working in the module case?

level: middleimportance: should knowfreq 52%

answer

  1. default type is classic
  2. one flat global versus module scope
  3. synchronous fetch-and-run versus a resolved graph
  4. the loader already owns dependency order
  5. new URL(..., import.meta.url) is a literal pattern

basics

~20 s

The default is a classic worker, which loads dependencies with the synchronous importScripts(). Passing type:'module' runs the worker script as an ES module with static and dynamic import, always in strict mode; importScripts() is not available there and throws.

solid answer

~50 s

`new Worker(url)` defaults to `{ type: 'classic' }`: the script is evaluated as a classic script, it shares one flat global, and it pulls in dependencies with `importScripts('/a.js', '/b.js')`, which fetches and runs them synchronously in order. `new Worker(url, { type: 'module' })` evaluates the entry point as an ES module instead — you get static `import` and `export`, dynamic `import()`, a module scope rather than a shared global, and automatic strict mode. The module graph is fetched and instantiated by the loader before your code runs, so a blocking `importScripts` would be both redundant and incompatible with that model; it is simply not defined on a module worker's global and calling it throws a `TypeError`. The `credentials` option in the constructor also only applies to module workers. Bundlers recognise the literal form `new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })` and emit a correct hashed URL for it.

code

javascript · 6 lines
javascript
// classic-worker.js — loaded via new Worker('/classic-worker.js')
importScripts('/vendor/tokenizer.js');

self.onmessage = (event) => {
  self.postMessage(tokenize(event.data));
};

go deeper

for a junior

Know that the second argument to new Worker() picks the script type and that {type:'module'} lets you use import inside the worker file. Recognise the new URL('./w.js', import.meta.url) form as the way to reference a worker file.

for a middle

Explain the mechanics: classic means a flat global with synchronous importScripts, module means a resolved dependency graph, module scope and automatic strict mode, and importScripts is undefined there. Be able to describe what breaks when someone flips the flag on existing code.

for a senior

Demonstrate build-time judgment: why the bundler needs the literal URL pattern, how a worker that works in dev 404s in production, and how you would migrate a classic worker that depends on a global-assigning third-party script.

for a principal

Own the policy question — whether workers are first-class modules in your build graph or hand-managed side files, what that means for code sharing between page and worker, and what fallback (if any) you owe environments without module worker support.

## Two script types, one constructor `Worker` takes a second argument: `new Worker(scriptURL, options)`, where the options are `type`, `name` and `credentials`. `type` is `'classic'` by default, which is why plain `new Worker('/w.js')` gives you a classic worker. The choice decides how the browser *evaluates* the entry script, and that ripples through everything else about how the worker loads code. ## The classic worker A classic worker script is evaluated the way an old-style `<script>` is: one flat global scope, no strict mode unless you write `'use strict'`, top-level `var` and function declarations landing on the worker global. Dependencies come from `importScripts()`, which exists only in classic workers: ```js // worker.js — classic (the default) importScripts('/vendor/tokenizer.js', '/vendor/stemmer.js'); // both scripts have now been fetched, run to completion, and // their globals are visible here self.onmessage = (event) => { const tokens = tokenize(event.data); self.postMessage(stem(tokens)); }; ``` Three properties matter. It is **synchronous** — the call blocks the worker thread until each script is fetched and executed, which is tolerable off the main thread but still stalls that worker. It takes **multiple URLs** and runs them in argument order. And it is **not scoped**: everything the loaded scripts declare goes into the same global, so name collisions are yours to manage. It is also allowed to load cross-origin scripts, unlike the worker's own entry URL, which must be same-origin. ## The module worker ```js // worker.js — module import { tokenize } from './tokenize.js'; import { stem } from './stem.js'; self.onmessage = async (event) => { const { normalise } = await import('./normalise.js'); // dynamic import works too self.postMessage(stem(tokenize(normalise(event.data)))); }; ``` Constructed as `new Worker('/worker.js', { type: 'module' })`. Now the entry point is a module: it is parsed, its imports are resolved and fetched, the whole graph is instantiated, and only then is any of it evaluated. Each module has its own scope; nothing leaks to the global unless you put it there explicitly. Strict mode is on, so a stray undeclared assignment throws instead of creating a global. Dynamic `import()` is available for lazily pulling in a rarely used code path. `importScripts()` is deliberately absent here. Its whole contract — stop, fetch, run, continue — contradicts the module loader's contract, where the dependency graph is resolved up front and evaluation is ordered by the loader. Calling it in a module worker throws a `TypeError`. That is the single most common migration surprise: someone flips `type: 'module'` on an existing worker and it dies on the first line. The `credentials` option (`'omit'`, `'same-origin'`, `'include'`) also only applies to module workers; it controls how the module scripts are fetched. ## The bundler URL pattern Worker scripts are separate files, so they need URLs that survive bundling and hashing. Modern bundlers (Vite, webpack 5 and friends) statically recognise this exact literal shape: ```js const worker = new Worker( new URL('./search.worker.js', import.meta.url), { type: 'module' } ); ``` The bundler sees `new URL(<string literal>, import.meta.url)` inside a `new Worker(...)` call, emits the worker as its own chunk, and rewrites the URL to the hashed output path. Break the pattern — compute the path in a variable, pass a bare string, wrap the construction in a helper that receives the URL as an argument — and the bundler can no longer see it, so you ship a URL that 404s in production while working perfectly in dev. ## Choosing For new code, module workers are the better default: real scoping, static analysis, tree shaking, and the same import syntax as the rest of your app. Reach for a classic worker when you must load a third-party script that only exists as a global-assigning bundle, when you construct the worker from a `blob:` URL built at runtime, or when you are targeting an environment where you cannot rely on module worker support. As of 2025–2026, module workers are supported across all current major browsers; Firefox was the last to ship them, in Firefox 114 (2023). If you must support older builds, either stay classic or ship a fallback that catches the constructor failure and retries with a bundled classic script.

  • What does the `name` option in the Worker constructor actually do?
    It sets the worker's name, readable inside the worker as `self.name`. It has no effect on loading or scoping — its value is diagnostic: browser devtools label the worker's thread with it, which makes a pool of otherwise identical workers distinguishable in the sources panel and in performance traces.
  • Why can importScripts() load a cross-origin script when the worker's own entry URL cannot be cross-origin?
    They are different fetches with different rules. The top-level worker script defines the worker's origin and global, so the platform requires it to be same-origin. `importScripts` loads additional classic scripts into an already-established worker, and like a classic `<script src>` it is allowed to run cross-origin code — which is exactly why the code it loads is opaque to error reporting.
  • A worker works in dev but 404s after a production build. What is the usual cause?
    The `new Worker(new URL('./w.js', import.meta.url))` pattern was broken so the bundler could not statically see it — the path was put in a variable, passed into a helper, or written as a plain string. The dev server resolves the real path anyway, but the build never emitted a chunk for it, so the hashed URL does not exist.

saying these in an interview costs you the question

  • Thinks type:'module' is only about syntax, not loading
  • Expects importScripts to work in a module worker
  • Assumes the worker script may be cross-origin
  • Builds the worker URL from a variable and expects bundlers to resolve it
  • Says classic workers cannot use ES syntax at all

context