Your Code is Embarrassing and Here's Why
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 (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
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
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.