Sometimes you ship a CommonJS codebase - require(), older bundler defaults, or tooling that never moved to native ESM. Then you need a dependency that is ESM-only (often marked "type": "module" or exports that resolve only to .mjs). Migrating the whole project is the right long-term fix, but deadlines and risk often say no.
This post shows a small pattern: keep the project on CJS, but load one ESM module with a real dynamic import() at runtime, while avoiding the usual pitfall - your transpiler or bundler rewriting import() into require() and blowing up on an ESM-only package.
Taken as example here is p-queue library which switched to ESM-only in V9.
You might try:
const mod = await import("p-queue");
In many setups (Babel, older TypeScript targets, some bundlers), that import() is not left alone. It becomes something like require() or a wrapped call that assumes the dependency can load like CommonJS. For an ESM-only package, you get errors along the lines of module format mismatch or missing interop.
You need Node to execute a native dynamic import of the package string, not a build-time transform of it.
If the tool only sees a string, it will not rewrite it into require(). At runtime, Node evaluates that string and performs a true ESM import().
Below, a module exports:
PQueueReady promise - await this before using the constructor.PQueue binding - set once the async load finishes.// Mutable variable that will hold the needed constructor
// It starts as undefined, until the async initialization completes and assigns the value to it accordingly
export let PQueue;
// Promise that resolves when the needed constructor is ready
// Awaiting this in main entrypoint guarantees availability
export const PQueueLoaded = (async function () {
// Store the dynamic import statement as a string
// Key trick in which transpiler only sees a string literal, not an import
// Hence, it won't transpile it to require(), causing the exception
const importStatement = 'import("p-queue")';
// "eval()" executes the string at runtime, bypassing transpilation
// At runtime, Node.js sees the actual string literal
// It then performs a true ESM dynamic import
const hackedImport = await eval(importStatement);
// Extract the "default" export from the dynamically imported module
// Assign it to the exported variable, finally giving it the value
PQueue = hackedImport.default;
})();
Usage sketch (pseudo-CJS): load this wrapper from your entry, await the ready promise, then use PQueue like a normal constructor. Exact shape depends on whether you use import or require for the wrapper file itself.
import(...) may rewrite it. A string literal passed to eval often is not treated as an import site.eval('import("p-queue")') still returns a Promise for the module namespace in modern Node, same as dynamic import().So you get a genuine ESM load without changing the whole project to ESM.
eval - disallowed or flagged in strict security reviews and some lint configs. This is a deliberate escape hatch, not a style choice.eval; if something strips it, the trick stops working. Test your real build pipeline.default; named exports need hackedImport.namedExport instead.Use this pattern when you must consume an ESM-only package from a CommonJS-heavy stack right now, and changing the whole project to ESM is off the table. Treat it as technical debt: file an issue to migrate to native ESM or to a supported dual-package setup, then remove the eval path when you can.
For greenfield work, prefer aligning "type": "module" in package.json, or a single coherent module system, over carrying this workaround forever.