When the library is ESM-only and the app is still CommonJS

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.

What usually goes wrong

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.


The idea - hide the import from static transforms

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:

  • A PQueueReady promise - await this before using the constructor.
  • A mutable 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.

Why this works (short version)

  • Static analysis - tools that scan for import(...) may rewrite it. A string literal passed to eval often is not treated as an import site.
  • Runtime - 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.

Trade-offs you should not ignore

  • eval - disallowed or flagged in strict security reviews and some lint configs. This is a deliberate escape hatch, not a style choice.
  • Packaging - some bundlers may still try to bundle or stub eval; if something strips it, the trick stops working. Test your real build pipeline.
  • Maintenance - hiding imports from the toolchain also hides them from tree-shaking and static checks. Prefer migration or an official CJS build of the library when you can.
  • API surface - the example uses default; named exports need hackedImport.namedExport instead.

Outro

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.

Previous Post