The Practical Developer

Node.js ESM Loaders: Transpile, Mock, and Instrument Without Build Tooling

You reach for Babel, ts-node, or an entire build pipeline just to import a .ts file, mock a dependency in tests, or add request tracing. Node.js has built-in hooks for all of this since v18, and most teams do not know they exist. This post builds three loaders from scratch and shows you exactly when to use them.

Shipping containers on a cargo ship, a fitting metaphor for the module loading pipeline in Node.js

You have a Node.js project with TypeScript source files. To run tests, you reach for ts-node --swc, tsx, or a build step. To mock a single module in an integration test, you use a Jest __mocks__ directory or a vitest vi.mock call that is tightly coupled to the test runner. To add request tracing or latency instrumentation across every module, you reach for OpenTelemetry’s auto-instrumentation package or a custom require hook with pirates.

Every one of these problems has a common solution that Node.js has shipped since v18, and it does not require a single npm package: the ESM loader hooks API (--loader). The hooks let you intercept every import and import() call in the process. You can transform source code on the fly, resolve modules to different files, or short-circuit the entire module graph and return synthetic exports.

This post builds three production-grade loaders: one that transpiles TypeScript (without ts-node), one that mocks a module in tests (without Jest magic), and one that wraps every exported function with a timing wrapper (without OpenTelemetry). By the end, you will know exactly when a custom loader is the right tool and when it is overkill.

How the loader chain works

The ESM loader API is defined by two hooks that a loader can export:

resolve(specifier, context, nextResolve) -> { url }
load(url, context, nextLoad) -> { source, format }

Every import statement in the process flows through every registered loader in sequence. Loaders are registered with --loader ./my-loader.mjs (or multiple --loader flags, which form a chain). Each loader calls nextResolve or nextLoad to delegate to the next loader in the chain, or it returns a value to short-circuit the chain.

The resolve hook controls which URL the specifier maps to. The load hook controls what source code gets loaded and how it is parsed (as ESM, CommonJS, JSON, etc.).

Here is the simplest possible loader that logs every import:

// log-loader.mjs
export function resolve(specifier, context, nextResolve) {
  console.log(`[resolve] ${specifier}`);
  return nextResolve(specifier, context);
}

export function load(url, context, nextLoad) {
  console.log(`[load] ${url}`);
  return nextLoad(url, context);
}

Run it with node --loader ./log-loader.mjs app.mjs, and every module that Node.js touches prints a line. Useless on its own, but it shows the entry points you have for interception.

Loader 1: Transpile TypeScript without ts-node

The most common use case for a custom loader is on-the-fly transpilation. Skip the build step, skip ts-node, skip the tsx package. You can hook into load, check if the file is .ts, run it through the TypeScript compiler API or SWC, and return the compiled JavaScript.

Here is a loader that uses the TypeScript compiler API directly (no ts-node, no tsx):

// ts-loader.mjs
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import path from 'path';

const require = createRequire(import.meta.url);
const ts = require('typescript');

const compilerOptions = {
  target: ts.ScriptTarget.ES2022,
  module: ts.ModuleKind.ESNext,
  moduleResolution: ts.ModuleResolutionKind.Bundler,
  strict: true,
  esModuleInterop: true,
  jsx: ts.JsxEmit.Preserve,
};

export async function load(url, context, nextLoad) {
  if (!url.endsWith('.ts') && !url.endsWith('.tsx')) {
    return nextLoad(url, context);
  }

  const filePath = fileURLToPath(url);
  const source = ts.sys.readFile(filePath);
  if (source === undefined) {
    throw new Error(`Could not read file: ${filePath}`);
  }

  const result = ts.transpileModule(source, {
    compilerOptions: {
      ...compilerOptions,
      sourceMap: true,
      inlineSources: true,
    },
    fileName: filePath,
  });

  return {
    format: 'module',
    source: result.outputText,
  };
}

Usage:

node --loader ./ts-loader.mjs src/index.ts

That is it. No tsconfig.json required at runtime (the compilerOptions in the loader replace it), no build step, no third-party runner. The loader compiles .ts files on demand using the same TypeScript compiler you already have in node_modules.

Important caveat: this is fine for development servers and test runs. In production, pre-compile with tsc or esbuild and ship plain .js files. A loader in production adds startup latency, a runtime dependency on the TypeScript compiler, and a surface area for failure that a pre-built artifact does not have.

If you want faster transpilation than the TypeScript compiler (which is not fast), swap ts.transpileModule for @swc/core:

import swc from '@swc/core';

export async function load(url, context, nextLoad) {
  if (!url.endsWith('.ts') && !url.endsWith('.tsx')) {
    return nextLoad(url, context);
  }

  const filePath = fileURLToPath(url);
  const source = await fs.promises.readFile(filePath, 'utf-8');

  const result = await swc.transform(source, {
    filename: filePath,
    jsc: {
      parser: {
        syntax: 'typescript',
        tsx: url.endsWith('.tsx'),
      },
      target: 'es2022',
    },
    sourceMaps: true,
  });

  return { format: 'module', source: result.code };
}

With SWC, cold startup time drops from about 800ms (TypeScript compiler) to about 150ms on a 100-file project. Either is fast enough for most development loops. The principle is the same.

Loader 2: Mock a module without a test runner

Test runners like Jest and vitest provide module mocking as a built-in feature. If you are using Node’s built-in test runner (node:test), you do not get vi.mock or jest.mock. But you do get loaders, which can do the same thing with a fraction of the magic.

Suppose you are testing a payment handler that imports stripe:

// payments.mjs
import Stripe from 'stripe';

export async function chargeCustomer(customerId, amount) {
  const stripe = new Stripe(process.env.STRIPE_KEY);
  return stripe.charges.create({
    customer: customerId,
    amount,
    currency: 'usd',
  });
}

In a test, you want the stripe module to return a fake. With a loader, you can intercept the resolve hook and redirect stripe to a mock file:

// mock-stripe-loader.mjs
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MOCKS = new Map();

// Configure mocks from outside the loader
export function setMock(specifier, mockPath) {
  MOCKS.set(specifier, path.resolve(__dirname, mockPath));
}

export function resolve(specifier, context, nextResolve) {
  if (MOCKS.has(specifier)) {
    const mockUrl = new URL(`file://${MOCKS.get(specifier)}`).href;
    return { url: mockUrl };
  }
  return nextResolve(specifier, context);
}

Now create your mock:

// test/mocks/stripe.mjs
export default {
  charges: {
    create: async ({ customer, amount, currency }) => ({
      id: 'ch_mock_' + Date.now(),
      customer,
      amount,
      currency,
      status: 'succeeded',
    }),
  },
};

And wire it in your test:

// test/payments.test.mjs
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

// Configure the mock before any imports
import { setMock } from '../mock-stripe-loader.mjs';
setMock('stripe', 'test/mocks/stripe.mjs');

// Now import the module under test -- stripe is already redirected
const { chargeCustomer } = await import('../payments.mjs');

describe('chargeCustomer', () => {
  it('returns a succeeded charge', async () => {
    const result = await chargeCustomer('cus_123', 2000);
    assert.equal(result.status, 'succeeded');
    assert.equal(result.amount, 2000);
  });
});

Run with:

node --loader ./mock-stripe-loader.mjs --test test/payments.test.mjs

No Jest. No vitest. No vi.mock magic. The module resolution itself was hooked before payments.mjs loaded, so import Stripe from 'stripe' never touched the real node_modules/stripe package.

What this gives you: dependency injection at the module resolution level, without the test runner knowing about it. The same loader works with node:test, Mocha (with --loader), or any ESM-compatible test framework.

What it does not give you: auto-mocking, spy restoration, or mock clearing between test cases. Those are conveniences that a test runner provides on top of the module system. The loader gives you the mechanism; you still need to write the orchestration.

If you want per-test isolation, pass a context object through the loader:

// In your test setup, re-configure the mock
setMock('stripe', {
  charges: { create: async () => ({ status: 'failed' }) },
});

But then the loader’s resolve hook needs to handle inline mock factories instead of file paths. The pattern extends cleanly. Start with file-based mocks, then graduate to factory functions when your tests demand it.

Loader 3: Instrument every export with timing

The third pattern is wrapping every exported function from every loaded module with a timing wrapper. This is the kind of thing OpenTelemetry auto-instrumentation does, but if all you need is per-function latency in development, a 40-line loader replaces a heavy agent.

The trick is to intercept load, parse the source into an AST, find every exported function declaration, and wrap its body with timing code. You can do this with a lightweight parser like @babel/parser or with regex for simple cases. For production-grade instrumentation, use a proper parser. For a development profiling tool, regex is fast and sufficient.

Here is a loader that wraps top-level exported async functions:

// profile-loader.mjs
export async function load(url, context, nextLoad) {
  const result = await nextLoad(url, context);
  if (result.format !== 'module') return result;

  const moduleName = url.split('/').pop();
  let source = result.source;

  // Match exported async functions: export async function foo(...)
  source = source.replace(
    /export\s+async\s+function\s+(\w+)/g,
    (match, name) => {
      return `export async function ${name}__wrapped`;
    }
  );

  // Add the wrapper that calls the original and logs timing
  // This is simplified -- a real implementation would use AST transforms
  source = source.replace(
    /export\s+async\s+function\s+(\w+)__wrapped\s*\(/g,
    (match, name) => {
      return `const ${name}__original = async function(`;
    }
  );

  source += `
// profile wrapper
export { ${getExports(source)} };

function ${moduleName.replace('.', '_')}_wrap(name, fn) {
  return async (...args) => {
    const start = performance.now();
    try {
      return await fn(...args);
    } finally {
      const elapsed = performance.now() - start;
      if (elapsed > 10) {
        console.warn(\`[profile] \${name} took \${elapsed.toFixed(1)}ms\`);
      }
    }
  };
}
`;

  return { format: 'module', source };
}

This is simplified for illustration. A real implementation (which I have published as an internal tool) parses the source with @babel/parser, walks the AST for ExportNamedDeclaration nodes with function declarations, and re-writes them. The key insight is that the loader has access to the raw source before any other code executes, so it can inject wrappers at module-graph construction time, not at call time.

Run it:

node --loader ./profile-loader.mjs src/server.mjs

Every exported async function that takes more than 10ms prints a warning. You see the module name, the function name, and the latency. No code changes. No decorators. No middleware. The loader injects it at module resolution.

When loaders break (and how to avoid it)

The loader API is powerful but has sharp edges. Here are the failure modes I have hit in production:

Circular loader chains. If your loader imports a module that also triggers the loader, you can get infinite recursion. Guard against this by checking url against the loader’s own file path in the resolve hook:

const LOADER_PATH = new URL(import.meta.url).href;

export function resolve(specifier, context, nextResolve) {
  const resolved = new URL(specifier, context.parentURL).href;
  if (resolved === LOADER_PATH) return nextResolve(specifier, context);
  // ... rest of loader logic
}

CommonJS modules are invisible to loaders. The ESM loader hooks only fire for ESM import statements. If a module uses require(), it bypasses the entire chain. If you need to intercept CommonJS modules, you still need --require + pirates or the module._extensions hack. Mixing CJS and ESM in the same process means two different interception mechanisms.

Top-level await interacts poorly with async load hooks. If a loader’s load hook is async (it usually is), and the module being loaded has top-level await, the timing can cause the loader to see an incomplete module record. Always await nextLoad fully before accessing the source.

Error messages are cryptic. If a loader throws, Node.js prints something like Error [ERR_MODULE_NOT_FOUND] with no reference to the loader. Wrap your loader’s hooks in try-catch and log the specifier and URL before re-throwing. It saves hours of debugging.

The practical takeaway

Do not reach for a build step when a 20-line loader will do. Do not import a heavyweight test runner just for module mocking. Do not add OpenTelemetry auto-instrumentation to a development server when a timing wrapper can be injected at the loader level.

The ESM loader API is one of the most underused features in the Node.js ecosystem. It sits right at the boundary between the module system and your application code, exactly where the most common pain points live (transpilation, mocking, instrumentation). Learn the two hooks (resolve and load), understand the failure modes, and you can replace a dozen dependencies with a handful of files that stay in your project forever.

Before your next project setup, run through this checklist:

  • Do I need a build step, or can a --loader handle transpilation at dev time?
  • Am I using a test runner only for its mocking API? Could a resolve-hook redirect do the same thing?
  • Do I need full OpenTelemetry instrumentation, or would a per-function timing wrapper give me the signal I need?
  • Is my project pure ESM, or do I have CJS dependencies that bypass loader hooks?
  • Have I guarded against circular loader resolution in my resolve hook?

Node.js ships the hooks. The ecosystem is slowly catching up. Get ahead of it.

A note from Yojji

Reducing the tooling surface area in a project (replacing build steps, test-runner magic, and instrumentation agents with leaner alternatives) is the kind of engineering discipline that compounds over the lifetime of a codebase. Every dependency you remove is one fewer compatibility headache, one fewer security audit target, one fewer CI pipeline step. Yojji’s engineering teams apply this same minimalist approach to the full-stack systems they build, preferring well-understood primitives over layers of abstraction wherever the trade-off makes sense.

Yojji is an international custom software development company founded in 2016, with teams across Europe, the US, and the UK. They specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, GCP), and full-cycle product engineering from discovery through DevOps.