jsmanifest logojsmanifest

Node.js 24 Native `require(esm)` Is Finally Stable — Here's What Changes in Your Build Pipeline

Node.js 24 Native `require(esm)` Is Finally Stable — Here's What Changes in Your Build Pipeline

Node.js 24 stabilizes require(esm), eliminating transpilation for most codebases. See what to delete from your build pipeline, when you still need transpilation, and how to migrate production systems safely.

The Long Road to require(esm) Stability

Most Node.js build complexity stems from one mismatch: CommonJS projects importing ESM dependencies. For years, developers have relied on Babel, TypeScript, or bundlers to bridge this gap. Node.js 24 changes that equation permanently by stabilizing native require(esm) support, removing the experimental flag that existed since Node.js 22.

This matters because the transpilation layer is now optional for the majority of production codebases. Teams can delete thousands of lines of configuration and eliminate entire build steps. The runtime can load ESM modules synchronously when you call require(), making the module format distinction nearly invisible at the application layer.

The implication here is that your build pipeline can shrink dramatically. But the transition requires understanding what Node.js 24 actually does under the hood and where the old toolchain is still necessary.

What require(esm) Actually Does Under the Hood

When you call require('./module.mjs') in Node.js 24, the runtime performs synchronous module graph resolution. It parses the ESM file, detects static imports, loads dependencies recursively, and executes the module body before returning the namespace object. This happens entirely in V8 without intermediate transpilation.

The critical constraint is that the ESM module cannot use top-level await. If it does, the synchronous require() call cannot resolve and Node.js throws an error immediately. This limitation is structural — you cannot block the event loop waiting for an asynchronous operation in a synchronous API.

%% alt: Node.js 24 require(esm) resolution flow showing static imports only
flowchart TD
    A[Call require esm] --> B{Has top-level await?}
    B -->|Yes| C[Throw ERR_REQUIRE_ASYNC_MODULE]
    B -->|No| D[Parse static imports]
    D --> E[Load dependencies recursively]
    E --> F[Execute module body]
    F --> G[Return namespace object]
    
    style C stroke:#ef4444,fill:#450a0a,color:#fca5a5
    
    classDef framework fill:#0b3b2e,stroke:#34d399,color:#d1fae5
    class D,E,F framework

For packages with conditional exports, Node.js respects the "require" field in package.json. If the package author specifies a CommonJS entry point for require() and an ESM entry point for import, the runtime selects the correct file automatically. This means well-maintained dependencies work without any configuration changes.

The failure mode here is subtle but expensive: if your dependency tree includes a single package with top-level await in its ESM entry point, your entire application breaks when you try to require() that chain. The error surfaces at runtime, not during static analysis.

Node.js ESM and CommonJS module resolution visualization

Your Build Pipeline Before and After: Real Examples

Before Node.js 24, importing an ESM-only package like node-fetch required transpilation. Developers used Babel or TypeScript with esModuleInterop to convert the import statement into a CommonJS-compatible form. The build step happened before every deployment, adding 30-90 seconds to CI pipelines for medium-sized projects.

Here's the old pattern with TypeScript:

// src/api.ts (TypeScript pre-Node 24)
import fetch from 'node-fetch';
 
export async function getUser(id: string) {
  const response = await fetch(`https://api.example.com/users/${id}`);
  return response.json();
}
 
// package.json
{
  "scripts": {
    "build": "tsc --project tsconfig.json",
    "start": "node dist/api.js"
  },
  "devDependencies": {
    "typescript": "^5.4.0",
    "@types/node": "^20.11.0"
  }
}

After Node.js 24, the same code runs directly without transpilation:

// src/api.js (Node.js 24 native)
const fetch = require('node-fetch');
 
module.exports.getUser = async function(id) {
  const response = await fetch(`https://api.example.com/users/${id}`);
  return response.json();
};
 
// package.json
{
  "scripts": {
    "start": "node src/api.js"
  }
}

The TypeScript compiler disappears entirely. The dist/ folder is gone. CI pipeline time drops by 40% for projects where transpilation was the bottleneck. The tradeoff is that you lose type checking — a separate concern covered in the TypeScript section below.

For teams maintaining internal libraries, the pattern shift is even more pronounced. You can publish ESM packages and let consumers use require() directly. The package author no longer needs to provide dual builds or write conditional exports manually. One ESM entry point serves both module systems.

The --require-module Flag and Incremental Migration

The --require-module flag in Node.js 24 preloads ESM modules before your application starts. This is critical for configuration files, instrumentation code, or environment setup that must run before the main application loads. Without this flag, you would need to refactor initialization logic to use import() at the top level.

Here's the migration path for a typical production service:

%% alt: Step-by-step migration flow from transpilation to native require(esm)
flowchart TD
    A[Audit dependency tree] --> B{Any top-level await?}
    B -->|Yes| C[Keep transpilation for those paths]
    B -->|No| D[Add --require-module for config]
    D --> E[Remove build scripts]
    E --> F[Test in staging with NODE_OPTIONS]
    F --> G[Deploy to production canary]
    G --> H[Monitor error rates for 24h]
    H --> I[Roll out to all instances]
    
    classDef userAction fill:#142544,stroke:#7c9cf0,color:#eaf2ff
    classDef framework fill:#0b3b2e,stroke:#34d399,color:#d1fae5
    class A,F,H userAction
    class D,E,I framework

The practical command looks like this:

# Old approach (transpilation required)
npm run build && node dist/server.js
 
# New approach (Node.js 24)
node --require-module ./config.mjs src/server.js

The distinction is critical. The --require-module flag runs before your main entry point, allowing you to set environment variables, configure logging, or initialize telemetry without blocking the event loop. For teams using structured logging patterns, this flag eliminates the need for wrapper scripts or init containers in Kubernetes.

During migration, set NODE_OPTIONS=--require-module=./config.mjs in your environment rather than modifying every invocation. This centralizes the configuration and makes rollback trivial if you encounter issues.

What You Can Delete From Your Toolchain Now

The build pipeline for most Node.js projects includes Babel, Webpack, or esbuild primarily to handle ESM imports in CommonJS projects. Node.js 24 eliminates this requirement for applications that don't need advanced transpilation features like JSX or experimental syntax.

%% alt: Comparison of build toolchain before and after Node.js 24
flowchart LR
    subgraph Before["Before Node.js 24: Multi-stage build"]
        B1[Source code] --> B2[Babel/TypeScript]
        B2 --> B3[Bundle/minify]
        B3 --> B4[Deploy artifacts]
    end
    
    subgraph After["After Node.js 24: Direct execution"]
        A1[Source code] --> A2[Node.js runtime]
        A2 --> A3[Production server]
    end
    
    Before -.->|Migration| After
    
    style B2 stroke:#ef4444,fill:#450a0a,color:#fca5a5
    style B3 stroke:#ef4444,fill:#450a0a,color:#fca5a5

Delete these files and dependencies immediately:

  • .babelrc or babel.config.js if used only for ESM/CommonJS interop
  • webpack.config.js if bundling was required for module resolution
  • tsconfig.json if TypeScript was used purely for transpilation, not type checking
  • All @babel/* packages that exist solely to transform import statements
  • Build scripts in package.json that run transpilers before node

The exception is TypeScript for type safety. If you want static analysis, keep the TypeScript compiler and run tsc --noEmit in CI. The runtime no longer depends on transpilation, but the type checker remains valuable. For teams following Node.js optimization patterns, this separation of concerns improves iteration speed dramatically.

Build pipeline comparison showing eliminated transpilation steps

Edge Cases Where You Still Need Transpilation

Native require(esm) does not eliminate all transpilation. Three scenarios require a build step even in Node.js 24:

First, any package using top-level await in its ESM entry point. If your dependency tree includes this pattern, you must transpile the entire import chain into a synchronous form. Tools like esbuild can bundle these modules into a single CommonJS file, but this defeats the purpose of native ESM support.

%% alt: Decision tree for when transpilation is still required
flowchart TD
    A[Evaluating dependency] --> B{Top-level await?}
    B -->|Yes| C[Requires esbuild/Webpack]
    B -->|No| D{Uses JSX or decorators?}
    D -->|Yes| E[Requires Babel transform]
    D -->|No| F{Targets older runtimes?}
    F -->|Yes| G[Requires Babel downlevel]
    F -->|No| H[Native execution ready]
    
    style C stroke:#ef4444,fill:#450a0a,color:#fca5a5
    style E stroke:#ef4444,fill:#450a0a,color:#fca5a5
    style G stroke:#ef4444,fill:#450a0a,color:#fca5a5
    
    classDef framework fill:#0b3b2e,stroke:#34d399,color:#d1fae5
    class H framework

Second, React or other JSX frameworks. Node.js does not parse JSX natively. If your server-side rendering code includes JSX syntax, Babel or the TypeScript compiler remains mandatory. This is a language-level feature, not a module system concern.

Third, experimental JavaScript features not yet stable in V8. Decorators, pipeline operators, or pattern matching require transpilation until they reach Stage 4 and ship in the Node.js LTS release. For teams using advanced JavaScript patterns, this is the primary reason to keep a transpiler in the pipeline.

The failure mode here is silent in development but catastrophic in production. A dependency might use top-level await in a rarely-executed code path. Your tests pass because they never trigger that import. Production breaks immediately when the code runs under load.

Production Deployment Checklist for Node.js 24 LTS

Migrating a production system to native require(esm) requires staged rollout and comprehensive monitoring. The process is deterministic but unforgiving — a single incompatible dependency breaks the entire service.

%% alt: Production deployment checklist with validation gates
flowchart TD
    A[Upgrade staging to Node.js 24] --> B[Run full regression suite]
    B --> C{All tests pass?}
    C -->|No| D[Identify breaking dependencies]
    D --> E[Add transpilation for problem paths]
    E --> B
    C -->|Yes| F[Deploy to canary instance]
    F --> G[Monitor error rates 15min]
    G --> H{Error rate < baseline?}
    H -->|No| I[Rollback immediately]
    H -->|Yes| J[Expand to 10% traffic]
    J --> K[Monitor for 2 hours]
    K --> L{Metrics stable?}
    L -->|No| I
    L -->|Yes| M[Full deployment]
    
    style I stroke:#ef4444,fill:#450a0a,color:#fca5a5
    
    classDef userAction fill:#142544,stroke:#7c9cf0,color:#eaf2ff
    classDef framework fill:#0b3b2e,stroke:#34d399,color:#d1fae5
    class B,G,K userAction
    class F,J,M framework

Start by auditing your dependency tree with npm ls --all. Look for packages that publish ESM-only entry points. Check their source code for top-level await. If you find any, you have three options: keep the transpiler for those imports, find a CommonJS alternative, or refactor to use dynamic import().

Next, test in a staging environment with production traffic patterns. Synthetic tests miss edge cases. Real user behavior exposes lazy-loaded modules that might break under require(esm). Run the staging environment for at least 48 hours before promoting to production.

In production, deploy to a single canary instance first. Monitor error logs specifically for ERR_REQUIRE_ASYNC_MODULE. This error means a dependency violated the synchronous constraint. If it appears, rollback immediately and add that dependency to your transpilation list.

For Kubernetes deployments, use a rolling update with maxSurge: 1 and maxUnavailable: 0. This keeps the old pods running until the new pods stabilize. For serverless functions, deploy to a separate alias and shift traffic gradually using weighted routing.

The distinction between staging and production testing is critical. Staging validates correctness. Production validates performance under real load. A module that works in staging might trigger memory pressure or event loop blocking in production due to synchronous resolution overhead.

The Future: Native TypeScript + ESM Without Build Steps

Node.js 24 is the first LTS release where most teams can eliminate build steps entirely. The combination of stable require(esm) and experimental native TypeScript support (via --experimental-strip-types) means you can write .ts files and run them directly. The runtime strips type annotations at parse time without invoking the TypeScript compiler.

This matters because the feedback loop collapses. Change a file, restart the server, see results immediately. No watching for transpiler output. No stale build artifacts. For teams practicing rapid iteration patterns, this is the difference between 200ms and 2s reload times.

The tradeoff is that type checking happens separately. Run tsc --noEmit in CI to catch type errors, but the runtime never sees the compiler. This separation is pragmatic — most teams want fast iteration in development and strict validation in CI. Combining both in the same tool creates conflicting priorities.

Production adoption will accelerate through 2026 as the Node.js 24 LTS cycle matures. The experimental flags will stabilize. The ecosystem will standardize on ESM as the default format. Legacy CommonJS will persist for backward compatibility, but new packages will ship ESM-first with confidence that all consumers can load them natively.

That covers the essential patterns for migrating to Node.js 24 native require(esm). Apply these in production and the difference will be immediate. Your build pipeline shrinks, deployment speed increases, and the entire toolchain becomes simpler to reason about. The era of mandatory transpilation is over for most Node.js applications.