Node.js Scripts Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
Search across all documentation pages
A condensed summary of the 25 most important best practices drawn from every page in this section.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
typescript-eslint (the meta package) instead of separate @typescript-eslint/parser and @typescript-eslint/eslint-plugin so you get tseslint.config() and the bundled parser wired up consistently.no-floating-promises and no-misused-promises silently do nothing without parserOptions.project; set it (and tsconfigRootDir: import.meta.dirname) so paths resolve relative to the config, not the cwd.eslint-config-prettier must 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 with rules turns it into a per-file filter..mjs is always ESM, .cjs is always CommonJS, and bare .js follows the nearest package.json "type" field (defaulting to CommonJS); flipping "type": "module" converts every .js underneath and requires renaming CJS holdouts to .cjs.ERR_MODULE_NOT_FOUND without the extension - import { helper } from "./utils.js" even when the source is utils.ts - because under "module": "NodeNext" the specifier models the emitted runtime path.__dirname, __filename, and require do not exist in ESM scope - const __dirname = path.dirname(fileURLToPath(import.meta.url)) - instead of copy-pasting CJS code that silently throws."module": "NodeNext" and "moduleResolution": "NodeNext" so TypeScript faithfully models Node's real resolution - including exports conditions, .mts/.cts, and the required .js extension.EventEmitter treats '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.emitter.off(event, fn) only removes the exact function reference you registered - const handler = () => { ... }; emitter.on("data", handler); emitter.off("data", handler) - anonymous arrow functions never match, so save the handler in a named variable.MaxListeners is 10 and the warning fires once at 11, which is an easy-to-miss leak signal; fix the over-registration, raise the limit intentionally with setMaxListeners(), or use EventEmitter.defaultMaxListeners.node:http with no byte cap is an OOM attack vector; accumulate into a length counter and respond 413 once you cross a limit (Express uses express.json({ limit }), Fastify has bodyLimit).app.get("/", (req, res, next) => { handleAsync(req, res).catch(next) }) - or upgrade to Express 5 which forwards to error middleware natively.res.send/res.json; Fastify sends whatever you return from the handler - mixing the two styles (returning data in Express, calling reply.send in Fastify) produces hung or doubled responses.sudo entirely, and lets you switch Node versions per project; running the official installer plus sudo npm install -g leads straight to EACCES misery."packageManager": "pnpm@x.y.z" in package.json so 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.node_modules hides phantom deps (imports not in package.json); the migration to pnpm's strict symlinked layout surfaces them as real errors - fix them by adding the dependencies, not by switching to node-linker=hoisted.parseArgs handles options without adding a dependency - const { values } = parseArgs({ args: process.argv.slice(2), options: { port: { type: "string", default: "3000" } } }) - and it automatically skips the node and script path entries.npm run cli --flag passes --flag to npm itself, not your script; use npm run cli -- --flag so the flag reaches the underlying command (pnpm and yarn behave the same way).tsx starts in ~100ms using esbuild and is ideal for local scripts; production should run compiled JavaScript via tsc + node so there is no runner dependency and startup cost stays flat.tsx, ts-node, and node --experimental-strip-types all strip types and hand JavaScript to V8 - none of them catch type errors at runtime, so run tsc --noEmit in CI for real safety.@types/node as a devDependency, process, Buffer, and every node:* import are any, killing autocomplete and hiding real bugs behind silent implicit-any coercions.fs.readFile(path) without an encoding returns a Buffer, not a string - const text = await readFile("config.json", "utf8") - so downstream .split/regex/JSON parsing works without a surprise buffer.process.exit() terminates immediately and truncates pending stdout or async writes - process.exitCode = 1; return - letting the event loop drain preserves logs while still failing the script.process.stdout.isTTY is false, which is the default in most CI environments; set FORCE_COLOR=1 (or equivalent) in the CI env if you want colored log output and remember Chalk v5 is ESM-only.Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥