How to Ship ESM-Only Packages Without Breaking Your Users

This guide belongs to Modern Module Formats: ESM vs CommonJS, within JavaScript Bundle Optimization & Code Splitting. For years, library authors published both CommonJS and ES module builds — "dual packages" — to serve both Node's require and bundlers' import. That doubled build output, invited the dual package hazard (two copies of a module loaded at once), and kept CommonJS around long after browsers and bundlers moved on.

The ecosystem has shifted. Every modern bundler consumes ESM natively and tree-shakes it far better than CommonJS. Node supports ESM fully, and recent Node versions can require() synchronous ES modules, removing the biggest historic blocker. For many packages — especially frontend libraries consumed through bundlers — shipping ESM only is now the simpler and faster choice.

Dual package vs ESM-only Comparison of publishing a library as both CommonJS and ESM with publishing ESM only. Dual package vs ESM-only Dual (CJS + ESM) • Two builds, two sets of files • Conditional exports for import/require • Risk: both copies loaded (hazard) • CJS path tree-shakes poorly ESM-only • One build, one set of files • Simple exports map • No dual-instance risk • Bundlers tree-shake every consumer

Rapid Diagnosis

  • Check who consumes the package. Frontend apps through bundlers: ESM-only is straightforward. Node tooling, Jest without ESM support, or old Electron apps: check compatibility first.
  • Look at your exports map. Separate import and require conditions pointing at different files indicate a dual package.
  • Search consumer bundles for both builds. If both dist/index.cjs and dist/index.mjs of your package appear in an app's bundle, the dual package hazard is live.
  • Check Node version support policy. Node 20.19+/22.12+ can require() ES modules without top-level await; older Node versions cannot.

Root Cause Analysis

1. CommonJS limits static analysis. require and module.exports are dynamic; bundlers must keep whole modules, so consumers ship more code.

2. Dual builds create two module instances. When some code paths import and others require the same package, two copies load — doubling bytes and breaking singletons (contexts, registries, instanceof checks).

3. Interop wrappers add weight. Bundlers wrap CommonJS modules in interop helpers to emulate ESM semantics, adding bytes and runtime cost.

4. Maintenance burden. Two outputs, two sets of types and conditional exports mean more ways to publish a broken package.

Consumer bundle impact of one package (importing 3 functions) Bar chart of bytes a consuming app ships from one utility package when consumed as CommonJS, dual (hazard) and ESM-only. Consumer bundle impact of one package (importing 3 functions) CommonJS build 46KB Dual package, both loaded 58KB ESM-only 7KB

Step-by-Step Resolution

1. Declare the package as ESM with a clean exports map

json
{
  "name": "@acme/utils",
  "type": "module",
  "exports": {
    ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
    "./format": { "types": "./dist/format.d.ts", "default": "./dist/format.js" }
  },
  "sideEffects": false,
  "engines": { "node": ">=20.19" }
}

The engines field documents the minimum Node version able to require() the package; bundler consumers are unaffected. Trade-off: consumers on older Node who require() the package will get an error and must upgrade or switch to import.

Expected outcome: one module format, explicit public entry points, and accurate side-effect information.

2. Avoid top-level await in public modules

Node's require(esm) works only for modules without top-level await. Keep TLA out of anything a CommonJS consumer might load.

javascript
// Avoid in library entry points:
// const config = await loadConfig();
// Prefer an explicit async initialiser:
export async function init(options) { /* ... */ }
// trade-off: callers must call init() before use. Document it, and throw a
// clear error from functions called before initialisation.

Expected outcome: the package is loadable from both import and modern require.

3. Release it as a major version with a migration note

Removing CommonJS is a breaking change for some consumers. Publish a major version, document the minimum Node version, and keep the last dual release maintained for critical fixes for a period.

4. Verify in real consumers

Test the package from a Vite app, a webpack app, a Next.js app and a Node script using both import and require. Check that each bundle contains only the ESM files and only the imported functions.

bash
# Quick Node compatibility check for require(esm).
node -e "const u = require('@acme/utils'); console.log(typeof u.format)"
# trade-off: this only proves the entry loads; run your consumer test suites
# against the new version before announcing the release.

ESM-only migration checklist Four steps to move a package from dual publishing to ESM-only safely. ESM-only migration checklist Set type: module and a single exports map types + default conditions, sideEffects declared Remove top-level await from entry points Keeps require(esm) working in modern Node Publish as a new major with engines.node Migration note for CommonJS-only consumers Verify in bundlers and Node Vite, webpack, Next.js, node import and require 1 2 3 4

Verification

In consumer applications, check bundle analysis: the package's files should appear once, with only the imported functions. Confirm no __esModule interop wrappers for your package. Run the consumers' tests. For Node consumers, confirm both import and require work on the supported Node versions.

Worked Example: A Shared Component Utilities Package

An internal utilities package published dist/cjs and dist/esm. A Next.js app imported it from client components (ESM path) while a server-side helper used require (CJS path); both copies ended up in the client bundle through a shared module, adding 51KB and breaking a React context because the provider and consumer came from different copies. The team released an ESM-only major with a single exports entry, removed a top-level await from a config loader, and set engines.node to >=20.19. Bundle size for consumers dropped by the full duplicate plus 9KB of interop helpers, and the context bug disappeared.

Common Mistakes

  • Forgetting "type": "module". .js files are then treated as CommonJS by Node, and imports fail.
  • Leaving main pointing at a removed CJS file. Old tooling reads main; point it at the ESM entry or remove it.
  • Publishing TypeScript declarations without types conditions. Consumers using moduleResolution: "bundler" or "node16" need them in exports.
  • Using __dirname and require in ESM source. Replace with import.meta.url and import.

Edge Cases

Jest and other CommonJS-first tools. Older Jest setups transform test code to CommonJS and may fail to load ESM-only dependencies without configuration (transformIgnorePatterns or native ESM mode). Consumers using such tools may need to update their test configuration; mention it in the migration note.

Electron and older Node runtimes. Applications pinned to older Node versions cannot require() ESM; they must use dynamic import(), which is asynchronous. Decide whether those consumers are in scope.

JSON imports. ESM requires import attributes for JSON (import data from './data.json' with { type: 'json' }); bundlers handle it, but Node consumers need a supporting version. Prefer exporting data from a .js module.

CDN consumption. ESM-only packages work well from ESM CDNs (esm.sh, jsDelivr's ESM endpoints) and with import maps, as described in using import maps for unbundled production.

FAQ

Is ESM-only right for every package?

For browser-focused libraries consumed through bundlers, almost always. For CLI tools and server libraries with many CommonJS consumers on older Node versions, a transition period with dual publishing may still be kinder. The trend is clearly towards ESM-only.

Does ESM-only improve runtime performance, or only bundle size?

Mostly bundle size and correctness. Smaller bundles mean less parse and compile work, which does improve startup in consuming apps. The module format itself has little runtime cost difference once bundled.

What is the dual package hazard exactly?

It is the situation where both the CommonJS and ESM builds of one package load in the same program, creating two instances of its module state. Singletons, caches and instanceof checks break, and bytes double. Fixing dual package hazard in a library covers mitigation if you must stay dual.

Should I bundle my ESM output into one file?

For libraries, prefer preserving modules so consumers' bundlers can drop unused files; see building tree-shakeable libraries with Vite library mode.

Do I still need a CommonJS build for TypeScript users?

No. TypeScript resolves ESM packages through the exports map with moduleResolution set to bundler, node16 or nodenext. Projects still on the legacy node resolution mode may fail to find types; the fix on their side is updating moduleResolution, which is worth stating in your release notes.