TypeScript setup

The browser-shape Worker global is typed by Nub's own types package — @types/node declares only node:worker_threads. Install @nubjs/types as a devDependency alongside @types/node 26.

# install the package
npm i -D @nubjs/types @types/node@25

# tsconfig.json
{ "compilerOptions": { "types": ["node", "@nubjs/types"] } }

Browsers expose a Worker global; Node ships only node:worker_threads.Worker, a different class with a different API, and never a browser-shape global. Nub closes the gap with a polyfill preloaded on every supported Node (18.19+), so the browser constructor and messaging shape work unchanged.

const worker = new Worker(import.meta.resolve("./worker.ts"), { type: "module" });

worker.onmessage = (ev) => {
  console.log(ev.data); // → 42
  worker.terminate();
};
worker.postMessage({ n: 41 });
// worker.ts
self.onmessage = (ev) => {
  self.postMessage(ev.data.n + 1);
};

The worker entry is TypeScript for free — Nub transpiles it like any other file. A listening worker keeps the process alive, so the main thread calls worker.terminate() once it has its reply (or the worker can self.close() itself).

Creating a worker

The constructor takes the WHATWG script-URL inputs — a file path or URL (including file://), a data: URL, or a blob: URL. For a path-based worker, default to import.meta.resolve:

new Worker(import.meta.resolve("./worker.ts")); // module-relative, build-free, TypeScript and all

The import.meta.resolve call resolves a specifier against the current module — never process.cwd() — and returns its URL. It is the clean spelling to reach for.

Using a bundler? Vite, webpack, and esbuild trace the new URL(...) form — pulling the worker into its own chunk and rewriting its path — but they don't yet trace import.meta.resolve. Switch forms whenever a bundler is in the pipeline:

new Worker(new URL("./worker.ts", import.meta.url)); // bundler-traceable, and runtime-correct too

A bare relative string resolves against process.cwd(), not the calling module — matching node:worker_threads and Bun. It works when the launch directory is fixed, but breaks for a nested file run from elsewhere:

new Worker("./worker.ts"); // cwd-relative — avoid unless the launch dir is known

Inline sources skip resolution. A data: URL runs directly, and a blob: URL from URL.createObjectURL is snapshotted synchronously and spawned:

const src = "self.postMessage('ready')";
new Worker("data:text/javascript," + encodeURIComponent(src));
new Worker(URL.createObjectURL(new Blob([src], { type: "text/javascript" })));

For a raw source string, pass { eval: true } — Node's inline form, covered under Node worker_threads compatibility.

The second argument is a WorkerOptions object:

OptionValuesEffect
type"module" (default), "classic"Which importScripts form the worker scope exposes — classic gets the synchronous loader, module the throwing form
namestringReadable as self.name inside the worker
evaltrueRuns the constructor's first argument as the worker's source instead of resolving it as a URL
execArgvstring arrayNode flags for the worker; merged onto the flags carrying Nub's preload
envobjectEnvironment for the worker thread

Messaging

The two threads exchange messages with postMessage and onmessage (or addEventListener("message")). Inbound messages arrive as real MessageEvents, so the payload is on ev.data:

worker.postMessage({ n: 41 });
worker.onmessage = (ev) => console.log(ev.data); // MessageEvent — payload on .data
worker.addEventListener("message", (ev) => console.log(ev.data)); // same event, EventTarget form

A thrown error in the worker surfaces on the main thread as an ErrorEvent carrying message, error, and the source location read from the worker's stack:

worker.onerror = (ev) => console.error(ev.message, ev.filename, ev.lineno);

Payloads are cloned with Node's structured serializer. Pass a transfer list as the second argument to move ownership of an ArrayBuffer or MessagePort instead of copying it — transferring detaches it on the sending side:

const buf = new ArrayBuffer(8);
worker.postMessage(buf, [buf]); // buf.byteLength is now 0 on this side
CategoryMembers
Clonedplain objects and arrays, Map / Set / Date, typed arrays
TransferredArrayBuffer, MessagePort, FileHandle — Node's transferable set
SharedSharedArrayBuffer (shared by reference, not copied)
UnavailableImageBitmap, OffscreenCanvas, stream transfer — no DOM substrate on Node

Inside the worker

Nub installs the WHATWG dedicated-worker scope on top of node:worker_threads.parentPort. The global self (=== globalThis) carries the messaging and lifecycle surface:

// worker.ts
self.name; // the { name } constructor option
self.onmessage = (ev) => self.postMessage(ev.data);
self.addEventListener("message", (ev) => {});
self.close(); // stop this worker from the inside

Node's worker global is not an EventTarget and exposes none of this, so the polyfill provides the whole scope. The standard messaging types — MessageEvent, MessageChannel, MessagePort — are Node's own globals, available inside a worker unchanged.

A module worker is the default. A classic worker ({ type: "classic" }) instead gets the synchronous importScripts() loader; a module worker gets a throwing importScripts and uses import. The type option chooses only which loader the scope exposes — Node still decides the entry's module-vs-CommonJS parsing by file extension and the nearest package.json "type", the same rule Nub applies to the main entry.

// worker.cjs — a classic worker
importScripts("./setup.cjs"); // fetch + run synchronously, in order
self.onmessage = (ev) => self.postMessage(ev.data);

In a classic worker, importScripts() evaluates local files and data: URLs synchronously, in order. Remote (http: / https:) URLs are unsupported — there is no synchronous network on Node.

Node worker_threads compatibility

The web shape above is the primary surface. The same handle also mirrors node:worker_threads.Worker for Node-style code: its EventEmitter methods, the online and exit lifecycle events, a Promise-returning terminate(), and { eval: true } for an inline source string.

const worker = new Worker(
  `const { parentPort } = require("node:worker_threads");
   parentPort.on("message", (n) => parentPort.postMessage(n * 2));`,
  { eval: true },
);

worker.on("online", () => console.log("started"));
worker.on("error", (err) => console.error(err.stack)); // a bare Error
worker.on("exit", (code) => console.log("exited", code));

worker.on("message", async (value) => {
  console.log(value); // 42 — the raw value, not a MessageEvent
  const code = await worker.terminate(); // Promise<number>
});

worker.postMessage(21);

On the node channel, message listeners get the raw posted value and error listeners a bare Error — Node's shapes, not the MessageEvent / ErrorEvent the web channel keeps. Both channels live on one handle; reach for whichever fits the code.

TypeScript

The main-thread Worker global and WorkerOptions are typed by @nubjs/types (a types-only devDependency). The declaration steps aside when lib: ["dom"] is in your tsconfig.json — the DOM's own Worker type wins there — so the two never collide.

Inside a worker file the global scope is the dedicated-worker scope, not the main thread's — self, postMessage, and onmessage live there, and neither @types/node nor @nubjs/types declares them. Set lib: ["webworker"] for the full worker-scope types, or drop a one-line shim at the top of the worker for the common handlers:

// worker.ts
declare var self: Worker;

self.onmessage = (ev: MessageEvent) => {
  self.postMessage(ev.data + 1);
};

This is the pattern Bun documents — it types self.postMessage and self.onmessage with no tsconfig change.

How it works

The polyfill is a preload (runtime/worker-polyfill.mjs), not a flag injection — Node has no flag that exposes a browser Worker. It defines globalThis.Worker as an EventTarget subclass wrapping a node:worker_threads.Worker, feature-detected with typeof so it installs only when no native global is already present. To protect cold start, it installs lazily on the first new Worker(...).

Spec conformance

Nub's Worker runs a vendored slice of web-platform-tests on every supported Node tier (tests/worker-wpt/) — the webmessaging battery, structured clone over a MessageChannel, and the worker-scope event tests — green against a checked-in expectations file. Messaging and structured clone pass; the documented exceptions are browser-DOM types Node has no substrate for, like ImageBitmap and OffscreenCanvas.

Divergences

The browser Worker and nub's share the same shape. Two differences are worth knowing:

  • No SharedWorker — it needs a browser document and origin model with no server-side equivalent. Use a single worker with message passing.
  • Workers are real threads — each is an OS thread with its own V8 isolate and module graph, heavier than a browser worker. Pool them for hot paths instead of spawning one per task.