Home › JavaScript › ERR_REQUIRE_ESM in Node? Fix It Fast
Intermediate 6 min · September 23, 2026

ERR_REQUIRE_ESM in Node? Fix It Fast

Fix ERR_REQUIRE_ESM by confirming the ESM-only contract, converting require to await import(), and scoping type per package..

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.

Follow
✓ Production
production tested
September 25, 2026
last updated
1,950
articles · all by Naren
Before you start⏱ 12 min
  • ✓Basic Node.js require and import knowledge
  • ✓Comfort running commands in a terminal
  • ✓A Node 18+ project with a package.json you control
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • require() can't load ESM-only packages, so Node throws ERR_REQUIRE_ESM — reinstalling changes nothing because the code is fine and the loader is the mismatch
  • Check the package's type field and exports map to confirm it's ESM-only before touching your code
  • Escape with await import() at the boundary call site and let async propagate one frame outward
  • Decide deliberately: pin the old major short-term with an expiry date, or migrate callers to import on a schedule
✦ Definition~90s read
What is ERR REQUIRE ESM Fix?

ERR_REQUIRE_ESM is Node's loader refusing to evaluate an ES module through require(). The full text reads Error [ERR_REQUIRE_ESM]: require() of ES Module /path/to/file.js not supported. Instead change the require to a dynamic import() which is available in all CommonJS modules.

★
Think of require and import as two languages with no shared dictionary.

It fires when the require graph reaches a file Node parses as ESM — because the nearest package.json sets type: module, the file uses .mjs, or the package's exports map routes only the import condition. The message even prescribes the fix; most developers scroll past the prescription to the stack trace.

The two loaders differ fundamentally. require() is synchronous: it must return the module value immediately, so it can't wait on ESM's asynchronous linking phase. import is asynchronous and static: the engine resolves the graph before evaluating. ESM-only packages exploit this honestly — type: module plus ESM source, or an exports map with import and no require — declaring that synchronous loading is unsupported.

Node honors the declaration with ERR_REQUIRE_ESM rather than returning a broken partial module.

This error concentrates at ecosystem boundaries because the ecosystem migrated unevenly. Flagship utilities (chalk, node-fetch, ora) went ESM-only on majors while millions of CommonJS apps still require them. Transpiled test runners mask it further: Jest with babel-jest may transform ESM in tests while production Node throws natively.

The fix is always at the call-site boundary — dynamic import(), file-extension discipline, reading the exports contract — never in node_modules. The sections ahead make each boundary explicit.

Plain-English First

Think of require and import as two languages with no shared dictionary. Your app speaks require, and yesterday's chalk spoke it too. Then chalk's authors republished the package speaking only import — same name on the cover, new language inside. Node refuses to translate on the spot and throws ERR_REQUIRE_ESM. The fix isn't shouting louder (reinstalling) or banning the book (pinning forever). It's learning one translated sentence — await import() — or switching shelves file by file.

You bump one dependency, redeploy, and Node dies on the first line: Error [ERR_REQUIRE_ESM]: require() of ES Module. You didn't change module systems. You didn't touch imports. Yet the app that booted yesterday won't start today, and the stack trace points at a require call that worked for years. Welcome to the ecosystem's slowest migration: packages going ESM-only underneath CommonJS apps.

The reflex is to downgrade the package and move on. That works for a week — until a security advisory lands on the pinned version, or the next dependency makes the same jump, and you're pinning history instead of shipping. The other reflex, flipping type: module across the project, trades one error for twenty. Both reactions skip the actual question: which module system does each file live in, and where's the narrowest bridge between them?

This guide answers that question precisely. You'll learn why require can't load ESM-only packages, how type: module and file extensions divide your code, and how dynamic import() lets CommonJS call into ESM without a rewrite. You'll read exports maps like contracts and make the downgrade-vs-migrate call with dates and owners instead of vibes. By the end, ERR_REQUIRE_ESM becomes a fifteen-minute conversion, not a migration project.

Why require() of an ESM-Only Package Throws

Node runs two module loaders side by side: CommonJS (require, synchronous, dynamic) and ESM (import, asynchronous, static). require() can only parse CommonJS syntax — module.exports and free-floating require calls. When the target file contains import or export statements, the CommonJS loader can't evaluate it, so Node throws ERR_REQUIRE_ESM instead of producing a half-loaded module. The error is a refusal to guess, and that's good: silent interop would be worse.

Packages go ESM-only by changing their contract, not their name. The author sets type: module, ships only ESM source, or publishes an exports map with an import entry and no require entry. Chalk 5, node-fetch 3, and pretty-ms 8 all made this jump on major versions. Your require call didn't break — the package it points at changed languages underneath it. That's why the stack trace shows your innocent require line: the call site is where the mismatch surfaces, not where the decision was made.

Confirm before you fix. The error code ERR_REQUIRE_ESM is definitive — grep the log for it rather than matching message prose across Node versions. Then read the package contract: its type field and exports map tell you whether require is blocked by declaration or by syntax. This thirty-second check separates loader mismatches (convert the call site) from genuinely missing packages (install them). Developers who skip it reinstall node_modules twice and learn nothing.

BASH
1
2
3
4
5
6
node -e "try { require('chalk') } catch (e) { console.error(e.code) }"
// ERR_REQUIRE_ESM
node -p "require('./node_modules/chalk/package.json').type"
// module
node -e "import('chalk').then((m) => console.log(typeof m.default))"
// function
📊 Production Insight
A team reinstalled node_modules three times for an ERR_REQUIRE_ESM before reading the code field. The thirty-second contract check now opens their runbook for this error.
🎯 Key Takeaway
Two loaders, no on-the-spot translation. Confirm ERR_REQUIRE_ESM plus the package contract, then convert the call site.

type: module vs .mjs/.cjs: Know What Each File Is

The type field in package.json decides what .js means for the whole package: commonjs (the default) parses .js with the require loader, module parses it with the import loader. There is no per-file detection — Node doesn't sniff syntax. So flipping type to silence one import error instantly converts every .js file's loader, and each remaining require becomes the next crash. The blast radius is the entire package, which is why the flip feels like whack-a-mole.

Extensions override the package default per file, and they're the right tool at mixed boundaries. .mjs always loads as ESM, .cjs always loads as CommonJS, no matter what type says. A CommonJS app adopting one ESM helper names it helper.mjs; an ESM app keeping one require-based script names it legacy.cjs. Explicit extensions document the file's loader in its name, surviving refactors that move files between packages with different types.

Standardize one system per directory and convert at package granularity. If src/ is CommonJS, keep it require plus .cjs helpers and bridge ESM-only deps with import(). When you're ready to migrate, convert a whole package to type: module in one commit — renaming stragglers to .cjs — instead of flipping the flag and chasing errors for a week. CI should assert the invariant: grep for require( in ESM packages and for extension-less relative imports, and fail the build when either appears.

package.jsonBASH
1
2
3
4
5
6
7
// package.json — type applies to every .js file in this package
{
  "type": "module" // import syntax; require() of .js now fails
}
// mixed boundary: explicit extensions beat package-wide flips
// logger.cjs  — require() stays valid here regardless of type
// config.mjs  — import always valid here regardless of type
📊 Production Insight
A type: module flip broke fourteen requires across six files. Renaming two legacy helpers to .cjs fixed eleven of them — the remaining three were genuine ESM conversions.
🎯 Key Takeaway
type sets the default for all .js files; .mjs/.cjs pin single files. Migrate per package, never by flag-flip.

The Dynamic import() Escape Without a Rewrite

Dynamic import() is the sanctioned bridge: callable from CommonJS, returning a promise for the module namespace. The minimal change keeps your file CommonJS and converts only the call site — wrap it in an async function, await the import, and read .default for the default export. One frame goes async; everything below it stays synchronous. This is a fifteen-minute fix per call site, not a migration project.

Mind the shape of what comes back. ESM default exports arrive on namespace.default, named exports as sibling properties — const { default: chalk } = await import('chalk') handles the common case. Top-level await works in ESM files but not in CommonJS ones, so the await must live inside an async function when the caller is require-based. If the call site truly can't go async — a synchronous plugin hook, a config evaluated at load — hoist the import to startup: await it once in your async main and pass the loaded module down.

Let async propagate deliberately instead of fighting it. Convert the boundary function, update its callers to await, and stop where the framework handles promises for you — Express 5 and most CLIs accept async handlers natively. What you must not do is block the event loop waiting for the promise or shell out to a child process to dodge async. The loader is telling you the dependency is asynchronous now; align with it one frame at a time and the conversion completes itself.

lib/colors.jsJAVASCRIPT
1
2
3
4
5
6
7
// lib/colors.js — minimal bridge: async boundary, sync core untouched
async function paint(text) {
  const { default: chalk } = await import('chalk');
  return chalk.green(text);
}

paint('deploys green').then((s) => console.log(s));
Try it live
📊 Production Insight
A CLI converted three require calls to await import() in one afternoon. The async boundary stopped at main(), which was already async — total diff was nine lines.
🎯 Key Takeaway
await import() at the boundary, read .default, and let async propagate one frame. Never block the loop to stay sync.

Dual-Package Exports Maps: Read the Contract

The exports field turns package.json into a contract: only listed subpaths resolve, per loader condition. A package can expose import while omitting require, which makes it ESM-only by declaration even if some file inside looks requireable. Deep paths like pkg/dist/internal.js fail unless explicitly exported — the file exists on disk but doesn't exist for importers. When a minor version adds an exports map, previously working deep requires break overnight with no code change on your side.

Debug entry versus depth separately. Test the documented entry first — import('pkg') — and expect success. Then test the deep path you actually use. Entry-ok plus deep-fail proves an allowlist block, and the fix is switching to the entry, not pinning the version. Read the map with node -p on the installed package.json; the import, require, and default conditions show exactly which loader may reach each path. Library authors reorganize internals freely behind entries, so entries are the only stable surface.

Protect yourself at upgrade time. Grep for deep imports of the bumped package — require('pkg/dist or from 'pkg/lib — and replace each with the documented entry before merging the bump. After upgrading, run the test suite under native Node, not just the transpiled runner, so loader blocks surface in CI. Teams that treat exports as contracts review the map diff on every major bump the way they'd review a REST schema change.

BASH
1
2
3
4
// chalk's package.json (v5) — import entry only, no require entry
// { "type": "module", "exports": { ".": { "import": "./source/index.js" } } }
node -p "JSON.stringify(require('./node_modules/chalk/package.json').exports)"
node --input-type=module -e "import('chalk').then(() => console.log('entry ok'))"
⚠ Unexported Paths Don't Exist
The exports map is an allowlist, not documentation. If a subpath isn't listed, it doesn't exist for importers — even when the file sits on disk. Upgrade deep imports to entries before the next minor moves the file.
📊 Production Insight
A deep require of dist/format.js survived two years until a minor added an exports map. Switching to the documented entry took ten minutes; the incident review took longer than the fix.
🎯 Key Takeaway
exports is an allowlist per loader. Use documented entries; test deep paths natively after every bump.

Dual-Package Files Without Double Maintenance

Dual-package setups serve both loaders during a migration: ESM consumers import the .mjs entry while CommonJS consumers require the .cjs entry, sometimes from the same package. The pattern works when each entry is honest — .cjs files use only require syntax, .mjs files use only import syntax — and the exports map routes each condition to its file. Used deliberately, it lets a library migrate without stranding half its users. Used accidentally, it doubles every bug across two copies of the code.

For application code, prefer one system per package and reserve dual files for true boundaries: a legacy plugin that must stay requireable, a shared config consumed by both loaders. Name the files explicitly so the loader is visible — config.cjs next to config.mjs beats two config.js files in different folders. Keep the .cjs copy free of ESM syntax even in comments-turned-code, because one import statement flips its parse and the failure looks exactly like the error you just fixed.

Don't let dual packaging become permanent architecture. Every duplicated module is a drift risk: a fix lands in the .mjs copy and the .cjs copy serves stale logic until someone diffs them. Attach an owner and a deletion date to every dual file, and track the remaining count as migration debt on the team board. The healthy trajectory is dual files shrinking to zero as callers convert — the map should route everything to one entry within a quarter or two.

lib/legacy.cjsJAVASCRIPT
1
2
3
4
5
6
7
8
// .cjs helper keeps require() valid inside an ESM-leaning repo
// lib/legacy.cjs
module.exports = { retries: 3 };

// ESM side consumes it via default interop
// src/main.mjs
import cfg from '../lib/legacy.cjs';
console.log(cfg.retries);
Try it live
📊 Production Insight
A shared config shipped as dual .mjs/.cjs for a quarter. The deletion-date label got it removed on schedule; without the label it would still be duplicated today.
🎯 Key Takeaway
Serve both loaders with explicit .mjs/.cjs entries only at true boundaries. Track each dual file with an owner and deletion date.

Downgrade vs Migrate: Decide with Dates, Not Vibes

Downgrade-versus-migrate is a business decision wearing a technical disguise, so make it with numbers. Downgrading to the last CommonJS major — npm install -S chalk@4 — restores boot in minutes and buys time. It also freezes you out of security fixes and features on the new major, and the pin rots: six months later nobody remembers why 4.1.2 is sacred, and bumping it replays the incident. Migrating callers to import() costs hours now and ends the error class for that dependency forever.

Audit blast radius before choosing. npm ls shows whether one file or fifteen depend on the package, directly or transitively. A single direct import means migrate today — the nine-line diff from this guide's bridge pattern. A dozen transitive dependents means pin short-term while the ecosystem catches up, because you can't convert code you don't own. Check the advisories too: a pinned major with open CVEs is a clock ticking, not a solution.

Whatever you choose, write it down with an expiry. A pin without a calendar reminder and an owner is a decision to never migrate. Record the pin in package.json, the reason in a code comment, and the revisit date in the tracker: pinned chalk@4 until Q1, owner Ana, migrate when deps X and Y support ESM. Teams that date their pins migrate on schedule; teams that don't discover their pins during the next security incident.

BASH
1
2
3
npm ls chalk # who pulls it, and at which major?
npm view chalk versions --json | tail -5 # timeline of majors
npm install -S chalk@4.1.2 # short-term pin, with expiry note
📊 Production Insight
A team pinned chalk@4 with a Q1 revisit ticket. When the ticket fired, two transitive deps had gone ESM-compatible and migration took an afternoon — planned work, not an incident.
🎯 Key Takeaway
Migrate single direct imports now; pin transitive tangles short-term with an owner and expiry date.
● Production incidentPOST-MORTEMseverity: high

A Chalk Bump Killed Every Container: ERR_REQUIRE_ESM Blocked Deploys for a Day

Symptom
Tuesday's deploy failed on all eight API containers with Error [ERR_REQUIRE_ESM]: require() of ES Module /app/node_modules/chalk/source/index.js. Each pod crashed 300ms after start, never passing health checks, while every developer laptop booted the same commit cleanly. Rollback restored traffic in 6 minutes, but the auto-merge bot re-applied the bump on the next run and blocked the release train twice more.
Assumption
The team assumed the registry had served a corrupt tarball because only the container build failed while laptops worked — a stale npx cache story from months earlier. They cleared caches, republished the image, and bumped the lockfile twice. Laptops kept working because their node_modules still held chalk 4 from before the bump; only fresh npm ci installs fetched chalk 5. The lockfile diff showing 4.1.2 to 5.3.0 sat unreviewed in the dependency-update pull request.
Root cause
The weekly dependency bot bumped chalk 4.1.2 to 5.3.0, and chalk 5 ships ESM-only: its package.json sets type: module and its exports map exposes no require entry. The app's lib/colors.js used const chalk = require('chalk'), which worked for years and died instantly on the new major. Local dev machines kept booting because node_modules still contained chalk 4; Docker builds running npm ci fetched chalk 5 and crashed during startup before health checks could pass.
Fix
The require call in lib/colors.js became const chalk = (await import('chalk')).default with the function made async, a two-line diff. The team added an engines and dependency-review step that flags major bumps of known ESM-only packages, plus a Docker smoke test that boots the image and exercises the formatted output path. The update bot now labels ESM-only majors separately so they get a human review.
Key lesson
  • Fresh installs and warm laptops resolve different trees — CI must install from the lockfile and boot-test the result, or version jumps hide until deploy day.
  • Dependency-update pull requests need major-bump scrutiny, not auto-merge. A one-line version change can carry a module-system migration with it.
  • Smoke-test the real image, not just the unit suite. Transpiled tests can mask loader errors that only native Node in the container will throw.
Production debug guideFive patterns cover nearly every instance of this error — identify yours with these exact commands before changing anything.5 entries
Symptom · 01
Error names a package right after a version bump with code ERR_REQUIRE_ESM
→
Fix
Confirm the loader mismatch: run node -e "try { require('chalk') } catch (e) { console.error(e.code) }" and expect ERR_REQUIRE_ESM. Then inspect the contract with node -p "require('./node_modules/chalk/package.json').type" and node -p "JSON.stringify(require('./node_modules/chalk/package.json').exports, null, 2)". If type is module or exports lacks a require entry, the package is ESM-only and no reinstall will help.
Symptom · 02
Flipping type: module fixed one import and broke ten requires
→
Fix
Find which files changed meaning: run node --check src/index.js on the entry and grep -rn "require(" src | head -20 to list CommonJS call sites. If package.json gained type: module in the bump, every .js file flipped to ESM at once. Scope the fix per package and rename boundary helpers to .cjs where require must survive.
Symptom · 03
One require call deep in CommonJS code loads an ESM-only package
→
Fix
Convert at the narrowest boundary: change const chalk = require('chalk') to const chalk = (await import('chalk')).default inside the enclosing async function. Verify with node -e "import('chalk').then(m => console.log(typeof m.default))" expecting function. Let async propagate one frame outward rather than rewriting the file.
Symptom · 04
Deep require of dist/ internals fails while the entry import works
→
Fix
Test the entry versus the deep path: run node --input-type=module -e "import('pkg').then(() => console.log('entry ok'))" and node -e "require('pkg/dist/internal.js')" to compare. If the entry works and the deep path fails, the exports map blocks internals. Switch to the documented entry and grep -rn "require('pkg/dist" src to find every trespass.
Symptom · 05
Unsure whether to downgrade the package or migrate the code
→
Fix
Audit blast radius before choosing: run npm ls chalk to see which dependents pull it, then npm view chalk versions --json | tail -5 for the timeline. If only your code imports it directly, migrate now. If five transitive deps need the old major, pin with "chalk": "^4.1.2" plus a calendar reminder and an owner, not silence.
ERR_REQUIRE_ESM — Causes Compared
Root CauseHow to ConfirmFixPrevention
require() of ESM-only packageError code ERR_REQUIRE_ESM names packageConvert call site to await import()Lint for require of ESM-only deps
type: module flipping file meaningAll .js files parsed as ESMScope type per package; use .mjs/.cjsOne module system per directory
Tooling stuck on requireJest/Vitest throws on ESM transformAwait import() or ESM-native runnerESM-ready test config in CI
Dual-package exports map blocks pathDeep require fails; entry worksImport documented entry pointTest deep paths after upgrades
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
node -e "try { require('chalk') } catch (e) { console.error(e.code) }"Why require() of an ESM-Only Package Throws
package.json{type
libcolors.jsasync function paint(text) {The Dynamic import() Escape Without a Rewrite
node -p "JSON.stringify(require('./node_modules/chalk/package.json').exports)"Dual-Package Exports Maps
liblegacy.cjsmodule.exports = { retries: 3 };Dual-Package Files Without Double Maintenance
npm ls chalk # who pulls it, and at which major?Downgrade vs Migrate

Key takeaways

1
ERR_REQUIRE_ESM means require met an ESM-only package
convert the call site, don't reinstall.
2
type
module flips every .js file; scope it per package and use .mjs/.cjs at boundaries.
3
await import() bridges CommonJS to ESM without rewriting the file.
4
Read the exports map
it decides which entry points each loader may use.
5
Deep-requiring dist/ internals breaks on minor bumps; use documented entries.
6
Pin short-term with expiry dates; migrate on a schedule, not on hope.

Common mistakes to avoid

5 patterns
×

Reinstalling the package to fix a loader error

Symptom
Fresh node_modules, identical ERR_REQUIRE_ESM on the next boot, plus a mutated lockfile.
Fix
Check the error code and the package's package.json first. ERR_REQUIRE_ESM means a loader mismatch — diagnose the module system, then convert the call site.
×

Wrapping require in try/catch and falling back to a stale copy

Symptom
App boots with outdated vendored code while the real dependency updates silently pass by.
Fix
Use await import() in an async context at the boundary. Convert one call site and let the async flow outward.
×

Setting type: module to fix one import and breaking all requires

Symptom
ERR_REQUIRE_ESM disappears and ten new require-is-not-defined errors take its place.
Fix
Rename helpers to .cjs or set type: commonjs in that package. Keep require() code in CommonJS files.
×

Deep-requiring dist/ files to dodge the exports map

Symptom
Works until a minor bump moves the internal file, then fails with ERR_MODULE_NOT_FOUND.
Fix
Import the documented entry point from the exports map. Stop reaching into dist/ internals.
×

Pinning an ancient version forever instead of deciding

Symptom
Security advisories pile up on the pinned major while the migration nobody scheduled never happens.
Fix
Read the exports map deliberately, upgrade callers to import, and decide downgrade-vs-migrate with dates attached.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why does require('chalk') throw ERR_REQUIRE_ESM in v5+?
Q02SENIOR
What breaks when you set type: module to fix one error?
Q03SENIOR
How does dynamic import() escape the require trap?
Q04SENIOR
A package has index.js on disk but require fails. What do you check?
Q05SENIOR
When is downgrading the right call versus migrating?
Q01 of 05JUNIOR

Why does require('chalk') throw ERR_REQUIRE_ESM in v5+?

ANSWER
Require is synchronous and resolves through the CommonJS loader; ESM-only packages ship only ESM syntax, which require can't parse. Node throws ERR_REQUIRE_ESM instead of guessing. The fix is await import().
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Does dynamic import() duplicate the module instance?
02
Do I need file extensions in import specifiers now?
03
How do I know if my other dependencies will go ESM-only?
04
Can I still require() JSON files?
05
What if my call site can't be async?
06
Is createRequire a permanent solution?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.

Follow
✓ Verified
production tested
September 25, 2026
last updated
1,950
articles · all by Naren
🔥

That's Node.js. Mark it forged?

6 min read · try the examples if you haven't

←
Previous
Circular Structure JSON Fix
31 / 31 · Node.js