React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

best-practicessummarynodejs-scripts

Node.js Scripts Best Practices

A condensed summary of the 25 most important best practices drawn from every page in this section.

  1. Use the typescript-eslint Meta-Package: Install 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.
  2. Enable parserOptions.project for Type Rules: Type-aware rules like 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.
  3. Put eslint-config-prettier Last, Isolate ignores: In flat config, 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.
  4. Pick Module System Explicitly: Node chooses per file: .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.
  5. Write Explicit .js Extensions in ESM: Native ESM throws ERR_MODULE_NOT_FOUND without the extension, and under "module": "NodeNext" you must import ./foo.js even when the source file is ./foo.ts because the specifier models the emitted runtime path.
  6. Rebuild __dirname in ESM: __dirname, __filename, and require do not exist in ESM scope, so recover them with fileURLToPath(import.meta.url) and createRequire(import.meta.url) instead of copy-pasting CJS code that silently throws.
  7. Use NodeNext in tsconfig.json: Set both "module": "NodeNext" and "moduleResolution": "NodeNext" so TypeScript faithfully models Node's real resolution — including exports conditions, .mts/.cts, and the required .js extension.
  8. Always Listen for 'error': 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.
  9. 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.
  10. Respect MaxListeners Warnings: Default 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.
  11. Cap HTTP Request Body Size: Manual body concatenation in 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).
  12. 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.
  13. 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, calling reply.send in Fastify) produces hung or doubled responses.
  14. Install Node via a Version Manager: nvm, fnm, or Volta installs into your home directory, skips sudo entirely, and lets you switch Node versions per project; running the official installer plus sudo npm install -g leads straight to EACCES misery.
  15. 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.
  16. Pin packageManager Via Corepack: Set "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.
  17. Fix Phantom Dependencies After Migration: npm's flat hoisted 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.
  18. Parse argv With node:util parseArgs: Built-in parseArgs from node:util handles options, defaults, and validation without adding a dependency, and it automatically skips process.argv[0] (node) and [1] (script path) so you only see user args.
  19. Separate npm Script Flags With --: 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).
  20. Use tsx for Dev, Compiled JS for Prod: 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.
  21. TS Runners Do Not Type-Check: 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.
  22. Install @types/node: Without @types/node as a devDependency, process, Buffer, and every node:* import are any, killing autocomplete and hiding real bugs behind silent implicit-any coercions.
  23. Pass "utf8" to readFile: fs.readFile(path) without an encoding returns a Buffer, not a string; always pass "utf8" (or another explicit encoding) so downstream .split/regex/JSON parsing works without a surprise buffer.
  24. Use process.exitCode, Not process.exit: process.exit() terminates immediately and truncates pending stdout or async writes; setting process.exitCode = 1 and letting the event loop drain preserves logs while still failing the script.
  25. Force Chalk Colors in CI: Chalk suppresses colors when 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.