Reviewing Node.js runtime code - what to flag beyond the language, and what the toolchain already flags USE WHEN: you are reviewing, critiquing or auditing existing Node.js code - a "code review" of server code, a CLI, a script, a diff or a PR - and the concern is the RUNTIME: the event loop, streams, processes, file handles, EventEmitter, unhandled rejections DO NOT USE FOR: language-level review - use `review/typescript` for typed code; Express/NestJS/Fastify routing - use the framework s...
Scanned 9/8/2026
Install to Claude Code
npx -y skills add claude-dev-suite/claude-dev-suite --skill nodejs --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Nodejs?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/claude-dev-suite-nodejs-b68b01c8)More formats (shields.io, HTML) on the badges page.
---
name: nodejs-review
description: |
Reviewing Node.js runtime code - what to flag beyond the language, and what the toolchain already flags
USE WHEN: you are reviewing, critiquing or auditing existing Node.js code - a
"code review" of server code, a CLI, a script, a diff or a PR - and the
concern is the RUNTIME: the event loop, streams, processes, file handles,
EventEmitter, unhandled rejections
DO NOT USE FOR: language-level review - use `review/typescript` for typed code;
Express/NestJS/Fastify routing - use the framework skills; writing or learning
Node - use `languages/nodejs`; browser JavaScript, which has none of this
allowed-tools: Read, Grep, Glob
---
# Reviewing Node.js
This skill is about the **runtime**, not the language. Type and syntax findings
belong in `review/typescript`; what follows are defects that exist because the
code runs on a single-threaded event loop with an async I/O layer, and would not
exist in the same code running anywhere else.
The organising fact: **one process, one thread for your code**. Almost every
check below is a way of occupying that thread, or of losing track of work
scheduled on it.
The snippets are fragments cut down to the defect, not runnable programs.
## Already covered - do not spend review on it
| Defect | Reported by |
|---|---|
| Unhandled promise rejection | the runtime — **crashes the process since Node 15**, so it is loud |
| A promise created and not awaited | `@typescript-eslint/no-floating-promises` — type-aware, so only if configured |
| `require` of a missing module | the runtime, at load |
| Callback called twice, callback never called | nothing |
| Blocking the event loop | nothing |
| A stream written faster than it drains | nothing |
| A listener added per request | nothing until the `MaxListenersExceededWarning`, which is a warning about a symptom |
Rows 4 to 7 are the reason this skill exists: the runtime's most expensive
mistakes have no diagnostic at all.
## The checks that earn their place
### Synchronous I/O on a request path
```js
const cfg = fs.readFileSync(path); // blocks every other request
const hash = crypto.pbkdf2Sync(pw, salt, 100000, 64, 'sha512');
```
**When you see it**: any `*Sync` call, `execSync`, or a CPU-bound loop inside a
handler, a middleware, or a module that runs per request.
**Ask**: is this on the startup path or the request path? At startup, `Sync` is
often the right choice and worth no comment. Per request, it stops the entire
process — not just this request — and the latency lands on unrelated endpoints,
which is why it is so hard to diagnose from the symptom.
### A stream piped without backpressure
```js
source.on('data', chunk => dest.write(chunk)); // ignores write() returning false
```
**When you see it**: a manual `data`/`write` loop instead of `pipe` or
`pipeline`; `res.write` in a loop over a large result set.
**Ask**: what happens when the destination is slower than the source? `write`
returning `false` means "buffer full"; ignoring it grows the buffer in memory
until the process dies. `pipeline()` also propagates errors and destroys the
streams, which a bare `pipe` does not.
### An error listener that is not attached
```js
const s = fs.createReadStream(p);
s.pipe(res); // no 's.on("error", ...)'
```
**When you see it**: a stream, a socket, or a child process without an `error`
handler.
**Ask**: what happens on ENOENT? An `error` event with no listener is rethrown
as an uncaught exception and takes the process down. `pipeline(a, b, cb)` is the
form that routes it.
### A listener added per request or per call
```js
app.get('/x', (req, res) => {
emitter.on('update', () => res.write('...')); // never removed
});
```
**When you see it**: `.on(...)` inside a handler, a loop, or a reconnect path;
`process.on('unhandledRejection', ...)` registered in a module loaded many
times.
**Ask**: who removes this? Listeners accumulate on the emitter, hold the closure
and everything it captured, and the only signal is a
`MaxListenersExceededWarning` at 11 — which is a warning about the leak's
*size*, not its existence. `once` or an explicit `off` in a finally.
### `process.exit()` with output still buffered
```js
console.log(report);
process.exit(0); // stdout to a pipe is async: the write can be lost
```
**When you see it**: `process.exit` immediately after writing output, or inside
a callback.
**Ask**: is stdout a TTY or a pipe here? To a terminal it is synchronous and the
output appears; to a pipe or a file it is asynchronous, and `exit` truncates it.
The bug shows up only when the CLI is used in a script — which is the case that
matters. Setting `process.exitCode` and returning lets the loop drain.
### `spawn` with a shell, or with unvalidated arguments
```js
exec(`convert ${userFile} out.png`); // a shell parses this
spawn('convert', [userFile, 'out.png']); // no shell: argv is passed directly
```
**When you see it**: `exec`, `execSync`, or `spawn(..., { shell: true })` with
anything interpolated.
**Ask**: does a shell see this string? `exec` always spawns one, so `;`, `$()`
and backticks in the value are commands. `spawn` with an argv array and
`shell: false` (the default) removes the whole category — and is also faster.
### Unbounded concurrency in a fan-out
```js
await Promise.all(urls.map(u => fetch(u))); // 10,000 sockets at once
```
**When you see it**: `Promise.all` over an array whose length comes from data
rather than from a constant.
**Ask**: how large can this get? Node will happily open every connection at
once, exhausting file descriptors or the remote's rate limit. A concurrency
limit makes the behaviour a decision instead of an accident. (`Promise.all`'s
partial-failure semantics are a separate concern — see `review/typescript`.)
### A file handle or timer left open on the error path
```js
const fh = await fs.promises.open(p);
const data = await parse(fh); // throws: the handle is never closed
await fh.close();
```
**When you see it**: `open`, `setInterval`, a socket or a DB connection acquired
without `try/finally`.
**Ask**: does every path release it? An open handle keeps the event loop alive,
so the symptom is often a process that will not exit rather than an error. An
un-`unref`'d `setInterval` does the same thing.
### Module-scope state in a multi-instance deployment
```js
let cache = {}; // per process, not per app
export function get(k) { return cache[k]; }
```
**When you see it**: mutable module-level state used as a cache, a rate limiter,
a session store, or a lock.
**Ask**: how many processes run this? Under `cluster`, PM2, or any horizontally
scaled deployment, each has its own copy — so a rate limiter allows N times the
limit and a lock locks nothing. It works perfectly on one dev machine.
## Version- and deployment-dependent
| What to read | Why it changes the review |
|---|---|
| `engines` in `package.json`, the CI Node version | Unhandled rejections terminate the process from Node 15; `fetch` is global from 18; `--experimental-strip-types` and stable test runner from 20/22. Do not suggest below the floor |
| `type: module` vs CommonJS | Decides whether top-level `await` exists and whether `__dirname` does |
| Is this clustered or replicated? | Decides whether the module-state finding is real |
| Is it a CLI or a server? | `*Sync` at startup is fine in both; `process.exit` truncation matters mainly for CLIs whose output is piped |
| Worker threads available? | The answer to CPU-bound work, and it changes the advice for the blocking check |
## What to say
Anchor the comment to the line, name the condition that triggers the defect, and
say what breaks: "`pbkdf2Sync` here blocks the event loop for ~100 ms, so every
concurrent request — including health checks — stalls behind it" beats "avoid
sync APIs". If you cannot state the input that fails, it is a preference, not a
defect.
Distinguish startup from request path before commenting on anything synchronous.
That single distinction removes most false positives in Node review.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!