module.register() Is Deprecated on Node 26: Fixing DEP0205 with module.registerHooks()
You moved a project to Node 26 LTS, started it, and the console ended with a line you've never seen before. Nothing crashed. The app works. But Node now says module.register() is deprecated, it says so on every start, and something in your process is calling it:
(node:440675) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
That caller might be your own custom loader, or a package like jiti, Sentry or Eleventy. The fix is different for each, so the first job is finding out which one it is.
Run the app with --trace-deprecation and read the first stack frame outside node:internal. If it's your code, rewrite the loader with module.registerHooks({ resolve, load }): make every hook synchronous, replace data and initialize() with plain variables, and keep starting it with --import. Check typeof module.registerHooks === 'function' to support Node below 22.15. If it's a dependency, upgrade it, or hide only this warning with --disable-warning=DEP0205.
Why does Node 26 say module.register is deprecated?
Because Node 26.0.0 made DEP0205 a runtime deprecation, so the warning is printed instead of sitting quietly in the docs. It started as a documentation-only deprecation in Node 25.9.0. Then nodejs/node PR #62401 ("module: runtime deprecate module.register()") landed as a semver-major change and shipped in Node.js 26.0.0 on 2026-05-05. Node 26 turned LTS ("Lithium") with 26.13.0 on 2026-10-28, which is when a lot of teams finally upgraded and met the warning.
The deprecations page gives the reason in one sentence: "Supporting async hooks has proven to be complex, involving worker threads orchestration, and there are issues that have proven unresolvable." Hooks added with register() run on a separate loader thread, with their own globals, apart from your code. Hooks added with registerHooks() run in the same thread, with the same globals, as the modules they load. Node wants everyone on the second model, and the docs say module.register() "will be removed in a future version of Node.js".
Before you go hunting, know how the warning behaves:
- It fires once per process. Node's own test for this change checks that the warning appears only once, so one line in the log can still mean several calls.
- Older lines stay quiet. The PR is labeled dont-land-on for v20, v22 and v24. On my lab box, Node 25.2.1 printed nothing either, because it predates even the doc-only deprecation.
--experimental-loaderis a different warning. That flag doesn't go throughregister(), so it never prints DEP0205. It prints anExperimentalWarninginstead (output in the hands-on section). If you see[DEP0205], something calledmodule.register()directly.
If the same upgrade also broke outgoing HTTP calls, that's a separate problem with its own fix: I covered it in Node 26 fetch failed and the undici 8 mismatch. And if your loader existed only to run TypeScript, check that first, because 26.0.0 also removed --experimental-transform-types (#61803).
Find the code that calls module.register() with --trace-deprecation
The default warning tells you what happened, not who did it. Add --trace-deprecation and Node prints a stack trace with the warning. For a CLI that you don't start with node yourself, put the flag in NODE_OPTIONS:
# your own entry point
node --trace-deprecation server.js
# a tool that starts Node for you
NODE_OPTIONS=--trace-deprecation npx @11ty/eleventy
Read the frames top down and skip every line that starts with node:internal. The first frame after those is the caller. Here's the trace from a real Eleventy 3.1.5 report on Node 26.1.0:
at node:internal/util:129:11
at Module.register (node:internal/modules/esm/loader:963:3)
at file:///.../node_modules/@11ty/eleventy/src/Util/Require.js:21:9
The path goes through node_modules/@11ty/eleventy, so this one isn't yours. Other traces people posted point at @sentry/node-core/build/esm/sdk/esmLoader.js (from sentry-javascript #20810) and at jiti/lib/jiti-register.mjs:4:1, which was loaded through NODE_OPTIONS='--import jiti/register' (jiti #480).
That one frame decides your next hour. A frame inside your repo means you rewrite the loader (next two sections). A frame inside node_modules means you upgrade or wait (the dependency section further down).
One more place to look if the trace confuses you: NODE_OPTIONS in your shell, your Dockerfile and your package.json scripts. An --import some-package/register hidden there is easy to forget. The vite-plus issue about this warning was closed because nobody could find the caller.
register() vs registerHooks(): what actually changes
Don't expect a rename. registerHooks() takes functions, not the URL of a hooks file. Tailwind's first attempt at this migration passed a loader URL as resolve, and reviewers had to point out that it wants functions (tailwindcss #19907). The esmock maintainer put it well: "it seems registerHooks is very different and not really analogous to the old register function". After porting a loader myself, I agree.
module.register() | module.registerHooks() | |
|---|---|---|
| What you pass | A specifier for a hooks module, a parentURL, options data and transferList | An object with resolve and load functions |
| Where hooks run | Separate loader thread with its own globals | Same thread and globals as your modules |
| Hook style | Async | Synchronous only |
| Passing setup data | initialize(data) hook with cloneable data | Plain variables in the same file |
require() | Doesn't cover createRequire(), several CommonJS caveats | require and require.resolve always go through the hooks |
| Undo | Not possible | deregister() or [Symbol.dispose] |
| Worker threads | Inherited by default | Not inherited, unless loaded with --import or --require |
| Added in | v20.6.0 / v18.19.0 | v23.5.0 / v22.15.0 |
| Status in Node 26 | Deprecated (DEP0205, runtime) | Stability 1.2, release candidate since v25.4.0 / v24.13.1 |
Both APIs chain hooks last in, first out: if you call registerHooks(hook1) and then registerHooks(hook2), hook2 runs first. When sync and async hooks are mixed, the module docs say the synchronous ones always run first. Hook signatures didn't change: resolve(specifier, context, nextResolve) returns { url } and load(url, context, nextLoad) returns { format, source }, each optionally with shortCircuit: true.
One side effect I like: Deno's loader hooks page says it implements module.registerHooks() but not the async module.register(). If you also run code under Deno, the migrated loader is the only version that has a chance there. I didn't test that part.
Migrate module.register to registerHooks: the loader, before and after
The loader I ported does two things plain Node can't. It resolves an @app/ alias to src/lib/, and it lets you import a .txt file as an ES module. The app is tiny, with "type": "module" in package.json:
import { add } from '@app/math';
import greeting from './src/greeting.txt';
console.log(greeting.trim(), add(2, 3));
Before: register() plus a separate hooks file
// Old-style hooks: run on the loader thread, receive data via initialize()
import { readFile } from 'node:fs/promises';
let root;
export async function initialize(data) {
root = data.root;
}
export async function resolve(specifier, context, nextResolve) {
if (specifier.startsWith('@app/')) {
const name = specifier.slice('@app/'.length);
return { url: `${root}lib/${name}.js`, shortCircuit: true };
}
return nextResolve(specifier, context);
}
export async function load(url, context, nextLoad) {
if (url.endsWith('.txt')) {
const text = await readFile(new URL(url), 'utf8');
return {
format: 'module',
source: 'export default ' + JSON.stringify(text),
shortCircuit: true,
};
}
return nextLoad(url, context);
}
import { register } from 'node:module';
register('./hooks-async.mjs', import.meta.url, {
data: { root: new URL('./src/', import.meta.url).href },
});
You start it with node --import ./register-old.mjs main.js. Three things tie it to the old API. The hooks live in their own file that Node loads on another thread. Both hooks are async, and load awaits readFile(). And root only reaches the hooks through initialize(data), because the two files don't share memory.
After: one file, sync hooks, a closure instead of initialize()
import module from 'node:module';
import { readFileSync } from 'node:fs';
// A closure replaces register()'s data option + initialize()
const root = new URL('./src/', import.meta.url).href;
function resolve(specifier, context, nextResolve) {
if (specifier.startsWith('@app/')) {
const name = specifier.slice('@app/'.length);
return { url: `${root}lib/${name}.js`, shortCircuit: true };
}
return nextResolve(specifier, context);
}
function load(url, context, nextLoad) {
if (url.endsWith('.txt')) {
const text = readFileSync(new URL(url), 'utf8');
return {
format: 'module',
source: 'export default ' + JSON.stringify(text),
shortCircuit: true,
};
}
return nextLoad(url, context);
}
if (typeof module.registerHooks === 'function') {
// Node 22.15+ / 23.5+: in-thread, synchronous hooks
module.registerHooks({ resolve, load });
} else {
// Older Node: fall back to the off-thread async hooks
module.register('./hooks-async.mjs', import.meta.url, { data: { root } });
}
Only the first two changes touch logic. The rest is moving code around:
await readFile()becamereadFileSync(). Each hook has to return its result directly, with no Promise in between. If you're used to async/await everywhere, this is the part that feels backwards. Storybook's fix (PR #35337) made the same swap, plus esbuild'stransformtotransformSync.dataandinitialize()are gone. The docs sayregisterHooks()"does not support nor need it", since any setup can run right before the call. Here that's a singleconst rootthat both hooks close over. Anything you used to send throughdataor aMessagePortintransferListbecomes a normal variable.- No second file to find by URL. The hooks are ordinary functions in the module that registers them.
- Startup doesn't change. It's still
node --import ./register-new.mjs main.js.
Keep Node 22.14 and older working
The if at the bottom is the fallback. Notice the default import: import module from 'node:module', then a typeof check. A named import like import { registerHooks } from 'node:module' looks cleaner, but on a Node without that export it fails at link time with a SyntaxError, before your typeof check ever runs. Storybook ran into exactly this. If the named versus default import difference is fuzzy, my post on JavaScript import and export explains why named imports are checked before any code executes.
My fallback branch reuses the old hooks-async.mjs untouched. That's fine on old versions, because the 22.x line doesn't print DEP0205 at all.
When would I drop the fallback? If your engines field already requires Node 22.15 or newer, delete it along with hooks-async.mjs. One code path is easier to test than two. Keep it only while you still support a Node version that lacks registerHooks.
I ran the old and new loader on Node 22.14, 25.2.1 and 26.13.0
I didn't want to trust the docs alone here, so I ran both loaders on three versions. My setup: a Linux x64 box with system Node 25.2.1, plus the official v26.13.0 and v22.14.0 tarballs from nodejs.org, checked against SHASUMS256.txt and unpacked into a lab folder. No global install and no npm packages, only node:module and node:fs. The app is the one above. First, which versions even have the new API:
N26=../bin/node-v26.13.0-linux-x64/bin/node
N22=../bin/node-v22.14.0-linux-x64/bin/node
for n in $N26 node $N22; do $n -p 'process.version + " registerHooks: " + typeof require("node:module").registerHooks'; done
v26.13.0 registerHooks: function
v25.2.1 registerHooks: function
v22.14.0 registerHooks: undefined
The old loader: silent on 25, DEP0205 on 26
On Node 25.2.1, node --import ./register-old.mjs main.js printed Hello from a text file 5 and exited with 0. No warning. Same command on 26.13.0:
$ $N26 --import ./register-old.mjs main.js
Hello from a text file 5
(node:440675) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
exit=0
The app still worked. What surprised me is the order: the warning came after the app's own output, so in a busy log it's easy to miss. With --trace-deprecation the stack pointed straight at line 3 of my file, the register( call:
Hello from a text file 5
(node:440688) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
at node:internal/util:128:11
at register (node:internal/modules/esm/loader:1014:3)
at file:///opt/blog-pipeline/runs/2026-10-31/lab/app/register-old.mjs:3:1
at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
at async node:internal/modules/esm/loader:650:26
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:97:9)
exit=0
Next I tried the two flags that come up when people ask about this warning. NODE_OPTIONS=--disable-warning=DEP0205 printed only Hello from a text file 5 and exit 0. --throw-deprecation behaved differently from what I expected:
$ $N26 --throw-deprecation --import ./register-old.mjs main.js
Hello from a text file 5
node:internal/process/warning:176
throw warning;
^
DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
at node:internal/util:128:11
at register (node:internal/modules/esm/loader:1014:3)
at file:///opt/blog-pipeline/runs/2026-10-31/lab/app/register-old.mjs:3:1
...
code: 'DEP0205'
}
Node.js v26.13.0
exit=1
main.js had already run before the throw. So the flag fails a CI job with exit code 1, but it won't stop an app from starting. Good as a gate, useless as a guard.
The new loader: same output on all three, nothing on stderr
I wrote a small script that runs a loader on every version and fails if stdout differs or stderr isn't empty:
#!/usr/bin/env bash
# Run a loader (default register-new.mjs) on every Node version: stdout must match, stderr must be empty.
set -u
LOADER=${1:-./register-new.mjs}
cd "$(dirname "$0")"
BIN=../bin
NODES=("$BIN/node-v26.13.0-linux-x64/bin/node" "node" "$BIN/node-v22.14.0-linux-x64/bin/node")
expected="Hello from a text file 5"
fail=0
for n in "${NODES[@]}"; do
v=$("$n" -v)
out=$(timeout 20 "$n" --import "$LOADER" main.js 2>stderr.tmp)
err=$(cat stderr.tmp)
if [[ "$out" == "$expected" && -z "$err" ]]; then
echo "PASS $v: $out"
else
echo "FAIL $v: stdout=[$out] stderr=[$err]"
fail=1
fi
done
rm -f stderr.tmp
exit $fail
$ ./test.sh
PASS v26.13.0: Hello from a text file 5
PASS v25.2.1: Hello from a text file 5
PASS v22.14.0: Hello from a text file 5
exit=0
$ ./test.sh ./register-old.mjs
FAIL v26.13.0: stdout=[Hello from a text file 5] stderr=[(node:441015) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)]
PASS v25.2.1: Hello from a text file 5
PASS v22.14.0: Hello from a text file 5
exit=1
The old loader is the negative control: it only fails on 26. On 22.14.0 the new loader took the register() branch, since registerHooks is undefined there, and still printed nothing on stderr.
Two mistakes I reproduced on purpose
Both come straight out of the GitHub threads. First, the named import, in a file called register-bad.mjs that starts with import { registerHooks } from 'node:module'; and has no fallback. On 26 it works. On 22.14.0:
$ $N22 --import ./register-bad.mjs main.js
file:///opt/blog-pipeline/runs/2026-10-31/lab/app/register-bad.mjs:1
import { registerHooks } from 'node:module';
^^^^^^^^^^^^^
SyntaxError: The requested module 'node:module' does not provide an export named 'registerHooks'
at ModuleJob._instantiate (node:internal/modules/esm/module_job:180:21)
...
Node.js v22.14.0
exit=1
Second, the lazy port: take the old async hooks and hand them to the new API unchanged.
import module from 'node:module';
// Mistake: copying the old async hooks straight into registerHooks()
import { resolve, load } from './hooks-async.mjs';
module.registerHooks({ resolve, load });
TypeError [ERR_INVALID_RETURN_PROPERTY_VALUE]: Expected a URL string to be returned for the "url" from the "resolve" hook but got undefined.
at validateResolve (node:internal/modules/customization_hooks:223:11)
...
code: 'ERR_INVALID_RETURN_PROPERTY_VALUE'
}
Node.js v26.13.0
exit=1
An async function returns a Promise, and a Promise has no .url. The error never says "async" or "Promise", so if you see "got undefined" right after a migration, look for a leftover async keyword before anything else. When I made resolve sync and left only load async, the message moved to the other hook: Expected a string, an ArrayBuffer, or a TypedArray to be returned for the "source" from the "load" hook but got undefined. Both went away once every hook was synchronous.
Two smaller results. --experimental-loader ./hooks-async.mjs crashed with Cannot read properties of undefined (reading 'root'), because that flag can't pass data to initialize(). With a pass-through loader instead, it ran and printed this, which still recommends the API that's now deprecated:
$ $N26 --experimental-loader ./noop-loader.mjs -e 'console.log("ran")'
ran
(node:440831) ExperimentalWarning: `--experimental-loader` may be removed in the future; instead use `register()`:
--import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("./noop-loader.mjs", pathToFileURL("./"));'
(Use `node --trace-warnings ...` to show where the warning was created)
exit=0
And deregister() did what the docs promise. After calling it, a fresh import of the .txt file failed with ERR_UNKNOWN_FILE_EXTENSION on both 26.13.0 and 25.2.1.
What I didn't test: real third-party packages (no Sentry, jiti or Storybook installed), the CommonJS require() path, Node 24.x, and the 25.9.0 build that has the doc-only deprecation.
When module.register() is called by a dependency
Upgrade the package if a fixed release exists. That's the only real fix, because the call sits in code you don't own. These are the packages from the bug reports and where each one stood on October 30, 2026:
| Package | Where the call is | Status |
|---|---|---|
| Eleventy | src/Util/Require.js | Fixed in 3.1.6 (and 4.0.0-alpha.8) |
| esmock | its loader | Fixed in 2.7.7 with a new sync esmockLoader.js (#342) |
| Storybook | importModule() | PR #35337 merged into next on 2026-09-03, release not confirmed |
| Angular CLI | worker execArgv hook file | PR #34359 merged on 2026-10-15, release not confirmed |
| jiti | lib/jiti-register.mjs | 2.7.0 still warns, PR #481 open |
Sentry (@sentry/node) | @sentry/node-core esmLoader.js | No fix, issue closed as not planned |
| import-in-the-middle | its register entry | 3.2.0+ ships a sync register-hooks.mjs |
Sentry is the awkward one. A maintainer said "This is expected at the moment until we migrate off IITM and RITM" and called it an upstream issue. The call happens when the registerEsmLoaderHooks option is on, which is the default. Turning it off should skip the call, but that option exists to wire up ESM instrumentation through import-in-the-middle, so I wouldn't flip it only to get a clean log.
Without a fixed release, hide this one warning and nothing else:
NODE_OPTIONS=--disable-warning=DEP0205 node server.js
--disable-warning takes a code or a type. It was added in v21.3.0 / v20.11.0 and became stable in v26.7.0, according to the CLI docs. The Angular CLI thread (#34358) used exactly this. I'd pick it over --no-deprecation, which hides every deprecation, including the next one you actually need to read.
Put a comment next to it with the issue link. Once the package ships a fix, delete the flag. Otherwise it quietly hides your own code too if someone adds a register() call later.
For your own code, --throw-deprecation in a CI step is a cheap check, since it exits with 1 (as the lab output showed). It also trips on every dependency that's still warning, so I'd only add it after the table above is clear for your project. If you already have a pattern for failing loudly on process-level problems, my notes on Node.js error handling cover where that kind of check fits.
Gotchas after you switch to registerHooks
"Expected a string, an ArrayBuffer, or a TypedArray ... but got null"
This one shows up when sync and async hooks are both active and CommonJS is loaded. The full message is TypeError [ERR_INVALID_RETURN_PROPERTY_VALUE]: Expected a string, an ArrayBuffer, or a TypedArray to be returned for the "source" from the "load" hook but got null. The async default nextLoad returns source: null for CommonJS, and the sync side rejected it (nodejs/node #57327, #59384). It was fixed by #59929.
registerHooks exists from 22.15.0 and 23.5.0, but the null source fix only shipped in 22.22.3, 24.11.1, 25.1.0 and 26.0.0 (per the import-in-the-middle 3.3.2 README). If you mix the new hooks with a package that still uses async hooks, upgrade Node to at least those versions before blaming your hook. I didn't reproduce this one, since my lab app is ESM only.
Hooks in the same file as your static imports
Static imports are linked before any code in the module runs, including your registerHooks() call. So this doesn't work: register hooks at the top of main.js and import a .txt file three lines below. The docs say it directly: "Do not use static import statements to load modules that need to be customized in the same module that registers the hooks." Use --import ./register-new.mjs, or call registerHooks() first and then await import() the rest of the app.
Worker threads lose the hooks
Sync hooks aren't inherited by worker threads by default, while the old async ones were. Hooks preloaded with --import or --require do carry over, because workers inherit process.execArgv. Angular's fix passes the hook file through the worker's execArgv. If you call registerHooks() from regular code and spawn workers later, register inside the worker too.
Registering twice runs the hook twice
Hooks chain, so a second registerHooks() call doesn't replace the first. It stacks on top. Storybook's review caught that concurrent importModule() calls could register the same hook twice, and fixed it with a shared in-flight promise. A file preloaded with --import is evaluated once, so this only bites when the call sits inside a function that can run more than once. Guard that function with a module-level flag. Keep the handle that registerHooks() returns if you'll want deregister() later.
FAQ
Is it safe to ignore the module.register() deprecation warning?
For now, yes. The app keeps working: on Node 26.13.0 my old loader printed the same output, then the warning, and exited with code 0. The catch is the future. The Node docs say module.register() will be removed in a future version, so treat the warning as a deadline and plan the move to registerHooks().
When will module.register be removed?
No removal version has been announced. The deprecations page only says it will be removed in a future version of Node.js. In the pull request that made it a runtime deprecation, maintainer mcollina wrote that they should aim for 26.x so they can actually drop it in 27, but that's an intention, not a published schedule.
Are async customization hooks entirely deprecated?
The supported way to install them is. module.register() is the API that runs async hooks on a separate loader thread, and module.registerHooks() only accepts synchronous functions. The only other route, the --experimental-loader flag, is discouraged rather than deprecated. If a hook needs async setup, do that work before you call registerHooks(), then keep the hooks themselves synchronous with APIs like readFileSync.
Is --experimental-loader deprecated too?
Not under DEP0205. The CLI docs call the flag discouraged and say it may be removed. On Node 26.13.0 it printed an ExperimentalWarning instead, and that warning still suggests register(), the API that is now deprecated. I'd skip both and preload a registerHooks() file with --import.
Do registerHooks hooks also apply to require()?
Yes. According to the Node module docs, require and require.resolve always go through hooks registered with registerHooks(), while the old async hooks missed createRequire() and had several CommonJS caveats. My lab app was ESM only, so I haven't tested the CommonJS path myself. Check the null source fix versions if you mix both kinds of hooks.
Which Node versions support module.registerHooks?
Node 22.15.0 and 23.5.0 or newer, which covers every 24.x, 25.x and 26.x release. It became a release candidate in 24.13.1 and 25.4.0. If sync hooks share the process with a package that still uses async hooks and loads CommonJS, aim for 22.22.3, 24.11.1, 25.1.0 or 26.0.0, where the null source bug is fixed.