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 →