Why Your CommonJS Code Makes People Cringe

Your Code is Embarrassing and Here's Why

Node.js Logo

If you're still writing const express = require('express') in 2025, your codebase reeks of 2018 and every developer who looks at it is quietly planning their exit strategy. ESM has been bulletproof since Node.js 12 - that's five fucking years of avoiding the obvious. The 2024 State of JS survey shows 78% of developers prefer ESM, and GitHub's package data proves new packages ship ESM-first because nobody wants to support your legacy bullshit anymore.

I learned this the hard way when we updated to Node.js 18 and some random middleware broke because it depended on a CommonJS-only package where the maintainer just disappeared. Spent most of a Saturday hunting for a replacement while production logs kept screaming.

The real problem isn't migrating - it's the weeks you'll piss away when your team inevitably decides to "modernize" and finds out half your dependencies straight up lied about ESM support. I watched a team burn two entire sprints debugging dual package hazards because some shitty middleware claimed ESM compatibility but only tested it against webpack, not actual Node.js.

What Actually Changes

CommonJS vs ESM Syntax Comparison

CommonJS (your current mess):

// package.json - the bare minimum
{
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "test": "jest"
  }
  // no "type" field = CommonJS by default
}

// index.js - works but feels old
const express = require('express');
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');
const readFile = promisify(fs.readFile);

// That ugly IIFE wrapper because no top-level await
(async function() {
  const app = express();
  
  const configBuffer = await readFile(path.join(__dirname, 'config.json'));
  const config = JSON.parse(configBuffer.toString());
  
  app.get('/api/health', (req, res) => {
    res.json({ status: 'ok', version: require('./package.json').version });
  });
  
  app.listen(config.port, () => {
    console.log(`Server running on port ${config.port}`);
  });
})();

ESM (actually clean):

// package.json - one magic line changes everything
{
  "type": "module",
  "main": "index.js", 
  "scripts": {
    "start": "node index.js",
    "test": "node --test",
    "dev": "node --watch index.js"  // built-in nodemon
  },
  "engines": {
    "node": ">=22.0.0"
  }
}

// index.js - no more wrapper hell
import express from 'express';
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();

// Top-level await - finally
const configData = await readFile(join(__dirname, 'config.json'), 'utf8');
const config = JSON.parse(configData);

app.get('/api/health', (req, res) => {
  res.json({ 
    status: 'ok', 
    version: process.env.npm_package_version 
  });
});

app.listen(config.port, () => {
  console.log(`Server running on port ${config.port}`);
});

What Actually Happens During Migration

Don't try to migrate everything at once - that's how you spend a weekend rolling back commits at 3am while your production apps shit themselves because some import path broke in a way that only shows up in prod, never staging.

First: Update Node.js and check dependencies

Update to Node.js 22+ first - it's current LTS and ESM actually works reliably now, unlike the dumpster fire that was Node 16. Check the official migration guide and pray your Docker base images don't spontaneously combust. I've had Alpine images break because of glibc version mismatches during Node updates - burned 4 hours debugging why the app worked locally but crashed with cryptic error messages in Docker.

## Find out which of your deps are lying about ESM support
npx publint

## Check what's actually going to break before updating
npx npm-check-updates -u --dry-run

## This WILL break something, usually your oldest middleware
npx npm-check-updates -u
npm install

## If things get really fucked up (nuclear option):
rm -rf node_modules package-lock.json
npm install

Pro tip: publint catches most package.json fuckups, but can't save you from dependencies that blatantly lie about ESM support. Always test in a branch first, unless you enjoy debugging production at 2am.

Second: The package.json changes

Add "type": "module" and watch half your scripts break:

{
  "type": "module",  // This one line changes everything
  "main": "index.js",
  "scripts": {
    "test": "node --test",  // Ditch Jest, use built-in
    "dev": "node --watch src/index.js"  // Built-in nodemon
  },
  "engines": {
    "node": ">=22.0.0"  // Force everyone to update
  }
}

Then: Convert imports one file at a time

Start with your utils and work your way up to the main server file:

// This breaks immediately
const express = require('express');

// This works
import express from 'express';

// This breaks (no .js extension)
import { utils } from './utils';

// This works (you'll forget the .js 100 times)
import { utils } from './utils.js';

The __dirname hell

ESM doesn't have __dirname. You need this ugly boilerplate:

// Every ESM file needs this shit
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const configPath = join(__dirname, 'config.json');

JSON imports are stupid

// This doesn't work
import config from './config.json';

// This verbose mess works
import config from './config.json' assert { type: 'json' };

// I just do this instead - simpler and more reliable
import { readFile } from 'fs/promises';
const config = JSON.parse(await readFile('./config.json', 'utf8'));

Built-in Features That Actually Work Now

Node.js Built-in APIs

Built-in test runner

Stop installing Jest - Node has a native test runner that actually works with ESM, unlike Jest which needs a bunch of weird config flags to maybe work sometimes. Jest's ESM support is still experimental and breaks in random ways between versions:

// test/user.test.js
import { test, describe } from 'node:test';
import assert from 'node:assert';
import { createUser } from '../src/user.js';

describe('User creation', () => {
  test('creates user with valid data', () => {
    const user = createUser({ name: 'Alice' });
    assert.strictEqual(user.name, 'Alice');
  });
});
## Just works
node --test

## With coverage
node --test --experimental-test-coverage

Native fetch

No more installing node-fetch for basic HTTP requests. Native fetch landed in Node.js 17.5 and became stable in 18, so you can finally delete that 500KB dependency:

// This just works in Node 18+
const response = await fetch('https://api.example.com/users/1');
const user = await response.json();

Built-in watch mode

Nodemon is dead. Use --watch:

## Restart on file changes
node --watch server.js

## Watch specific directories
node --watch-path=./src server.js

Fair warning: the watch mode is stupidly aggressive - restarts on every save, even temp files. Way more trigger-happy than nodemon. Your VS Code will make it restart 10 times per minute if you don't configure ignore patterns. Check the --watch docs because you'll need them.

Things That Will Break (And Waste Your Time)

File extensions are mandatory

This shit will break 100+ times during migration, and the error messages are absolutely fucking useless:

// Breaks with "Cannot resolve module" 
import { utils } from './utils';

// Works (but you'll keep forgetting the .js)
import { utils } from './utils.js';

Paths with spaces break on Windows

If your Windows username has a space, ESM modules break in completely random ways that only show up on specific machines. Took me 3 hours to figure out why import('./file.js') failed only on Sarah's laptop (her username was "Sarah Smith") - turns out Windows paths with spaces cause file:// URLs to shit themselves in ESM resolution.

The exact error was:

Error [ERR_MODULE_NOT_FOUND]: Cannot resolve module 'file:///C:/Users/Sarah%20Smith/project/src/utils.js'

Which tells you jack shit about the actual problem. The Node.js issue tracker is littered with similar edge cases that nobody talks about in tutorials. I only figured it out because I ran node --trace-warnings and saw URL encoding problems in the module resolution.

JSON imports are verbose as hell

// Doesn't work
import config from './config.json';

// Works but ugly  
import config from './config.json' assert { type: 'json' };

// What I actually use - no ESM weirdness
import { readFile } from 'fs/promises';
const config = JSON.parse(await readFile('./config.json', 'utf8'));

The JSON import syntax changed three fucking times during Node.js development because the TC39 spec couldn't make up its mind. The current import assertions syntax will probably change again next year. Save yourself the migraine and just use fs/promises like a sane person.

Performance - Not Life-Changing But Decent

Node.js Performance Chart

Faster startup times

ESM might load 10-15% faster than CommonJS in Node.js 22, but your mileage will vary wildly based on your specific mess of a codebase. Sometimes it's faster, sometimes it's the same, occasionally it's actually slower because of weird edge cases. Good for AWS Lambda cold starts, but your real bottleneck is probably still your database queries.

I measured cold starts on our payment service - went from around 800ms to maybe 650ms after the ESM migration. Not life-changing, but every millisecond counts when you're trying to keep checkout under that magic 1-second mark before customers get pissy.

Tree shaking works

// Old way - imports all of lodash (massive)
const _ = require('lodash');

// ESM way - only what you need
import { map } from 'lodash-es';

Your bundles will be smaller, but don't expect miracles. Most of your bundle size is probably React anyway, so tree-shaking won't magically fix your bloated app.

Less dependency hell

Node.js includes fetch, crypto, and other web APIs natively now. Fewer npm packages means fewer supply chain vulnerabilities and less npm audit noise screaming about CVEs in packages you don't even use directly.

How Long This Actually Takes

Small apps (under 20 files): 2-4 weeks

Plan for 3 weeks that turns into 6 weeks because production will catch fire and you'll get pulled into other emergencies. I migrated what looked like a simple API and it dragged on forever because:

  • One dependency claimed ESM support but only worked with dynamic imports - complete lies in the docs, kept throwing ERR_REQUIRE_ESM
  • The Docker build scripts assumed .js files were CommonJS and just died with exit code 1 and zero useful output
  • Tests broke in ways that only showed up in CI, never locally, because Jest handles ESM differently between minor versions and nobody bothers documenting it

Medium apps (20-100 files): 1-3 months

My 50-file Express app took 6 weeks. Budget for:

  • Week 1-2: Dependencies (half will need updates or alternatives)
  • Week 3-4: File conversion (you'll find circular imports you forgot about)
  • Week 5-6: Everything breaks in staging in ways you didn't expect

Large codebases: 3+ months

Don't even fucking attempt this unless you have dedicated time. One team spent 4 months migrating their 200+ file API because they kept discovering legacy code that depended on obscure CommonJS bullshit.

Variables that add time:

  • Old dependencies that never got ESM support
  • Custom build scripts that assume CommonJS
  • Docker images with old Node versions
  • Team members who resist change and slow down reviews

Every migration I've done took 50% longer than planned because you always find some random bullshit you completely forgot about. It's not the ESM conversion that kills you - it's the ancient build scripts, Docker configs, and legacy middleware that all break in creative new ways designed to ruin your weekend.

Migration Path Comparison: CommonJS vs ESM vs Hybrid

Aspect

CommonJS (Legacy)

Pure ESM (Modern)

Hybrid Approach

Recommended For

Syntax

require() / module.exports

import / export

Both supported with conditions

ESM for new code, hybrid for migration

File Extensions

.js assumes CommonJS

.js requires "type": "module"

.mjs for ESM, .cjs for CommonJS

Hybrid during transition

Top-Level Await

❌ Not supported

✅ Native support

✅ In ESM files only

Pure ESM

Dynamic Imports

❌ Requires import() function

✅ Native import() and await import()

✅ Both syntaxes work

Pure ESM

Tree Shaking

❌ Bundles include unused code

✅ Dead code elimination

✅ Only for ESM imports

Pure ESM

__dirname / __filename

✅ Global variables

❌ Must use import.meta.url

Different per file type

Hybrid with utility functions

JSON Imports

✅ Direct require

✅ With import assertions

Both methods available

Pure ESM with assertions

IDE Support

Good but aging

Excellent with modern features

Mixed experience

Pure ESM

Bundle Size

Larger (no tree shaking)

Smaller (optimized imports)

Varies by module type

Pure ESM

Node.js Compatibility

All versions

Node.js 12+ (stable 14+)

Node.js 12+

Pure ESM for Node 22+

npm Ecosystem

100% compatible

85%+ compatible

100% compatible

Hybrid for legacy deps

Learning Curve

Low (familiar)

Medium (new patterns)

High (two systems)

Pure ESM

Performance

Baseline

Maybe 10-15% faster (varies wildly)

Mixed performance

Pure ESM

Future-Proof

❌ Legacy path

✅ JavaScript standard

❌ Temporary solution

Pure ESM

Advanced Migration Patterns and Production Deployment

Handling Complex Migration Scenarios Without Losing Your Mind

Node.js Architecture

Incremental Migration Strategy for Large Applications

Node.js Migration Strategy

Don't try to migrate everything at once - that's how you spend a weekend at 3am rolling back commits while your staging environment burns to the ground. I've watched teams attempt "big bang" migrations that turned into month-long disasters because they underestimated how many things break simultaneously in creative new ways. Do it incrementally using conditional exports, dual package strategies, and actually reading the Node.js ESM docs instead of assuming it "just works".

The Layer-by-Layer Approach

Start with the outermost layers and work inward:

  1. Utilities and helpers first - lowest risk, highest confidence
  2. Route handlers and controllers - business logic with clear boundaries
  3. Service layer - core business operations
  4. Database and external integrations - highest risk, migrate last
// Phase 1: Start with utilities (lowest risk)
// src/utils/index.js -> src/utils/index.mjs
export const formatDate = (date) => {
  return new new Intl.DateTimeFormat('en-US').format(date);
};

export const slugify = (text) => {
  return text.toLowerCase().replace(/\s+/g, '-');
};

// Phase 2: Controllers that use utilities
// src/controllers/user.controller.js
import { formatDate, slugify } from '../utils/index.mjs';

const userController = {
  async createUser(req, res) {
    const user = {
      ...req.body,
      slug: slugify(req.body.name),
      createdAt: formatDate(new Date())
    };
    
    // Still using CommonJS for database layer (Phase 4)
    const UserService = require('../services/UserService');
    const result = await UserService.create(user);
    
    res.json(result);
  }
};

// Export for both CommonJS and ESM consumption
module.exports = userController;
export default userController;

Dependency Transition Strategies

Dealing with Mixed Dependencies

The npm ecosystem is a complete shitshow of ESM-first, CommonJS-only, and hybrid packages where half the maintainers straight up lie about their ESM support in their README files. Node.js 22+ improved interoperability with better module resolution and require() ESM support, but you still need strategies for when packages inevitably break in production. Check publint and the Are The Types Wrong? tool to catch bullshit before it detonates in production.

// Strategy 1: Dual-mode packages with conditional exports
// package.json
{
  "type": "module",
  "exports": {
    ".": {
      "import": "./esm/index.js",
      "require": "./cjs/index.js"  
    }
  },
  "main": "./cjs/index.js", // Fallback for old Node.js
  "module": "./esm/index.js" // Bundler hint
}

// Strategy 2: Gradual replacement of CommonJS-only packages
// Before: using old CommonJS-only package
const oldPackage = require('legacy-package');

// During migration: wrapper module
// src/adapters/legacy-adapter.js
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const legacyPackage = require('legacy-package');

export const adaptedFunction = (data) => {
  // Adapt old API to modern patterns
  return new Promise((resolve, reject) => {
    legacyPackage.oldCallback(data, (err, result) => {
      if (err) reject(err);
      else resolve(result);
    });
  });
};

// After: replaced with modern alternative
import { modernAlternative } from 'modern-package';

Package-by-Package Migration Guide

Common packages and their ESM migration paths:

// HTTP Clients
// OLD: const axios = require('axios');
import axios from 'axios'; // Works in ESM
// BETTER: Use native fetch
const response = await fetch('/api/data');

// Utilities
// OLD: const _ = require('lodash');
// NEW: Tree-shakeable imports
import { map, filter, reduce } from 'lodash-es';

// File operations
// OLD: const fs = require('fs').promises;
import { readFile, writeFile } from 'fs/promises';

// Environment variables
// OLD: require('dotenv').config();
// Node.js 20+: --env-file flag
// node --env-file=.env server.js

// Configuration
// OLD: const config = require('./config.json');
// NEW: Import assertions
import config from './config.json' assert { type: 'json' };
// BETTER: Async loading
const config = JSON.parse(await readFile('./config.json', 'utf8'));

Production Deployment Patterns

Blue-Green Deployment Pattern

Zero-Downtime ESM Migration

For production services that can't afford downtime (because your SLA actually matters and you're not some bullshit startup with 3 users), use blue-green deployment with feature flags and gradual rollouts. This isn't theoretical bullshit - I've used this exact pattern to migrate services handling millions of requests without dropping a single fucking one:

// src/server.js - Hybrid server during migration
import express from 'express';
import { createRequire } from 'module';

const require = createRequire(import.meta.url);
const app = express();

// Feature flag for gradual ESM adoption
const USE_ESM_ROUTES = process.env.USE_ESM_ROUTES === 'true';

if (USE_ESM_ROUTES) {
  // New ESM routes
  const { userRoutes } = await import('./routes/users.mjs');
  app.use('/api/users', userRoutes);
} else {
  // Legacy CommonJS routes
  const userRoutes = require('./routes/users.js');
  app.use('/api/users', userRoutes);
}

// Gradually increase ESM_ROUTE_PERCENTAGE
const esmRoutePercentage = parseInt(process.env.ESM_ROUTE_PERCENTAGE || '0');
const useEsmRoute = Math.random() * 100 < esmRoutePercentage;

app.use('/api/orders', useEsmRoute 
  ? (await import('./routes/orders.mjs')).default
  : require('./routes/orders.js')
);

Docker and Container Considerations

## Dockerfile for ESM migration
FROM node:22-alpine

WORKDIR /app

## Copy package files
COPY package*.json ./

## Install dependencies
RUN npm ci --only=production

## Copy source code
COPY . .

## Use Node.js native features
## --env-file for environment variables (Node 20+)
## --experimental-loader for advanced ESM features
CMD ["node", "--env-file=.env.production", "server.js"]

Environment Configuration

## .env.production
NODE_ENV=production
USE_ESM_ROUTES=true
ESM_ROUTE_PERCENTAGE=100

## Enable Node.js performance optimizations
NODE_OPTIONS="--max-old-space-size=2048 --experimental-vm-modules"

Monitoring and Observability During Migration

Performance Monitoring

Track key metrics during migration because "it feels faster" isn't good enough when your boss asks why you spent 3 weeks on this shit. Track the important stuff with Node's built-in perf hooks. Skip the fancy monitoring unless your boss specifically asks for charts they'll never look at. clinic.js and 0x are useful but overkill for most migrations. New Relic and DataDog will track this stuff automatically if you're already paying their ridiculous fees:

// src/monitoring/performance.js - Simple version that actually works
import { performance } from 'perf_hooks';

const loadTimes = new Map();

export function measureModuleLoad(moduleName, loadFunction) {
  const start = performance.now();
  return Promise.resolve(loadFunction()).finally(() => {
    const time = performance.now() - start;
    console.log(`Module ${moduleName} loaded in ${time.toFixed(2)}ms`);
    loadTimes.set(moduleName, time);
  });
}

export function getLoadTimes() {
  return Object.fromEntries(loadTimes);
}

// Usage - keep it simple
const userService = await measureModuleLoad('userService', 
  () => import('./services/UserService.mjs')
);

Error Tracking and Rollback Procedures

// src/utils/migration-safety.js
import { createRequire } from 'module';

const require = createRequire(import.meta.url);

export class SafeMigration {
  static async tryEsmImport(modulePath, fallbackRequire) {
    try {
      const module = await import(modulePath);
      console.log(`✅ Successfully loaded ESM module: ${modulePath}`);
      return module;
    } catch (error) {
      console.warn(`⚠️  ESM import failed for ${modulePath}, falling back to require`);
      console.error(error.message);
      
      // Log to monitoring system
      this.logMigrationError(modulePath, error);
      
      // Fallback to CommonJS
      return require(fallbackRequire);
    }
  }
  
  static logMigrationError(modulePath, error) {
    // Send to your monitoring system
    const errorDetails = {
      module: modulePath,
      error: error.message,
      stack: error.stack,
      nodeVersion: process.version,
      timestamp: new Date().toISOString()
    };
    
    // Example: send to logging service
    console.error('Migration Error:', errorDetails);
  }
}

// Usage in application code
const userController = await SafeMigration.tryEsmImport(
  './controllers/user.controller.mjs',
  './controllers/user.controller.js'
);

Advanced ESM Patterns and Optimizations

Module Federation Architecture

Module Federation for Microservices

// src/modules/shared-kernel.mjs
// Shared business logic across microservices
export class SharedKernel {
  static validateEmail(email) {
    return /^[^
@]+@[^
@]+\.[^
@]+$/.test(email);
  }
  
  static generateId() {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }
}

// Conditional exports for different environments
export const config = await (async () => {
  if (process.env.NODE_ENV === 'production') {
    return import('./config/production.mjs');
  } else if (process.env.NODE_ENV === 'staging') {
    return import('./config/staging.mjs');
  } else {
    return import('./config/development.mjs');
  }
})();

Dynamic Module Loading for Plugin Systems

// src/plugins/plugin-loader.mjs
import { readdir } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));

export class PluginLoader {
  static async loadPlugins() {
    const pluginDir = join(__dirname, 'plugins');
    const files = await readdir(pluginDir);
    
    const plugins = await Promise.all(
      files
        .filter(file => file.endsWith('.mjs'))
        .map(async file => {
          const pluginPath = join(pluginDir, file);
          const plugin = await import(pluginPath);
          
          return {
            name: plugin.name || file.replace('.mjs', ''),
            plugin: plugin.default || plugin
          };
        })
    );
    
    return plugins;
  }
}

// Usage
const plugins = await PluginLoader.loadPlugins();
plugins.forEach(({ name, plugin }) => {
  console.log(`Loaded plugin: ${name}`);
  plugin.initialize?.();
});

Testing During Migration

Dual-Mode Testing Strategy

// test/migration.test.mjs
import { test, describe } from 'node:test';
import assert from 'node:assert';
import { createRequire } from 'module';

const require = createRequire(import.meta.url);

describe('Migration compatibility', () => {
  test('ESM and CommonJS versions produce same output', async () => {
    // Test ESM version
    const { processData } = await import('../src/processors/data.mjs');
    const esmResult = processData({ input: 'test' });
    
    // Test CommonJS version
    const { processData: cjsProcessData } = require('../src/processors/data.js');
    const cjsResult = cjsProcessData({ input: 'test' });
    
    // Both should produce identical results
    assert.deepEqual(esmResult, cjsResult);
  });
  
  test('Performance regression check', async () => {
    const { performance } = await import('perf_hooks');
    
    // Benchmark ESM version
    const start = performance.now();
    const { heavyComputation } = await import('../src/heavy-computation.mjs');
    await heavyComputation();
    const esmTime = performance.now() - start;
    
    // Benchmark CommonJS version  
    const cjsStart = performance.now();
    const { heavyComputation: cjsHeavy } = require('../src/heavy-computation.js');
    await cjsHeavy();
    const cjsTime = performance.now() - cjsStart;
    
    // ESM should be faster or at least not significantly slower
    assert(esmTime <= cjsTime * 1.1, `ESM too slow: ${esmTime}ms vs ${cjsTime}ms`);
  });
});

The key to successful ESM migration is treating it as a gradual process, not a one-time fucking event. Monitor performance, maintain backward compatibility during transition, and always have a rollback plan because something will break in production. Teams that rush the migration always end up reverting at 2am because some obscure middleware broke in production in ways that never showed up in staging, because of course it didn't. Teams that take the incremental approach actually finish migrations and don't want to quit their jobs afterward. Check the Node.js migration case studies and Fastify's ESM migration story for real-world examples that aren't complete bullshit.

Modern Node.js apps built with ESM are measurably faster, easier to debug, and don't make senior developers want to quit when they have to onboard new team members. The migration effort pays dividends in developer productivity (faster builds, better tree-shaking), application performance (roughly 10-15% startup improvements), and alignment with JavaScript ecosystem standards instead of fighting webpack configuration hell every fucking week.

Modern Node.js Migration: Questions That Actually Matter

Q

Should I migrate my existing Node.js app from CommonJS to ESM?

A

Depends on your situation, but probably fucking yes if you're actively developing.

If you're adding new features regularly and your team isn't completely fried from putting out other fires, the ESM migration pays for itself within 6 months. You'll spend way less time wrestling with webpack configuration, get faster builds with Vite, and delete a ton of dependencies that Node.js now includes natively.

Just did this migration on a 6-person team

  • we went from spending 2-3 hours per week fighting webpack issues to maybe 20 minutes. The Vite dev server restart time went from "enough time to grab coffee" to "blink and you miss it."Migrate if:
  • You're using Node.js 16 or older (these versions are EOL or approaching it)
  • You spend time fighting webpack/build tool configuration
  • Your bundle sizes are too large
  • You want to use modern JavaScript features
  • Your team is adding new features regularlyDon't migrate if:
  • Your app is in maintenance mode with rare changes
  • You have complex native dependencies that don't support ESM
  • Your team doesn't have time for 2-4 weeks of migration work
  • You're running critical financial systems that can't afford ANY downtimeReality check from the trenches: I migrated a 200-file Express API last year that was originally built in 2019.

Took 3 weeks that stretched to 6 weeks because production kept breaking and I got dragged into other fires. Bundle size went down maybe 25-30%, cut 12 dependencies (including some sketchy packages where maintainers just vanished), and dev is noticeably snappier. The webpack-dev-server went from painful 45-second cold starts to about 8-second Vite rebuilds.

Q

What breaks when migrating from CommonJS to ESM?

A

File paths, imports, and global variables

  • basically everything that made CommonJS "convenient."Things that definitely break (learned the hard way):javascript// ❌ These don't work in ESMconst module = require('./module');const __dirname = 'current directory';const config = require('./config.json');module.exports = { something };// ✅ ESM equivalentsimport module from './module.js'; // Note: .js extension requiredimport { fileURLToPath } from 'url';const __dirname = dirname(fileURLToPath(import.meta.url));import config from './config.json' assert { type: 'json' };export { something };The missing .js extension will give you ERR_MODULE_NOT_FOUND and you'll stare at it for 20 minutes thinking the file doesn't exist.

Copy this command, you'll need it: find . -name "*.js" | grep -v node_modules to double-check your files are actually there.Surprising gotchas that will eat your entire afternoon:

  • File extensions are mandatory: import './utils'import './utils.js' (you'll forget this 50+ times and get "ERR_MODULE_NOT_FOUND" every fucking time)
  • this is undefined instead of pointing to exports
  • breaks some old middleware in unexpected ways
  • Circular dependencies work differently
  • sometimes better, sometimes they just fail silently and drive you insane
  • Half your npm packages haven't actually tested their "exports" field and straight up lie about ESM support
  • JSON imports need assertions
  • the syntax is verbose as hell
  • Windows file paths with spaces break dynamic imports in completely random waysMigration tip that will save your sanity: Start with utilities and work outward toward your main server file.

Don't try to convert your Express app entry point first

  • I made that mistake and spent 8 hours debugging why nothing worked.

Follow the official migration guide and use publint to catch configuration mistakes.

Q

Can I use both CommonJS and ESM in the same project?

A

Yes, but it's messy and should be temporary.

Node.js supports interop between Common

JS and ESM, but you'll spend more time managing the complexity than benefiting from ESM features.What works:javascript// ESM can import CommonJSimport oldModule from 'commonjs-package';// CommonJS can import ESM with dynamic importsconst esmModule = await import('./esm-module.mjs');What's painful about hybrid mode:

  • Two different module systems with completely different semantics and resolution algorithms
  • Error messages that are completely useless: "Cannot resolve module specifier" tells you nothing
  • Different file extensions (.js, .mjs, .cjs) and you'll forget which is which
  • Build tools that handle each format differently
  • Rollup vs esbuild vs webpack
  • IDE support that gets confused and gives you wrong autocomplete
  • TypeScript configuration that needs different settings for each module systemMy recommendation from painful fucking experience: Use hybrid mode for max 3 months during migration, then pick one system and stick with it. Hybrid mode is a transition strategy, not a permanent solution. I've seen teams get stuck in hybrid hell for 18+ months because they never finished the migration and just accepted the pain.
Q

How do I handle __dirname and __filename in ESM?

A

Use import.meta.url with Node.js utilities. This is the most annoying part of ESM migration, but it's not that bad once you create helper functions.javascript// CommonJS (old way)const path = require('path');const configPath = path.join(__dirname, 'config.json');// ESM (new way)import { fileURLToPath } from 'url';import { dirname, join } from 'path';const __filename = fileURLToPath(import.meta.url);const __dirname = dirname(__filename);const configPath = join(__dirname, 'config.json');// Better: Create a utility function// src/utils/paths.jsimport { fileURLToPath } from 'url';import { dirname, join } from 'path';export function getProjectRoot(importMetaUrl) { return dirname(fileURLToPath(importMetaUrl));}export function joinPath(importMetaUrl, ...paths) { const dir = getProjectRoot(importMetaUrl); return join(dir, ...paths);}// Usage in your filesimport { joinPath } from './utils/paths.js';const configPath = joinPath(import.meta.url, 'config.json');Pro tip from someone who's done this 6 times: Create these helpers once and copy them to every project you migrate. Put them in a shared utility package if you have multiple services. I've used this exact pattern across 6 different codebases and it makes the migration much cleaner. The Node.js documentation shows other approaches but this one actually works reliably.

Q

What about performance? Is ESM actually faster?

A

Yes, but not dramatically.

ESM provides 10-15% faster startup time and better memory usage due to tree shaking, but you won't notice huge differences unless you're running serverless functions or have massive dependency trees.Performance benefits I've actually measured (not the marketing hype):

  • Startup time: Maybe 15-25% faster cold starts, varies a lot (good for serverless)
  • Bundle size: Around 20-40% smaller after tree shaking, depends on your deps (helps with deployment)
  • Memory usage: Roughly 10-15% lower baseline, but not consistent (decent for containers)
  • Build time: 30-50% faster with modern tools like Vite (this is the big win)What doesn't improve:
  • Runtime performance of your actual business logic
  • Database query speed
  • Network requests
  • CPU-intensive operationsThe biggest performance win is actually developer productivity
  • Vite dev server instead of 45-second webpack builds, better VS Code intellisense with proper ESM imports, and not spending hours debugging webpack configuration. Time savings add up to weeks per developer per year.
Q

Which Node.js version should I target for ESM migration?

A

Node.js 22+ for new projects, Node.js 20+ minimum for migration.Node.js 22 (Current LTS):

  • Excellent ESM support with require() for ESM modules
  • Built-in test runner with coverage
  • Native watch mode
  • Performance improvements
  • Active LTS until April 2027Node.js 20 (Previous LTS):
  • Stable ESM support
  • Native test runner (experimental)
  • Good performance
  • LTS until April 2026Avoid Node.js 18 for new ESM projects:
  • ESM support is functional but has rough edges
  • Missing some convenience features
  • LTS ending April 2025Reality check: If you're still on Node 16 or earlier, you're missing security patches and performance improvements. The ESM migration is a good excuse to update to a supported version.
Q

How do I handle JSON imports in ESM?

A

Use import assertions or async loading.

JSON imports are one of the most annoying ESM changes because the syntax is verbose and not well documented.```javascript// CommonJS (simple)const config = require('./config.json');// ESM Option 1: Import assertions (Node.js 17+)import config from './config.json' assert { type: 'json' };// ESM Option 2:

Async loading (more flexible)import { read

File } from 'fs/promises';const config = JSON.parse(await readFile('./config.json', 'utf8'));// ESM Option 3: Dynamic import (works everywhere)const { default: config } = await import('./config.json', { assert: { type: 'json' } });```Which one to use:

  • Import assertions: Best for static configuration that doesn't change
  • Async loading: Best for configuration that might not exist or needs error handling
  • Dynamic import: Best for conditional loadingMy preference after trying all three approaches: I use async loading for most cases because the error handling is clearer and you don't have to remember the import assertion syntax which might change again.

The fs/promises approach just works everywhere.

Also learned this when production died at 3am because a config file was malformed JSON and import assertions give you a useless SyntaxError: Unexpected token instead of telling you which file broke. With fs/promises you can wrap it in try/catch and actually log meaningful errors.

Q

What about testing? Do I need to change my test setup?

A

Modern Node.js has a built-in test runner, but you can keep Jest/Vitest if you prefer. The native test runner is surprisingly good for simple cases.Option 1: Node.js native test runner (Node.js 18+)javascript// test/user.test.jsimport { test, describe } from 'node:test';import assert from 'node:assert';import { createUser } from '../src/user.js';describe('User creation', () => { test('creates user with valid data', () => { const user = createUser({ name: 'Alice' }); assert.strictEqual(user.name, 'Alice'); });});// Run with: node --testOption 2: Vitest (Jest alternative with better ESM support)javascript// Same syntax as Jest, but actually works with ESMimport { test, expect } from 'vitest';import { createUser } from '../src/user.js';test('creates user', () => { const user = createUser({ name: 'Alice' }); expect(user.name).toBe('Alice');});Skip Jest unless you absolutely have to because your team will revolt if you change test frameworks. Jest's ESM support is still experimental and breaks constantly. I wasted 3 days fighting Jest configuration hell with babel transforms and experimental flags during my first migration. Got ERR_REQUIRE_ESM errors that made zero sense. Vitest has the same API but actually works with ESM.

Q

How do I handle environment variables in modern Node.js?

A

Use the built-in --env-file flag in Node.js 20+. No more require('dotenv').config() calls.bash# Old way (still works)npm install dotenv# In your code: require('dotenv').config()# New way (Node.js 20+)node --env-file=.env server.jsnode --env-file=.env.development --env-file=.env server.js # Multiple filesDocker example:dockerfileFROM node:22-alpineWORKDIR /appCOPY . .CMD ["node", "--env-file=.env.production", "server.js"]Development script:json{"scripts": {"dev": "node --env-file=.env.development --watch server.js","start": "node --env-file=.env.production server.js"}}This eliminates the dotenv dependency (one less package to audit for CVEs) and makes environment loading explicit. No more wondering if someone called dotenv.config() before importing your modules. Check the --env-file documentation for advanced usage.

Q

What's the migration timeline for a real application?

A

2-4 weeks for most applications, but plan for 6 weeks if you have complex dependencies.**Week 1:

Preparation and Planning (if you're lucky)**

  • Update to Node.js 22+ (2 hours if Docker doesn't shit itself)
  • Audit dependencies for ESM compatibility (full day because half the packages lie)
  • Create migration branch (10 minutes)
  • Set up hybrid build process (4 hours of cursing at webpack)Week 2: Core Migration (where everything breaks)
  • Convert package.json to "type": "module" (5 minutes)
  • Migrate utility functions and helpers (2 days)
  • Update import/export syntax (3 days, you'll miss a dozen .js extensions)
  • Fix __dirname/__filename usage (1 day of copy-pasting boilerplate)**Week 3:

Integration and Testing (debugging hell)**

  • Convert route handlers and controllers (2 days)
  • Update database connections and external APIs (3 days, something will break mysteriously)
  • Run comprehensive test suite (1 day of fixing tests that worked fine before)
  • Performance testing (2 hours unless you find a memory leak)Week 4: Production Deployment (prayer and backup plans)
  • Deploy to staging environment (30 minutes)
  • Monitor performance and error rates (3 days of paranoid checking)
  • Fix any remaining issues (could be 2 hours or 2 days, no middle ground)
  • Deploy to production with rollback plan (30 minutes deploy, 6 hours of anxiety)Variables that affect timeline:
  • Team size: More developers = more coordination needed
  • Test coverage: Better tests = faster, safer migration
  • Dependency complexity: Old packages can block migration
  • Application size: 100+ files take longer than 20 files
  • Business criticality: Mission-critical apps need more careful testingRealistic expectations from someone who's done this too many times: Every migration I've done took 50% longer than planned because you always find some random shit you forgot about.

Budget extra time for dependency hell, Windows compatibility bullshit, and the one piece of legacy middleware that breaks everything in creative ways. The Node.js migration guide doesn't mention half the edge cases that will ruin your weekend.

Essential Resources for Node.js ESM Migration

Related Tools & Recommendations

tool
Similar content

Node.js Overview: JavaScript Runtime, Production Tips & FAQs

Explore Node.js: understand this powerful JavaScript runtime, learn essential production best practices, and get answers to common questions about its performan

Node.js
/tool/node.js/overview
100%
tool
Similar content

React Production Debugging: Fix App Crashes & White Screens

Five ways React apps crash in production that'll make you question your life choices.

React
/tool/react/debugging-production-issues
88%
tool
Similar content

Node.js Docker Containerization: Setup, Optimization & Production Guide

Master Node.js Docker containerization with this comprehensive guide. Learn why Docker matters, optimize your builds, and implement advanced patterns for robust

Node.js
/tool/node.js/docker-containerization
80%
tool
Similar content

Node.js Performance Optimization: Boost App Speed & Scale

Master Node.js performance optimization techniques. Learn to speed up your V8 engine, effectively use clustering & worker threads, and scale your applications e

Node.js
/tool/node.js/performance-optimization
78%
tool
Similar content

Solana Web3.js v1.x to v2.0 Migration: A Comprehensive Guide

Navigate the Solana Web3.js v1.x to v2.0 migration with this comprehensive guide. Learn common pitfalls, environment setup, Node.js requirements, and troublesho

Solana Web3.js
/tool/solana-web3js/v1x-to-v2-migration-guide
75%
tool
Similar content

Node.js Security Hardening Guide: Protect Your Apps

Master Node.js security hardening. Learn to manage npm dependencies, fix vulnerabilities, implement secure authentication, HTTPS, and input validation.

Node.js
/tool/node.js/security-hardening
73%
tool
Similar content

GraphQL Overview: Why It Exists, Features & Tools Explained

Get exactly the data you need without 15 API calls and 90% useless JSON

GraphQL
/tool/graphql/overview
70%
integration
Similar content

Claude API Node.js Express: Advanced Code Execution & Tools Guide

Build production-ready applications with Claude's code execution and file processing tools

Claude API
/integration/claude-api-nodejs-express/advanced-tools-integration
68%
tool
Similar content

Express.js Middleware Patterns - Stop Breaking Things in Production

Middleware is where your app goes to die. Here's how to not fuck it up.

Express.js
/tool/express/middleware-patterns-guide
65%
integration
Similar content

Claude API Node.js Express Integration: Complete Guide

Stop fucking around with tutorials that don't work in production

Claude API
/integration/claude-api-nodejs-express/complete-implementation-guide
65%
tool
Similar content

Node.js Memory Leaks & Debugging: Stop App Crashes

Learn to identify and debug Node.js memory leaks, prevent 'heap out of memory' errors, and keep your applications stable. Explore common patterns, tools, and re

Node.js
/tool/node.js/debugging-memory-leaks
65%
tool
Similar content

DataLoader: Optimize GraphQL Performance & Fix N+1 Queries

Master DataLoader to eliminate GraphQL N+1 query problems and boost API performance. Learn correct implementation strategies and avoid common pitfalls for effic

GraphQL DataLoader
/tool/dataloader/overview
65%
tool
Similar content

Express.js - The Web Framework Nobody Wants to Replace

It's ugly, old, and everyone still uses it

Express.js
/tool/express/overview
63%
tool
Similar content

npm - The Package Manager Everyone Uses But Nobody Really Likes

It's slow, it breaks randomly, but it comes with Node.js so here we are

npm
/tool/npm/overview
58%
tool
Similar content

Create React App is Dead: Why & How to Migrate Away in 2025

React team finally deprecated it in 2025 after years of minimal maintenance. Here's how to escape if you're still trapped.

Create React App
/tool/create-react-app/overview
58%
tool
Similar content

Bolt.new: VS Code in Browser for AI Full-Stack App Dev

Build full-stack apps by talking to AI - no Docker hell, no local setup

Bolt.new
/tool/bolt-new/overview
55%
tool
Similar content

ESLint - Find and Fix Problems in Your JavaScript Code

The pluggable linting utility for JavaScript and JSX

/tool/eslint/overview
55%
tool
Similar content

npm Enterprise Troubleshooting: Fix Corporate IT & Dev Problems

Production failures, proxy hell, and the CI/CD problems that actually cost money

npm
/tool/npm/enterprise-troubleshooting
55%
tool
Similar content

Node.js Microservices: Avoid Pitfalls & Build Robust Systems

Learn why Node.js microservices projects often fail and discover practical strategies to build robust, scalable distributed systems. Avoid common pitfalls and e

Node.js
/tool/node.js/microservices-architecture
53%
howto
Similar content

Install Node.js & NVM on Mac M1/M2/M3: A Complete Guide

My M1 Mac setup broke at 2am before a deployment. Here's how I fixed it so you don't have to suffer.

Node Version Manager (NVM)
/howto/install-nodejs-nvm-mac-m1/complete-installation-guide
53%

Recommendations combine user behavior, content similarity, research intelligence, and SEO optimization