Production-ready Node.js patterns — ESM, native test runner, built-in APIs, async control flow, process management, and security for modern Node.js applications
npx skills add m10rten/typescript-bits --skill nodejs-best-practicesskill.md · 518 lines~3.9kPractical, production-ready Node.js patterns for building robust, maintainable server-side applications. Apply these when writing or reviewing Node.js code.
{
"type": "module"
}`import`/`export` syntax throughout — no `require()`.`node:` protocol: `import { readFile } from "node:fs"`.`.js`, `.ts`, `.mjs`). Never omit extensions.`import type` / `export type` for type-only imports to keep runtime bundles clean.Use `package.json` `exports` field for dual ESM/CJS distribution or subpath exports:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js"
}
}
}`import()` in hot paths — prefer static top-level imports.`import.meta.url` instead of `dirname` / `filename`:import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);`import.meta.dirname` and `import.meta.filename` directly — no `fileURLToPath` + `dirname` conversion needed.`node:test` and `node:assert`Prefer Node's built-in test runner over third-party frameworks (Jest, Mocha, Vitest):
import { describe, it, before, after, mock } from "node:test";
import assert from "node:assert/strict";
import { myModule } from "../src/index.js";
describe("myModule", () => {
it("handles the basic case", () => {
assert.strictEqual(myModule(1), 2);
});
});`describe` for grouping, `it` for individual test cases.`node:assert/strict` (deepStrictEqual, strictEqual) over the base `node:assert`.`for` loops for repetitive tests:const edgecases = [
{ input: null, expected: "null" },
{ input: undefined, expected: "undefined" },
{ input: "", expected: "empty" },
];
for (const { input, expected } of edgecases) {
it(`handles ${expected}`, () => {
assert.strictEqual(myFn(input), expected);
});
}Use `mock.fn()` and `mock.method()` from `node:test` — avoid external mocking libraries:
import { mock } from "node:test";
const fn = mock.fn((x: number) => x * 2);
assert.strictEqual(fn(3), 6);
assert.strictEqual(fn.mock.calls.length, 1);`fs.promises` API over `fs.callback` API.`util.promisify` when needed.Use `for await...of` for streams and async generators over `.on("data")` / `.on("end")` patterns:
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
async function processLines(filePath: string): Promise<void> {
const rl = createInterface({ input: createReadStream(filePath) });
for await (const line of rl) {
// process line
}
}// Parallel — no ordering needed
const [a, b] = await Promise.all([fetchA(), fetchB()]);
// Sequential — each depends on prior
const a = await fetchA();
const b = await fetchB(a);
// Race with timeout
const result = await Promise.race([
fetchData(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000)),
]);Node 22+: `Promise.withResolvers()` replaces the deferred pattern:
const { promise, resolve, reject } = Promise.withResolvers();`AbortSignal.timeout(5000)` is the simplest timeout — simpler than `Promise.race`:
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });`await` or `.catch()` promises — never leave them dangling.process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
process.exit(1);
});| Type | Examples | Handling |
|---|---|---|
| Operational | Network failure, file not found | Recover, retry, or return safe error to user |
| Programmer | `undefined` access, invalid args | Crash fast — don't recover |
export class AppError extends Error {
readonly code: string;
readonly statusCode: number;
readonly context?: Record<string, unknown>;
constructor(message: string, code: string, statusCode = 500, context?: Record<string, unknown>) {
super(message);
this.name = "AppError";
this.code = code;
this.statusCode = statusCode;
this.context = context;
}
}Use discriminated union `Result` types for expected failures (network, parsing, validation) and reserve `throw` for programmer errors:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
const parsed = parseJSON(input);
if (!parsed.ok) {
return { ok: false, error: new AppError("Invalid JSON", "PARSE_ERROR", 400) };
}try {
await operation();
} catch (err: unknown) {
if (err instanceof AppError) throw err;
if (err instanceof SyntaxError) return handleSyntax(err);
throw new AppError("Unexpected error", "INTERNAL", 500, { cause: err });
}`process.env` at startup, not inline.function getEnv(name: string, fallback?: string): string {
const value = process.env[name] ?? fallback;
if (value === undefined) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
const config = {
port: Number(getEnv("PORT", "3000")),
nodeEnv: getEnv("NODE_ENV", "development"),
};Use meaningful exit codes:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Misuse of shell builtins |
| 126 | Command cannot execute |
| 127 | Command not found |
| 130 | Terminated by Ctrl+C |
function shutdown(signal: string) {
console.log(`Received ${signal}, shutting down gracefully...`);
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 10000); // force exit after 10s
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));`pino`, `bunyan`) over `console.log` in production.`console.log` in library code — accept a logger or use the `node:events` pattern.`--test-reporter` for structured output — use it instead of ad-hoc formatting.| Need | Built-in | Instead of |
|---|---|---|
| HTTP server | `node:http` | express |
| HTTP/2 server | `node:http2` | spdy |
| File system | `node:fs` (promises) | fs-extra |
| SQLite (Node 22.5+) | `node:sqlite` | better-sqlite3 |
| Path manipulation | `node:path` | path-to-regexp |
| URL parsing | `node:url` | urijs |
| Command-line parsing | `node:util.parseArgs` | commander, yargs |
| Assertions / testing | `node:assert`, `node:test` | jest, mocha, chai |
| Event system | `node:events` (EventEmitter) | eventemitter3 |
| Streaming | `node:stream` (web streams) | through2, pump |
| Crypto | `node:crypto` | bcrypt (use scrypt) |
| Subtle Crypto (Web) | `crypto.subtle` | forge, node-webcrypto-ossl |
| UUID | `node:crypto.randomUUID` | uuid |
| Environment variables | `process.env` | dotenv (Node 20+ loads .env natively) |
`Buffer.from()` / `Buffer.alloc()` over the `new Buffer()` constructor.`ReadableStream` / `WritableStream` APIs in Node 20+ for cross-platform code.import { Buffer } from "node:buffer";
import { TextEncoder, TextDecoder } from "node:util";
const encoded = new TextEncoder().encode("hello");
const decoded = new TextDecoder().decode(encoded);import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
async function readConfig(path: string): Promise<Config> {
const content = await readFile(path, "utf-8");
return JSON.parse(content);
}`readFileSync`, `writeFileSync`, `existsSync` are acceptable in:- CLI tools and scripts
- Startup/initialization (before the server starts)
- Build-time tooling
Never pass unsanitized user input to shell commands:
// ❌ Dangerous
import { exec } from "node:child_process";
exec(`ls ${userInput}`); // userInput could be "; rm -rf /"
// ✅ Safe — use execFile with arguments array
import { execFile } from "node:child_process";
execFile("ls", [userInput]); // arguments are escaped
// ✅ Safe — use spawn with arguments array
import { spawn } from "node:child_process";
spawn("ls", [userInput]);import { resolve, relative } from "node:path";
function safePath(base: string, userPath: string): string {
const full = resolve(base, userPath);
if (!full.startsWith(resolve(base))) {
throw new Error("Path traversal detected");
}
return full;
}`JSON.parse` without validation.`EventEmitter` for pub/sub objects, not custom listener implementations — unless you need stricter control.`once()` for one-shot listeners, `on()` for persistent ones.`Symbol.dispose` or explicit cleanup method:import { EventEmitter } from "node:events";
class MyService extends EventEmitter {
#cleanup: (() => void)[] = [];
start(): void {
const listener = (data: unknown) => this.#onData(data);
someSource.on("data", listener);
this.#cleanup.push(() => someSource.off("data", listener));
}
[Symbol.dispose](): void {
for (const cleanup of this.#cleanup) cleanup();
this.removeAllListeners();
}
}`maxListeners` warning threshold to detect leaks:import { EventEmitter } from "node:events";
const emitter = new EventEmitter();
emitter.setMaxListeners(20); // silence warning if intentional`node:worker_threads`:import { Worker, parentPort, workerData } from "node:worker_threads";
// Main thread
const worker = new Worker("./cpu-work.js", { workerData: input });
worker.on("message", (result) => console.log(result));
// cpu-work.js — worker thread
parentPort.on("message", (data) => {
parentPort.postMessage(expensiveComputation(data));
});Use `SharedArrayBuffer` for zero-copy sharing of large data between threads.
`setImmediate()` to break up synchronous work:function processInBatches(items: big[], batchSize = 1000): void {
let index = 0;
function nextBatch(): void {
const batch = items.slice(index, index + batchSize);
index += batchSize;
for (const item of batch) process(item);
if (index < items.length) setImmediate(nextBatch);
}
nextBatch();
}Reuse connections, file handles, and workers — don't create/destroy per request:
// ❌ Creates new connection per request
app.get("/data", async () => {
const conn = await createConnection();
const result = await conn.query("...");
await conn.close();
return result;
});
// ✅ Pooled connection
const pool = new ConnectionPool({ max: 10 });
app.get("/data", async () => {
const conn = await pool.acquire();
try {
return await conn.query("...");
} finally {
pool.release(conn);
}
});`util.parseArgs`import { parseArgs } from "node:util";
const { values, positionals } = parseArgs({
options: {
output: { type: "string", short: "o", default: "dist" },
verbose: { type: "boolean", short: "v", default: false },
},
allowPositionals: true,
});`process.on("exit")` for synchronous cleanup only — async cleanup needs `SIGINT`/`SIGTERM` handlers.`process.exit()` in library code.| Mistake | Fix |
|---|---|
Using `require()` instead of `import` | Enable `"type": "module"` in package.json |
| Omitting file extensions in imports | Always add `.js` extension |
| Unhandled promise rejections | Always `await` or `.catch()` |
Using `fs.readFileSync` in request handlers | Use `fs.promises.readFile` with `await` |
Shell injection via template strings in `exec` | Use `execFile` with arguments array |
Assuming `err` is always an `Error` instance | Narrow with `instanceof Error` or check shape |
| Not cleaning up EventEmitter listeners | Track listeners and remove on cleanup/dispose |
| Using third-party packages for built-in capabilities | Check Node.js docs first — prefer native |
| Blocking the event loop with CPU-heavy work | Offload to worker threads or chunk with `setImmediate` |
Missing `await` in `try/catch` | The caught error may be a rejected promise, not an Error |