Async & Event Loop Rules
Node serves concurrent I/O on a single thread per process. These rules keep the event loop responsive under load.
Recipe
Quick-reference recipe card - copy-paste ready.
import { readFile } from "node:fs/promises";
import pLimit from "p-limit";
const limit = pLimit(10);
export async function processBatch(ids: string[]) {
await Promise.all(ids.map((id) => limit(() => handleOne(id))));
}When to reach for this:
- Request handlers perform file, network, or CPU work.
- Workers process queue messages in parallel.
- Latency spikes appear under concurrent load.
Working Example
// src/files/serve-config.ts - GOOD
import { readFile } from "node:fs/promises";
let cached: string | null = null;
export async function getConfig(): Promise<string> {
if (cached) return cached;
cached = await readFile(new URL("./defaults.json", import.meta.url), "utf8");
return cached;
}// BAD - blocks event loop per request
import { readFileSync } from "node:fs";
export function getConfigSync() {
return readFileSync("./defaults.json", "utf8");
}// src/workers/outbound.ts
import pLimit from "p-limit";
const httpLimit = pLimit(20);
export async function notifyAll(urls: string[]) {
await Promise.all(
urls.map((url) =>
httpLimit(async () => {
const res = await fetch(url, { signal: AbortSignal.timeout(5_000) });
if (!res.ok) throw new Error(`notify failed ${res.status}`);
}),
),
);
}What this demonstrates:
- Async file read with module-level cache after first load.
p-limitcaps concurrent outbound HTTP to 20.AbortSignal.timeoutprevents hung promises.
Deep Dive
How It Works
- JavaScript runs on one thread; long synchronous work delays all requests.
- Async I/O delegates to libuv thread pool / kernel; callbacks resume on loop.
Promise.allon 10,000 tasks still schedules 10,000 operations - bound concurrency.- CPU-heavy work belongs in
worker_threadsor external workers.
Rules at a Glance
| Rule | Rationale |
|---|---|
| No sync fs/crypto in handlers | Blocks loop for all clients |
Always await promises | Unhandled async errors and fire-and-forget bugs |
| Bound parallel outbound I/O | Protects sockets and upstreams |
| Timeouts on network calls | Prevents stuck requests occupying memory |
| CPU crunch off main thread | JSON parse 50MB payload blocks everyone |
TypeScript Notes
- Enable
@typescript-eslint/no-floating-promisesin CI. - Type
limitwrappers returnPromise<T>from bounded tasks.
Gotchas
- Fire-and-forget async -
void doWork()loses errors. Fix:await,.catchwith log, or queue job. - Sync bcrypt in auth route - 100ms CPU per login under load. Fix: async hash or worker pool.
- Unbounded
Promise.allon DB - Exhausts connection pool. Fix: pool size + concurrency limit aligned. - Microtask starvation - Infinite
queueMicrotaskloop. Fix: code review; never recursive sync microtasks. - Blocking JSON.parse on huge body - Effectively sync CPU. Fix: size limits at reverse proxy and parser middleware.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
worker_threads | CPU-bound transforms | Simple CRUD API |
| External queue (BullMQ) | Heavy async work | Inline work < 50ms |
| Cluster module | CPU scale on single host | I/O-bound APIs (use horizontal pods) |
FAQs
Is fs.promises always non-blocking?
It offloads to thread pool; still limited pool size. Cache small config files; stream large files.
What concurrency limit for fetch?
Start 10-50 based on upstream tolerance; monitor 429/503 and adjust.
Does Fastify handle concurrency differently?
Same event loop rules; Fastify reduces per-request overhead but does not remove blocking sync code risk.
How do I detect event loop lag?
perf_hooks.monitorEventLoopDelay or APM event loop metrics in production.
Are NestJS interceptors async-safe?
Yes if they await; avoid sync work in interceptors and guards.
setImmediate vs process.nextTick?
Prefer setImmediate for deferring work; nextTick runs before I/O and can starve loop if abused.
Streams for large files?
Use fs.createReadStream instead of reading whole file into memory.
Promise.all vs allSettled?
all fails fast on first error; allSettled for batch notifications where partial success is OK.
How do timers affect shutdown?
Clear intervals on SIGTERM; see graceful shutdown runbooks in runtime-ops section.
Is top-level await OK?
Yes in ESM modules for startup config load; not per-request path.
Related
- Node Project Rules Checklist - rules 4-5
- API Rules - timeout rules for handlers
- Logging & Observability Rules - log loop lag metrics
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.