Skip to main content

The ESM vs CJS Adoption Gap Across npm

·PkgPulse Team

TL;DR

The ESM transition is mostly complete at the top of npm but still stalled in the long tail. 95% of new packages published in 2025 support ESM. But 40% of the top 100 packages still ship CommonJS (either CJS-only or dual CJS+ESM). The stickiness: Node.js CJS interop requirements, legacy code that can't use dynamic import(), and the infamous "ESM-only" migration pain (chalk v5, node-fetch v3, etc.). By 2027, the transition will be essentially complete for actively maintained packages.

Key Takeaways

  • New packages (2024-2026): 95% ship ESM — it's the default now
  • Existing top 100: ~40% still CJS or dual — migration takes time
  • ESM-only packages cause pain — chalk v5, node-fetch v3 broke CJS projects
  • Dual packages (CJS + ESM) are the pragmatic middle ground
  • type: "module" in package.json — the signal that a package is ESM-first

The State of ESM vs CJS (2026)

ESM adoption across npm:

New packages (published 2024-2026):
→ ESM-only: 55%
→ Dual CJS+ESM: 35%
→ CJS-only: 10%

Top 100 packages by downloads:
→ Dual CJS+ESM: 50%
→ ESM-only: 25%
→ CJS-only: 25%

Top 1000 packages:
→ Dual CJS+ESM: 42%
→ CJS-only: 38%
→ ESM-only: 20%

All packages:
→ CJS-only: 55% (majority still CJS)
→ Dual CJS+ESM: 30%
→ ESM-only: 15%

Trend: ESM-only is growing, CJS-only is shrinking
Timeline: Full ESM dominance expected by 2028

How to Check What a Package Ships

# Check package type field:
npm view package-name --json | jq '.type, .exports, .main, .module'

# type: "module" → the package is ESM-first
# type: undefined or "commonjs" → CJS or depends on file extension

# Exports field tells the full story:
npm view vite --json | jq '.exports'
# Should show something like:
# {
#   ".": {
#     "types": "./dist/node/index.d.ts",
#     "require": "./dist/node/index.cjs",  ← CJS build
#     "import": "./dist/node/index.js"     ← ESM build
#   }
# }

# ESM-only package (no "require" field):
# {
#   ".": {
#     "types": "./dist/index.d.ts",
#     "import": "./dist/index.js"   ← only ESM
#   }
# }

# Check locally:
cat node_modules/package-name/package.json | jq '.type, .exports'

The ESM-Only Migration Pain

// The chalk v5 incident: ESM-only, broke everything
// Before chalk v5:
const chalk = require('chalk');  // CJS, worked everywhere

// chalk v5+:
import chalk from 'chalk';  // ESM only
// If your project is CJS: BREAKS

// Error you'd see:
// Error [ERR_REQUIRE_ESM]: require() of ES Module not supported.
// Change require() of xxx.js to a dynamic import() which is available
// in all CommonJS modules.

// Workaround in CJS:
const chalk = await import('chalk');  // Dynamic import in async context

// But this doesn't work at the top level in CJS without async:
// ❌ const chalk = await import('chalk');  // SyntaxError in CJS

// The real fix: migrate your project to ESM
// Or: pin to the last CJS version
// "chalk": "4"  ← pin to v4 (last CJS version)

// Same pattern happened with:
// node-fetch v3: ESM-only
// got v12: ESM-only
// Various UnJS packages: ESM-first

CJS vs ESM: The Technical Difference

// CommonJS (CJS):
const express = require('express');
const { readFile } = require('fs');
module.exports = { myFunction };

// ESM:
import express from 'express';
import { readFile } from 'fs';
export function myFunction() {}

// The fundamental difference:
// CJS: synchronous, evaluated at runtime
// ESM: static, parsed before execution, enables tree-shaking

// Why ESM enables tree-shaking:
// CJS: bundler can't know what you'll use at parse time
// ESM: static imports are analyzed → dead code eliminated

// Why CJS persists:
// 1. Huge existing codebase (15+ years of Node.js history)
// 2. require() is synchronous: simpler for many patterns
// 3. Dynamic require() patterns: require(`./plugins/${name}`)
// 4. Conditional requires in middleware/plugins
// 5. Some environments (old Node.js, some serverless) need CJS

// Node.js 22: interop improved
// You can require() an ESM module in Node.js 22+ (experimental flag)
// But not all environments have caught up yet

Migrating Your Package to ESM

// Step 1: Add "type": "module" to package.json
{
  "name": "my-package",
  "type": "module",
  "main": "./dist/index.js",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  }
}
// Step 2: Update source files
// Change all require() to import
import { readFile } from 'fs/promises';

// Change module.exports to export
export function myFunction() {}
export default class MyClass {}

// Step 3: Update __dirname and __filename (not available in ESM)
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// Or use import.meta.url directly:
const configPath = new URL('./config.json', import.meta.url);
const config = JSON.parse(await readFile(configPath, 'utf8'));

// Step 4: Dynamic imports for CJS dependencies you can't replace
const { default: chalk } = await import('chalk');

The Dual Package Approach

// Most pragmatic: ship both CJS and ESM
// Consumers get what their environment needs

{
  "name": "my-package",
  "exports": {
    ".": {
      "require": "./dist/index.cjs",  // CJS: require() works
      "import": "./dist/index.mjs",   // ESM: import works
      "types": "./dist/index.d.ts"
    }
  }
}
// Build tools that generate dual packages:
// tsup: most popular
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
  entry: ['src/index.ts'],
  format: ['cjs', 'esm'],  // Builds both
  dts: true,
  clean: true,
});

// pkgroll: rollup-based
// unbuild: nuxt ecosystem
// Both generate the dual output from a single source

ESM in Node.js: Current State

# Node.js ESM support by version:
# Node.js 12: ESM experimental (don't use)
# Node.js 14: ESM stable (basic support)
# Node.js 16: ESM well-supported
# Node.js 18 LTS: ESM recommended for new packages
# Node.js 20 LTS: ESM mature
# Node.js 22 LTS: require(ESM) supported behind flag (--experimental-require-module)

# Current recommendation:
# New packages: ESM-first with CJS compatibility via dual package
# New apps on Node 18+: can use ESM-only packages
# Apps on older Node: stick to dual packages, avoid ESM-only libraries

# Browser + Edge environments:
# All support ESM natively
# No CJS in browser — bundlers translate CJS to ESM for you

# The convergence:
# Node.js 22+: ESM-only packages importable via require() (experimental)
# This will remove the CJS interop pain when widely adopted
# Timeline: Node.js 24+ likely to make this stable

Compare package formats and ecosystem health at PkgPulse.

See the live comparison

View bun vs. node on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.