Node.js Scripts Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
- Use the typescript-eslint Meta-Package: Install
typescript-eslint(the meta package) instead of separate@typescript-eslint/parserand@typescript-eslint/eslint-pluginso you gettseslint.config()and the bundled parser wired up consistently. - Enable parserOptions.project for Type Rules: Type-aware rules like
no-floating-promisesandno-misused-promisessilently do nothing withoutparserOptions.project; set it (andtsconfigRootDir: import.meta.dirname) so paths resolve relative to the config, not the cwd. - Put eslint-config-prettier Last, Isolate ignores: In flat config,
eslint-config-prettiermust be the final entry or its rule-disables get overridden, and an{ ignores: […] }block only acts as a global ignore when it is the sole key in its object — mixing it withrulesturns it into a per-file filter. - Pick Module System Explicitly: Node chooses per file:
.mjsis always ESM,.cjsis always CommonJS, and bare.jsfollows the nearestpackage.json"type"field (defaulting to CommonJS); flipping"type": "module"converts every.jsunderneath and requires renaming CJS holdouts to.cjs. - Write Explicit .js Extensions in ESM: Native ESM throws
ERR_MODULE_NOT_FOUNDwithout the extension, and under"module": "NodeNext"you must import./foo.jseven when the source file is./foo.tsbecause the specifier models the emitted runtime path. - Rebuild __dirname in ESM:
__dirname,__filename, andrequiredo not exist in ESM scope, so recover them withfileURLToPath(import.meta.url)andcreateRequire(import.meta.url)instead of copy-pasting CJS code that silently throws. - Use NodeNext in tsconfig.json: Set both
"module": "NodeNext"and"moduleResolution": "NodeNext"so TypeScript faithfully models Node's real resolution — includingexportsconditions,.mts/.cts, and the required.jsextension. - Always Listen for 'error':
EventEmittertreats'error'specially — emitting it with no registered listener crashes the process with an uncaught exception, so attach a handler (even just logging) on every emitter you own. - Store Handler References for off:
emitter.off(event, fn)only removes the exact function reference you registered; anonymous arrow functions never match, so save the handler in a named variable whenever you need to unsubscribe later. - Respect MaxListeners Warnings: Default
MaxListenersis 10 and the warning fires once at 11, which is an easy-to-miss leak signal; fix the over-registration, raise the limit intentionally withsetMaxListeners(), or useEventEmitter.defaultMaxListeners. - Cap HTTP Request Body Size: Manual body concatenation in
node:httpwith no byte cap is an OOM attack vector; accumulate into a length counter and respond 413 once you cross a limit (Express usesexpress.json({ limit }), Fastify hasbodyLimit). - Wrap Express 4 Async Handlers: Express 4 silently swallows thrown errors and rejected promises from async handlers, hanging the request until the client times out; wrap them or upgrade to Express 5 which forwards to error middleware natively.
- Mind Express vs Fastify Response Styles: Express ignores return values and requires
res.send/res.json; Fastify sends whatever you return from the handler — mixing the two styles (returning data in Express, callingreply.sendin Fastify) produces hung or doubled responses. - Install Node via a Version Manager: nvm, fnm, or Volta installs into your home directory, skips
sudoentirely, and lets you switch Node versions per project; running the official installer plussudo npm install -gleads straight toEACCESmisery. - Target Even-Numbered LTS Versions: LTS releases (even-numbered majors like 20, 22) get 30 months of support; odd-numbered Current releases die after about 6 months, so always pick the latest LTS for scripts and servers.
- Pin packageManager Via Corepack: Set
"packageManager": "pnpm@x.y.z"inpackage.jsonso Corepack (shipped with Node 18.17+) enforces the exact tool and version across every contributor and CI runner, eliminating "works on my machine" install drift. - Fix Phantom Dependencies After Migration: npm's flat hoisted
node_moduleshides phantom deps (imports not inpackage.json); the migration to pnpm's strict symlinked layout surfaces them as real errors — fix them by adding the dependencies, not by switching tonode-linker=hoisted. - Parse argv With node:util parseArgs: Built-in
parseArgsfromnode:utilhandles options, defaults, and validation without adding a dependency, and it automatically skipsprocess.argv[0](node) and[1](script path) so you only see user args. - Separate npm Script Flags With --:
npm run cli --flagpasses--flagto npm itself, not your script; usenpm run cli -- --flagso the flag reaches the underlying command (pnpm and yarn behave the same way). - Use tsx for Dev, Compiled JS for Prod:
tsxstarts in ~100ms using esbuild and is ideal for local scripts; production should run compiled JavaScript viatsc+nodeso there is no runner dependency and startup cost stays flat. - TS Runners Do Not Type-Check:
tsx,ts-node, andnode --experimental-strip-typesall strip types and hand JavaScript to V8 — none of them catch type errors at runtime, so runtsc --noEmitin CI for real safety. - Install @types/node: Without
@types/nodeas a devDependency,process,Buffer, and everynode:*import areany, killing autocomplete and hiding real bugs behind silent implicit-any coercions. - Pass "utf8" to readFile:
fs.readFile(path)without an encoding returns aBuffer, not a string; always pass"utf8"(or another explicit encoding) so downstream.split/regex/JSON parsing works without a surprise buffer. - Use process.exitCode, Not process.exit:
process.exit()terminates immediately and truncates pending stdout or async writes; settingprocess.exitCode = 1and letting the event loop drain preserves logs while still failing the script. - Force Chalk Colors in CI: Chalk suppresses colors when
process.stdout.isTTYis false, which is the default in most CI environments; setFORCE_COLOR=1(or equivalent) in the CI env if you want colored log output and remember Chalk v5 is ESM-only.