Node.js 27 removes module.register() and the --experimental-loader and --loader flags (DEP0205). These provided asynchronous module customization hooks, which ran on a separate loader thread. They were documented as experimental, deprecated in the docs in Node.js 24.15.0 and 25.9.0, and began printing a runtime warning in Node.js 26.0.0.
module.registerHooks() is the replacement. It takes the same kinds of hooks (resolve and load), but they are synchronous and run on the thread that loads your modules. In most cases that makes them simpler to write and faster to run.
| Your hooks… | Do this |
|---|---|
| can run synchronously on the main thread (most can) | Rewrite them for registerHooks(), as described below. |
| depend on asynchronous work, and you can’t or don’t want to rewrite them | Use module-register-ponyfill, a drop-in register() built on registerHooks() and a worker thread. |
| need their own off-thread design | Build it on registerHooks() plus a worker; everysync turns async worker APIs into synchronous calls. |
registerHooks() is available in Node.js 22.15.0 and later (and 23.5.0 and later). If your package supports older versions, feature-detect it:
import module from 'node:module'; if (typeof module.registerHooks === 'function') { module.registerHooks({ resolve, load }); } else { module.register('./hooks.mjs', import.meta.url); }
An asynchronous hooks module, registered with register():
// hooks.mjs export async function resolve(specifier, context, nextResolve) { if (specifier === 'virtual:greeting') { return { url: 'virtual:greeting', shortCircuit: true }; } return nextResolve(specifier, context); } export async function load(url, context, nextLoad) { if (url === 'virtual:greeting') { return { format: 'module', source: 'export default "hello"', shortCircuit: true, }; } return nextLoad(url, context); }
// register.mjs import { register } from 'node:module'; register('./hooks.mjs', import.meta.url);
The same hooks, registered with registerHooks():
// register.mjs import { registerHooks } from 'node:module'; registerHooks({ resolve(specifier, context, nextResolve) { if (specifier === 'virtual:greeting') { return { url: 'virtual:greeting', shortCircuit: true }; } return nextResolve(specifier, context); }, load(url, context, nextLoad) { if (url === 'virtual:greeting') { return { format: 'module', source: 'export default "hello"', shortCircuit: true, }; } return nextLoad(url, context); }, });
The hook functions have the same names, arguments and return values. What changed:
- Remove
asyncandawait.nextResolve()andnextLoad()return their results directly. - Pass the hooks straight to
registerHooks()instead of exporting them from a separate module, so there is no specifier orparentURLto resolve. You can still keep them in their own file andimportthem.
Run your app with the registration file preloaded, the same way you did before:
node --import ./register.mjs app.mjs
These flags took a module that exported hooks. Replace the flag with --import and a small file that registers them:
# Before (removed) node --experimental-loader ./hooks.mjs app.mjs # After node --import ./register.mjs app.mjs
Running Node.js 27 with either flag fails with bad option: --loader (or --experimental-loader).
Asynchronous hooks had their own thread and global scope, so they passed data through initialize(), the data option and message ports. Synchronous hooks share your application’s thread and globals, so none of that is needed. Use a closure or module-level state instead:
import { registerHooks } from 'node:module'; const prefix = '>> '; // was passed through `initialize({ prefix })` registerHooks({ resolve(specifier, context, nextResolve) { console.log(prefix + 'resolving', specifier); return nextResolve(specifier, context); }, });
There is no initialize hook, data option or transferList option. A slow hook now blocks your application instead of a background thread, so keep them fast.
If a hook awaited something, such as a file read, replace it with a synchronous equivalent:
import { registerHooks } from 'node:module'; import { readFileSync } from 'node:fs'; registerHooks({ load(url, context, nextLoad) { if (url.endsWith('.txt')) { return { format: 'module', shortCircuit: true, source: `export default ${JSON.stringify(readFileSync(new URL(url), 'utf8'))};`, }; } return nextLoad(url, context); }, });
If the work can’t be made synchronous (a network request, for example), see Hooks that need asynchronous work.
Two options keep the asynchronous work off your main thread.
module-register-ponyfill provides register() on top of registerHooks(), a worker thread and Atomics, so an existing hooks module usually works unchanged. Install it, then replace import { register } from 'node:module' with an import from the package:
// register.mjs import { register } from 'module-register-ponyfill'; register('./hooks.mjs', import.meta.url);
It doesn’t reproduce every detail of the native implementation; see its known divergences. The most likely to affect a migration: earlier register() calls no longer affect how later hooks modules are loaded. For example, registering a hook that enables TypeScript and then a .ts hooks module worked natively but won’t work with the ponyfill. If your hooks bootstrap each other this way, migrate those to registerHooks() instead.
everysync lets synchronous code call an asynchronous function that runs in a worker thread: the caller blocks until the worker’s promise settles or a timeout expires. That is what a synchronous load hook needs to fetch modules from a URL, for example.
First, the worker exposes the asynchronous work:
// fetch-worker.mjs import { wire } from 'everysync'; import { workerData } from 'node:worker_threads'; wire(workerData.buffer, { async fetchText(url) { const response = await fetch(url); return await response.text(); }, }); // Keep the worker's event loop alive. setInterval(() => {}, 100000);
Then the registration file starts the worker and calls it from the hooks as if it were synchronous:
// register.mjs import { registerHooks } from 'node:module'; import { Worker } from 'node:worker_threads'; import { makeSync } from 'everysync'; const buffer = new SharedArrayBuffer(1024, { maxByteLength: 64 * 1024 * 1024, }); const worker = new Worker(new URL('./fetch-worker.mjs', import.meta.url), { workerData: { buffer }, execArgv: [], // Don't re-run this file (via --import) inside the worker. }); worker.unref(); // Don't let the worker keep the process alive. // The default timeout is 1 second per call, so allow longer for the network. const { fetchText } = makeSync(buffer, { timeout: 30_000 }); registerHooks({ resolve(specifier, context, nextResolve) { if (specifier.startsWith('https://')) { return { url: specifier, shortCircuit: true }; } return nextResolve(specifier, context); }, load(url, context, nextLoad) { if (url.startsWith('https://')) { return { format: 'module', source: fetchText(url), shortCircuit: true }; } return nextLoad(url, context); }, });
node --import ./register.mjs app.mjs
Two details matter. Pass execArgv: [] when you preload the registration file with --import, because workers inherit your flags and would otherwise run register.mjs themselves and start another worker (makeSync() then fails with “The initialization timed out”). And remember that the calling thread is blocked while the worker waits on the network, so this suits tooling and development more than hot paths. everysync gives up after one second by default (The response timed out after 1000ms), so choose a timeout that suits your slowest request.
Asynchronous hooks did not affect every require() call in a CommonJS module graph. Synchronous hooks run for import, require() and createRequire(), so a hook that previously only saw import will now also see CommonJS loads. If your hook assumes every module is ESM, check context.format or context.conditions.
Hooks registered with registerHooks() apply only to the thread that registered them. To use them in worker threads, either call registerHooks() in each worker, or register them in a file preloaded with --import (or --require); workers inherit those flags through process.execArgv:
node --import ./register.mjs app.mjs
As before, hooks only affect modules loaded after they are registered. Preloading with --import guarantees this. If you register inside your application, load the customized modules afterward with a dynamic import():
import './register.mjs'; const { default: greeting } = await import('virtual:greeting');
bad option: --loaderorbad option: --experimental-loader: see Replacing--loader.ERR_MODULE_REGISTER_REMOVED: module.register() has been removed (DEP0205):module.register()now throws. Switch toregisterHooks(), as described above.- A hook that worked before now fails with
Promiseorawaiterrors: it is probably returning a promise from a synchronous hook. Removeasync.
For the full hook reference, including the context objects and every return value, see the module API documentation.