Module customization hooks: intercepting resolve and load
Customization hooks intercept resolve and load for every module. Registered via module.register on a separate thread, they chain through next… hooks. Great for dev transpilation and mocking — but they tax startup and a hook that drops source maps wrecks every stack trace.
You shipped a TypeScript service that ran straight from source in production via a register()-based loader — no build step, “simpler pipeline.” Three weeks later a 2am page fires: TypeError in billing.ts at line 64. You open line 64 and it’s a blank line between two functions. Every stack frame is off by a dozen lines, because the load hook transpiled TS → JS on the fly and never emitted a source map, so V8 reports positions in the compiled output you’ve never seen. A five-minute null-check turns into a two-hour archaeology dig through generated code. The loader hook did exactly what you asked. You just forgot it now owns your stack traces.
The two hooks: resolve and load
Node’s module pipeline has two seams you can splice into, and a customization hook is a function that sits on one of them. resolve(specifier, context, nextResolve) turns a specifier ("lodash", "./util.js", "data:...") into a final, fully-qualified URL plus a declared format ("module", "commonjs", "json", …). load(url, context, nextLoad) takes that URL and returns the actual source (a string or buffer) and its format — and this is where you can transform the bytes before V8 ever sees them. Resolve answers “which file”; load answers “what’s in it”. A transpiler is a load hook: it intercepts a .ts URL, compiles the source, and hands back JavaScript with format: "module".
The third argument is the whole game. nextResolve / nextLoad is the next hook in the chain (or Node’s built-in default if you’re last). You either handle the request yourself, or you call next…(specifier, context) to defer. This is why hooks compose: tsx’s resolver and a coverage tool’s resolver can both be registered, each handling what it cares about and passing the rest down.
// loader.js — a load hook that transpiles TS on the fly
import { readFile } from "node:fs/promises";
import { transform } from "some-fast-transpiler"; // e.g. swc/esbuild
export async function load(url, context, nextLoad) {
if (!url.endsWith(".ts")) {
return nextLoad(url, context); // not ours — defer down the chain
}
const raw = await readFile(new URL(url), "utf8");
const { code } = await transform(raw, { sourcemap: "inline" }); // ← keep maps!
return { format: "module", source: code, shortCircuit: true };
}Two non-negotiable details live here. shortCircuit: true tells Node you deliberately handled this and are not calling nextLoad — omit it and Node throws to stop a hook silently swallowing the chain. And sourcemap: "inline" is the line that the hook in the intro forgot: without it, every position V8 reports is in the compiled output, not your .ts.
Registering hooks: module.register
You don’t wire hooks by hand — you register() them, and Node spins them up on a dedicated thread.
// app entry, run first: node --import ./register.js app.js
import { register } from "node:module";
register("./loader.js", import.meta.url); // (specifier, parentURL)register(specifier, parentURL) resolves specifier relative to parentURL (pass import.meta.url so a relative ./loader.js works regardless of cwd), then loads that module on a separate worker thread and starts routing every subsequent import/require through its resolve and load exports. The ordering rule that bites people: hooks only affect modules loaded after register runs, so registration must happen before the app’s own imports — which is exactly what --import guarantees (it runs the file before the main entry). Put register() at the top of app.js instead and the import of app.js itself, and anything it statically imports, was already resolved before your hook existed.
To pass configuration into the off-thread hooks, register takes a data option and a transferList, delivered to an optional initialize hook that runs once on the hook thread:
register("./loader.js", {
parentURL: import.meta.url,
data: { tsconfig: "./tsconfig.json" }, // structured-cloned to the hook thread
});
// loader.js
export async function initialize(data) {
// runs once on the hook thread; stash config, open a MessagePort, etc.
globalThis.__tsconfig = data.tsconfig;
}▸Why this works
Why a separate thread? Pre-Node-20 loader hooks ran in the same context as your application, sharing its module graph and globals. That created a snake-eating-its-tail problem: the loader’s own import statements went through the loader, so a hook could deadlock loading its own dependencies, and any state the hook touched (a monkey-patched global, a half-initialized module) leaked straight into the app. Moving hooks onto an isolated worker thread severs that: the hook graph and the application graph are fully separate, so the loader can pull in heavy deps (a whole transpiler) without polluting or racing the app it’s supposed to be loading for. The cost of isolation is that you can’t share live objects — you communicate by structured-cloned data and a MessagePort, never by a shared variable.
When to reach for a hook — and when not to
Before you wire a custom hook, ask yourself: is this a dev-loop problem, or a production requirement? The answer almost always determines the right choice.
Hooks earn their keep in a tight set of cases: on-the-fly TypeScript/JSX transpilation (tsx, ts-node, swc-node all register a load hook), test mocking and instrumentation, custom protocols (import x from "config:db"), import-map-style remapping, and coverage tools rewriting source. The common thread is development and test ergonomics — running code as authored without a build.
The tradeoffs are real and they compound. (a) A hook runs for every module, so a slow load adds its cost linearly across your dependency tree — a transpile-on-load step that costs ~30–50ms per file cold turns a 400-file app into 12–20 extra seconds of startup, every node invocation. (b) The off-thread model means no cheap shared state — you can’t reach into the app’s globals from a hook; everything crosses a port. (c) It’s an unstable surface. The hooks API was reshaped between Node 18, 20, and 22 (in-thread → off-thread, globalPreload removed, register added), so a loader pinned to one major can break on the next.
Taken together: hooks buy you a faster inner dev loop at the cost of per-invocation startup tax, no cheap shared state, and an API that shifts under you on Node upgrades. The senior call: transpile at build time for production (one cost, source maps emitted to disk, stable artifacts), and reserve hooks for dev/test where the no-build loop is worth the per-run tax.
You run a TypeScript HTTP service. In PRODUCTION you need fast, predictable startup and stack traces that point at real source lines. How should the TS become runnable JS?
Inside a load hook, what does calling nextLoad(url, context) do?
- 01Why were module customization hooks moved off the main thread, and what does that cost you?
- 02A transpile-on-load hook is the reason every production stack trace points at the wrong line. What is the mechanism, and what's the fix?
Node’s module pipeline exposes two interception seams, and a customization hook is a function on one of them: resolve(specifier, context, nextResolve) maps a specifier to a final URL plus format, and load(url, context, nextLoad) returns the source bytes — the point where a transpiler turns .ts into JavaScript before V8 sees it. The next… argument is the next hook (or the default), so hooks compose: handle what’s yours, defer the rest, and mark a handled request with shortCircuit: true. You install hooks with module.register(specifier, parentURL), which loads them on a separate worker thread — isolation that, since Node 20, replaced the dangerous in-thread globalPreload model where a loader could deadlock on its own imports or leak globals into the app; the price is no shared state, so config arrives via register’s data and the initialize hook, and anything live travels over a MessagePort. Hooks are the right tool for dev/test ergonomics — on-the-fly TS/JSX, mocking, custom protocols, coverage — but they run for every module (a ~30–50ms/file transpile is seconds of startup at scale), they can’t cheaply share state, and the API churns across majors, so production should transpile at build time. And the failure that defines this lesson: a transpiling load hook that omits source maps makes V8 report positions in compiled output, so every stack trace drifts off the real source — emit inline maps, or own the 2am dig.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.