Encountered this issue while building a SaaS analytics platform for a client. Here's the root cause and solution.
In Node.js ESM mode, import('./path/to/module') doesn't auto-resolve ./path/to/module.js. If the TypeScript build output is missing .js extensions, the module throws ERR_MODULE_NOT_FOUND. When this import is inside deferred logic (timers, conditionals), the app starts fine but crashes later โ PM2 shows a climbing restart count.
Fix: Ensure all ESM dynamic imports include .js extensions, and automate this in the build pipeline.
Problemโ
PM2 showed the app restarting continuously:
โ name โ โบ โ status โ uptime โ
โ analytics-api โ 9 โ online โ 28m โ
Error log repeated every few minutes:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/domains/video/cleanup'
imported from /app/dist/server.js
But the file clearly exists:
$ ls dist/domains/video/
cleanup.js executor.js queue.js
Root Causeโ
ESM doesn't auto-resolve file extensionsโ
Node.js CommonJS (require()) automatically tries .js, .json, and other extensions. ESM (import) does not.
import('./domains/video/cleanup')
import('./domains/video/cleanup.js')
Deferred imports hide the problemโ
The import was inside a deferred execution:
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler();
});
The app starts successfully (DB connection, port binding all fine). When the timer fires, the import fails โ process crashes โ PM2 restarts โ starts fine again โ timer fires again โ crashes again. This creates a crash loop.
Why did it work before?โ
Previous deployment used a build script that included a post-build step to fix import paths. One deployment skipped this step and deployed raw tsc output โ tsc doesn't modify import paths in output files.
Solutionโ
1. Write .js extensions in TypeScript sourceโ
TypeScript officially recommends writing .js extensions even in .ts files:
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler();
});
2. Automated post-build fix (recommended)โ
Add an import-fixing script to the build pipeline:
{
"scripts": {
"build": "tsc && node fix-imports.js"
}
}
Core logic of fix-imports.js:
import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { join } from 'path';
function fixImports(dir) {
for (const file of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, file.name);
if (file.isDirectory()) {
fixImports(fullPath);
} else if (file.name.endsWith('.js')) {
let content = readFileSync(fullPath, 'utf8');
const fixed = content.replace(
/import\(['"](\.[^'"]+)['"]\)/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
const fixed2 = fixed.replace(
/from\s+['"](\.[^'"]+)['"]/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
if (fixed2 !== content) {
writeFileSync(fullPath, fixed2);
}
}
}
}
The build pipeline automatically appends .js to all relative import paths, keeping TypeScript source extension-free while preventing module-not-found errors in ESM deployments.
Note
- This only affects Node.js ESM mode (
"type": "module" or .mjs files). CommonJS is unaffected.
- Static imports (
import ... from './foo') have the same limitation, not just dynamic import(); import hoisting also causes another common issue โ dotenv runs after the import chain, leaving env vars undefined
- Using
tsx or ts-node in development won't show this error (they auto-resolve extensions), but node dist/server.js in production will fail.
- PM2 crash loop signature: restart count (โบ) keeps growing, uptime never exceeds a few minutes.