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..
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
- ✓Basic Node.js require and import knowledge
- ✓Comfort running commands in a terminal
- ✓A Node 18+ project with a package.json you control
- 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
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.
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.
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.
import() in one afternoon. The async boundary stopped at main(), which was already async — total diff was nine lines.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.
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.
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.
A Chalk Bump Killed Every Container: ERR_REQUIRE_ESM Blocked Deploys for a Day
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.- 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.
| File | Command / Code | Purpose |
|---|---|---|
| node -e "try { require('chalk') } catch (e) { console.error(e.code) }" | Why require() of an ESM-Only Package Throws | |
| package.json | { | type |
| lib | async 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 | |
| lib | module.exports = { retries: 3 }; | Dual-Package Files Without Double Maintenance |
| npm ls chalk # who pulls it, and at which major? | Downgrade vs Migrate |
Key takeaways
import() bridges CommonJS to ESM without rewriting the file.Common mistakes to avoid
5 patternsReinstalling the package to fix a loader error
Wrapping require in try/catch and falling back to a stale copy
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
require() code in CommonJS files.Deep-requiring dist/ files to dodge the exports map
Pinning an ancient version forever instead of deciding
Interview Questions on This Topic
Why does require('chalk') throw ERR_REQUIRE_ESM in v5+?
import().Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
That's Node.js. Mark it forged?
6 min read · try the examples if you haven't