Backend

module.register() Is Deprecated on Node 26: Fixing DEP0205 with module.registerHooks()

Mayur Dabhi
Mayur Dabhi
October 31, 2026
13 min read

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:

Terminal
(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.

Short answer

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:

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:

Terminal
# 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:

Output
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 passA specifier for a hooks module, a parentURL, options data and transferListAn object with resolve and load functions
Where hooks runSeparate loader thread with its own globalsSame thread and globals as your modules
Hook styleAsyncSynchronous only
Passing setup datainitialize(data) hook with cloneable dataPlain variables in the same file
require()Doesn't cover createRequire(), several CommonJS caveatsrequire and require.resolve always go through the hooks
UndoNot possiblederegister() or [Symbol.dispose]
Worker threadsInherited by defaultNot inherited, unless loaded with --import or --require
Added inv20.6.0 / v18.19.0v23.5.0 / v22.15.0
Status in Node 26Deprecated (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:

main.js
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

hooks-async.mjs
// 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);
}
register-old.mjs
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()

register-new.mjs
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:

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:

Terminal
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
Output
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:

Terminal
$ $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:

Output
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:

Output
$ $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:

test.sh
#!/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
Terminal
$ ./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:

Output
$ $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.

register-async-mistake.mjs
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 });
Output
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:

Output
$ $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:

PackageWhere the call isStatus
Eleventysrc/Util/Require.jsFixed in 3.1.6 (and 4.0.0-alpha.8)
esmockits loaderFixed in 2.7.7 with a new sync esmockLoader.js (#342)
StorybookimportModule()PR #35337 merged into next on 2026-09-03, release not confirmed
Angular CLIworker execArgv hook filePR #34359 merged on 2026-10-15, release not confirmed
jitilib/jiti-register.mjs2.7.0 still warns, PR #481 open
Sentry (@sentry/node)@sentry/node-core esmLoader.jsNo fix, issue closed as not planned
import-in-the-middleits register entry3.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:

Terminal
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.

Version floor for the null source fix

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.

Node.js Module Hooks ESM Debugging
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.