JavaScript’s single-threaded limit and the worker escape hatch
JavaScript runs on a single thread, which keeps the model simple but becomes a bottleneck for expensive work like data processing, parsing, or complex calculations. As web applications grow more ambitious, developers increasingly need to offload that work to background threads. The Web Workers API is the platform’s primary tool for this, wrapping operating system threads behind a message-passing interface so the main thread stays responsive while workers handle the heavy lifting.
A typical setup has the main thread post a message to a worker, which listens, processes, and replies:
page.js:
const worker = new Worker('worker.js');
worker.addEventListener('message', e => {
console.log(e.data);
});
worker.postMessage('hello');
worker.js:
addEventListener('message', e => {
if (e.data === 'hello') {
postMessage('world');
}
});
Workers have been around for over a decade, which means broad browser support and solid optimization—but it also means the API predates JavaScript modules. Workers were designed when script loading was still synchronous, and the API for composing worker code has stayed stuck in that era.
Classic workers and the importScripts() problem
The Worker constructor takes a classic script URL relative to the document, returns a worker instance with a messaging interface and a terminate() method. Loading extra code inside the worker relies on importScripts():
importScripts('greet.js');
// ^ could block for seconds
addEventListener('message', e => {
postMessage(sayHello());
});
That approach has real drawbacks. importScripts() pauses the worker to fetch and evaluate each script, and it executes scripts in the global scope like a classic <script> tag—so variables in one file can silently overwrite variables in another. To make workers usable with modern development workflows, bundlers like webpack have had to embed their own mini module loader into worker code, wrapping modules in functions to simulate imports and exports.
Module workers: the modern alternative
Chrome 80 shipped a new mode for workers that brings JavaScript modules to the worker context. Passing {type:"module"} to the Worker constructor changes loading and execution to match <script type="module">:
const worker = new Worker('worker.js', {
type: 'module'
});
Because module workers are standard JavaScript modules, they support import and export statements. Dependencies execute only once per context, and subsequent imports reference the already-run instance. Browsers can also fetch the entire module tree in parallel before executing, and parsed module code is cached—so a module used on both the main thread and in a worker is only parsed once.
Dynamic import() also becomes available for lazy loading without blocking the worker. It’s more explicit than importScripts() because it returns the imported module’s exports instead of relying on globals:
worker.js:
import { sayHello } from './greet.js';
addEventListener('message', e => {
postMessage(sayHello());
});
greet.js:
import greetings from './data.js';
export function sayHello() {
return greetings.hello;
}
The old importScripts() method is intentionally unavailable in module workers. All code in modules runs in strict mode, and this at the top level of a module is undefined (in classic workers it’s the worker’s global scope). The self global still provides a reference to the global scope across all worker types and the DOM, so that remains the reliable way to access it.
Preloading module workers
Module workers unlock a meaningful performance win: preloading the worker script and its whole dependency tree. Because module workers load as standard modules, modulepreload can fetch and pre-parse them before the worker is even instantiated:
<!-- preloads worker.js and its dependencies: -->
<link rel="modulepreload" href="worker.js">
<script>
addEventListener('load', () => {
// our worker code is likely already parsed and ready to execute!
const worker = new Worker('worker.js', { type: 'module' });
});
</script>
Preloaded modules can also be shared between the main thread and a worker—useful for modules imported in both places or when it’s not clear upfront where a module will be used.
Preloading options for classic workers were never good. A dedicated worker resource type was specified for preload, but no browser implemented <link rel="preload" as="worker">. The practical fallback was <link rel="prefetch">, which only filled the HTTP cache and depended on correct cache headers. It couldn’t preload dependencies or prepare code for parsing the way modulepreload does.
Shared workers and the path to module support
Shared workers gained module support in Chrome 83, mirroring the dedicated worker behavior. Constructing a shared worker with the {type:"module"} option loads the script as a module:
const worker = new SharedWorker('/worker.js', {
type: 'module'
});
The original SharedWorker() signature took only a URL and an optional name argument. That still works for classic usage, but module shared workers require the new options argument. Its available options match those for dedicated workers, including name, which supersedes the old positional argument.
Service workers are next. The specification now allows a JavaScript module as the entry point using the same {type:"module"} option:
navigator.serviceWorker.register('/sw.js', {
type: 'module'
});
Browser implementations are still in progress, and service workers add wrinkles that dedicated workers don’t have. Registration must compare imported scripts against previously cached versions to decide whether an update is needed—a check that now has to cover module dependencies. And service workers need the ability to bypass the cache for certain scripts when checking for updates.
Module workers are a structural improvement: they bring the same loading, caching, and scoping benefits to background threads that modules already give the main thread, and they open the door to better preloading and shared code across contexts.



