Node.js Microservice
Overview
A Node service is six things: config, an HTTP surface, state (DB + cache),
async work, outbound calls, and operability. Node's specific hazards are that
one synchronous line blocks every concurrent request (single-threaded event loop), and
that fetch() has no default timeout — so a hung dependency leaks handles until the
process dies.
Verified against the npm registry and nodejs.org, 2026-08-01 — registry facts, not blog
claims: Node 24.18.1 "Krypton" LTS (26.5.1 is current non-LTS; 22 "Jod" in maintenance) ·
TypeScript 7.0.2 · Fastify 5.11.0 · Express 5.2.1 · NestJS 11.1.28 ·
Hono 4.12.33 · Prisma 7.9.1 · Drizzle 0.45.2 · pino 10.3.1 ·
BullMQ 6.0.5 · undici 8.9.0 · Zod 4.4.3 · Vitest 4.1.10.
When to Use
- Starting a Node/TS service, or adding a route/worker to one
- Choosing a framework, ORM, cache, queue, or test runner
- Reviewing Node service code for production-readiness
- Diagnosing: event-loop blocking, memory growth, OOMKills, pool exhaustion in serverless,
unhandled rejections crashing the process, ESM/CJS interop errors
- Containerising a Node service (including the PID-1 signal problem)
Not for: browser/React work (see web-frontend), or CLI tools.
Step 0: Choose the framework
| If… |
Use |
| Default for a new service |
Fastify 5 — schema-based validation and serialisation, fast, TS-friendly |
| Team knows Express; huge middleware ecosystem |
Express 5 — note v5 finally fixes async error handling |
| Large app, many teams, want enforced structure + DI |
NestJS 11 — the structure earns its cost above a certain size |
| Edge/Workers/Lambda, tiny cold start |
Hono — Web-standard Request/Response |
| Existing Koa codebase |
Koa 3 |
Fastify's real differentiator is JSON-schema serialisation — it compiles a serialiser
from your response schema, which is both faster than JSON.stringify and prevents
accidentally leaking fields you didn't declare. That second property is a security feature.
Full comparison in references/frameworks.md.
Quick Reference
| Task |
Reach for |
Detail |
| Framework |
Fastify 5 (default) |
references/frameworks.md |
| Runtime |
Node 24 LTS; declare engines.node |
references/language-runtime.md |
| Validation |
Zod 4 (or Fastify JSON schema) |
references/frameworks.md |
| DB |
Prisma 7 or Drizzle; Kysely for pure SQL |
references/persistence.md |
| Pooling in serverless |
PgBouncer / RDS Proxy / Accelerate |
references/persistence.md |
| Cache |
ioredis, client reused |
references/caching-redis.md |
| Jobs |
BullMQ 6 in a separate worker process |
references/messaging.md |
| Outbound HTTP |
undici 8 + AbortSignal.timeout() |
references/http-clients.md |
| Logging |
pino 10 → JSON on stdout |
references/observability.md |
| Request context |
AsyncLocalStorage |
references/observability.md |
| Metrics/tracing |
prom-client, OTel Node SDK |
references/observability.md |
| Tests |
Vitest 4 or node:test; testcontainers |
references/testing.md |
| Container |
multi-stage, node:24-slim, --init |
references/packaging-deploy.md |
Bootstrap a new service
myservice/
package.json # "type": "module", engines.node pinned
src/
index.ts # bootstrap + graceful shutdown only
config.ts # Zod-validated env, parsed at import
server.ts # framework wiring, routes, error handler
routes/
services/ # business logic — no HTTP or SQL types
repositories/ # DB access
clients/ # outbound HTTP
test/
Dockerfile
"type": "module" and engines.node: ">=24". Decide ESM once — the mixed state is
the top source of "works locally, breaks in CI".
- Zod-parse
process.env at import so a missing var crashes at boot, not on the first
request that needs it.
- One undici
Agent and one DB pool, created once and injected.
AsyncLocalStorage for the request ID; pino child logger bound to it.
/healthz (liveness, process-local) and /readyz (readiness, checks deps).
- Graceful shutdown: fail readiness → LB propagation delay →
server.close() → drain →
close pools.
--init (or dumb-init) in the container, because Node as PID 1 doesn't reap zombies and
mishandles signals.
The non-negotiables
- Every
fetch/undici call has a timeout. fetch() has none by default. Use
AbortSignal.timeout(ms). In real codebases this is the most-violated rule — one audit
found 45 fetch calls and a single AbortController.
- Never block the event loop. No sync crypto, no sync
fs in a request path, no
CPU-bound loops. Offload to worker_threads.
- Config validated at boot. Fail fast.
- One client per process, created at startup — never per request. A per-request client
discards connection pooling entirely.
AsyncLocalStorage for request context. A module-level variable does not survive
await — it will silently serve one request's ID to another.
- Handle
unhandledRejection. Modern Node crashes the process by default. Know
whether you want that (usually yes — but log it first).
--max-old-space-size below the container limit. V8 otherwise sizes the heap from the
host and gets OOMKilled.
--init in the container. Node as PID 1 leaves zombies and mishandles SIGTERM.
- Idempotent queue consumers. BullMQ retries; at-least-once is what you get.
- Liveness must not check dependencies. One DB blip otherwise restarts every pod and
turns a blip into an outage.
Reference Map
| File |
Read when |
| references/language-runtime.md |
Node versions, event loop, workers, ESM/CJS, memory, AsyncLocalStorage, TS 7 |
| references/frameworks.md |
Framework comparison and depth; Express 5 changes; validation |
| references/persistence.md |
Prisma/Drizzle/Kysely, pooling (incl. serverless), transactions, migrations |
| references/caching-redis.md |
Client config, cache-aside, stampede, locks, eviction |
| references/messaging.md |
BullMQ, SQS/Kafka, idempotency, DLQ, worker shutdown |
| references/http-clients.md |
fetch timeouts, undici, retries, breakers, pooling |
| references/observability.md |
pino, AsyncLocalStorage IDs, metrics, OTel, health endpoints |
| references/testing.md |
Vitest/node:test, testcontainers, HTTP tests, mocking |
| references/packaging-deploy.md |
package.json, Docker, PID 1, graceful shutdown, config |
Common Mistakes
| Mistake |
Why it hurts |
Fix |
fetch(url) with no signal |
No default timeout — hangs forever, leaks the handle |
AbortSignal.timeout(5000) |
axios and fetch in one codebase |
Two timeout stories, two error shapes, two interceptor mechanisms |
Pick one — undici in Node |
| Client created per request |
Discards connection pooling; TLS handshake every call |
Construct once, inject |
| Module-level "current request" variable |
Silently leaks context across concurrent requests |
AsyncLocalStorage |
| Sync work in a handler |
Blocks every concurrent request |
worker_threads / async APIs |
No engines.node |
npm won't warn; prod silently differs from local |
Declare it |
| ESM/CJS decided per-file |
Import errors that only appear in CI |
"type": "module" project-wide |
| Prisma default pool in Lambda |
Each invocation opens a pool → Postgres exhausted |
PgBouncer/RDS Proxy/Accelerate |
No --max-old-space-size |
V8 sizes from the host, not the cgroup → OOMKill |
Set below the container limit |
Node as PID 1 without --init |
Zombies accumulate; SIGTERM mishandled |
--init or dumb-init |
console.log in production |
Unstructured, unqueryable, sync on some streams |
pino, JSON to stdout |
| Migrations from app startup |
N replicas race the same migration |
Separate job/step |
| Liveness probe hitting the DB |
One blip restarts every pod at once |
Liveness = process-local only |
| Raw path in a metric label |
Unbounded cardinality kills Prometheus |
Route template |
| Zod majors split across repos |
Schemas can't be shared across services |
Align majors org-wide |
1---2name: node-microservice3description: Use when building, scaffolding, or reviewing a Node.js/TypeScript backend service or API — choosing between Express 5, Fastify 5, NestJS 11, Hono or Koa; Prisma 7/Drizzle/Kysely and connection pooling; Redis caching; BullMQ workers; undici and fetch timeouts; pino structured logging; AsyncLocalStorage correlation IDs; OpenTelemetry; Vitest/node:test; ESM vs CommonJS; Dockerfiles and graceful shutdown for Node. Also for "start a Node service", "fetch has no timeout", "Node service OOMKilled", "event loop blocked", "Prisma connection pool exhausted in serverless".4---56# Node.js Microservice78## Overview910A Node service is six things: **config**, an **HTTP surface**, **state** (DB + cache),11**async work**, **outbound calls**, and **operability**. Node's specific hazards are that12**one synchronous line blocks every concurrent request** (single-threaded event loop), and13that **`fetch()` has no default timeout** — so a hung dependency leaks handles until the14process dies.1516Verified against the **npm registry and nodejs.org, 2026-08-01** — registry facts, not blog17claims: Node **24.18.1 "Krypton" LTS** (26.5.1 is current non-LTS; 22 "Jod" in maintenance) ·18TypeScript **7.0.2** · Fastify **5.11.0** · Express **5.2.1** · NestJS **11.1.28** ·19Hono **4.12.33** · Prisma **7.9.1** · Drizzle **0.45.2** · pino **10.3.1** ·20BullMQ **6.0.5** · undici **8.9.0** · Zod **4.4.3** · Vitest **4.1.10**.2122## When to Use2324- Starting a Node/TS service, or adding a route/worker to one25- Choosing a framework, ORM, cache, queue, or test runner26- Reviewing Node service code for production-readiness27- Diagnosing: event-loop blocking, memory growth, OOMKills, pool exhaustion in serverless,28 unhandled rejections crashing the process, ESM/CJS interop errors29- Containerising a Node service (including the PID-1 signal problem)3031**Not for:** browser/React work (see `web-frontend`), or CLI tools.3233## Step 0: Choose the framework3435| If… | Use |36|---|---|37| Default for a new service | **Fastify 5** — schema-based validation *and* serialisation, fast, TS-friendly |38| Team knows Express; huge middleware ecosystem | **Express 5** — note v5 finally fixes async error handling |39| Large app, many teams, want enforced structure + DI | **NestJS 11** — the structure earns its cost above a certain size |40| Edge/Workers/Lambda, tiny cold start | **Hono** — Web-standard `Request`/`Response` |41| Existing Koa codebase | **Koa 3** |4243**Fastify's real differentiator is JSON-schema serialisation** — it compiles a serialiser44from your response schema, which is both faster than `JSON.stringify` and prevents45accidentally leaking fields you didn't declare. That second property is a security feature.46Full comparison in references/frameworks.md.4748## Quick Reference4950| Task | Reach for | Detail |51|---|---|---|52| Framework | Fastify 5 (default) | references/frameworks.md |53| Runtime | **Node 24 LTS**; declare `engines.node` | references/language-runtime.md |54| Validation | **Zod 4** (or Fastify JSON schema) | references/frameworks.md |55| DB | **Prisma 7** or Drizzle; Kysely for pure SQL | references/persistence.md |56| Pooling in serverless | PgBouncer / RDS Proxy / Accelerate | references/persistence.md |57| Cache | ioredis, client reused | references/caching-redis.md |58| Jobs | **BullMQ 6** in a separate worker process | references/messaging.md |59| Outbound HTTP | **undici 8** + `AbortSignal.timeout()` | references/http-clients.md |60| Logging | **pino 10** → JSON on stdout | references/observability.md |61| Request context | **AsyncLocalStorage** | references/observability.md |62| Metrics/tracing | prom-client, OTel Node SDK | references/observability.md |63| Tests | **Vitest 4** or `node:test`; testcontainers | references/testing.md |64| Container | multi-stage, `node:24-slim`, **`--init`** | references/packaging-deploy.md |6566## Bootstrap a new service6768```69myservice/70 package.json # "type": "module", engines.node pinned71 src/72 index.ts # bootstrap + graceful shutdown only73 config.ts # Zod-validated env, parsed at import74 server.ts # framework wiring, routes, error handler75 routes/76 services/ # business logic — no HTTP or SQL types77 repositories/ # DB access78 clients/ # outbound HTTP79 test/80 Dockerfile81```82831. **`"type": "module"`** and **`engines.node: ">=24"`**. Decide ESM once — the mixed state is84 the top source of "works locally, breaks in CI".852. **Zod-parse `process.env` at import** so a missing var crashes at boot, not on the first86 request that needs it.873. One **undici `Agent`** and one DB pool, created once and injected.884. `AsyncLocalStorage` for the request ID; pino child logger bound to it.895. `/healthz` (liveness, process-local) and `/readyz` (readiness, checks deps).906. **Graceful shutdown**: fail readiness → LB propagation delay → `server.close()` → drain →91 close pools.927. `--init` (or dumb-init) in the container, because **Node as PID 1 doesn't reap zombies and93 mishandles signals**.9495## The non-negotiables96971. **Every `fetch`/undici call has a timeout.** `fetch()` has **none** by default. Use98 `AbortSignal.timeout(ms)`. In real codebases this is the most-violated rule — one audit99 found 45 `fetch` calls and a single `AbortController`.1002. **Never block the event loop.** No sync crypto, no sync `fs` in a request path, no101 CPU-bound loops. Offload to `worker_threads`.1023. **Config validated at boot.** Fail fast.1034. **One client per process**, created at startup — never per request. A per-request client104 discards connection pooling entirely.1055. **`AsyncLocalStorage` for request context.** A module-level variable does **not** survive106 `await` — it will silently serve one request's ID to another.1076. **Handle `unhandledRejection`.** Modern Node **crashes the process** by default. Know108 whether you want that (usually yes — but log it first).1097. **`--max-old-space-size` below the container limit.** V8 otherwise sizes the heap from the110 host and gets OOMKilled.1118. **`--init` in the container.** Node as PID 1 leaves zombies and mishandles SIGTERM.1129. **Idempotent queue consumers.** BullMQ retries; at-least-once is what you get.11310. **Liveness must not check dependencies.** One DB blip otherwise restarts every pod and114 turns a blip into an outage.115116## Reference Map117118| File | Read when |119|---|---|120| references/language-runtime.md | Node versions, event loop, workers, ESM/CJS, memory, AsyncLocalStorage, TS 7 |121| references/frameworks.md | Framework comparison and depth; Express 5 changes; validation |122| references/persistence.md | Prisma/Drizzle/Kysely, pooling (incl. serverless), transactions, migrations |123| references/caching-redis.md | Client config, cache-aside, stampede, locks, eviction |124| references/messaging.md | BullMQ, SQS/Kafka, idempotency, DLQ, worker shutdown |125| references/http-clients.md | **fetch timeouts**, undici, retries, breakers, pooling |126| references/observability.md | pino, AsyncLocalStorage IDs, metrics, OTel, health endpoints |127| references/testing.md | Vitest/node:test, testcontainers, HTTP tests, mocking |128| references/packaging-deploy.md | package.json, Docker, PID 1, graceful shutdown, config |129130## Common Mistakes131132| Mistake | Why it hurts | Fix |133|---|---|---|134| `fetch(url)` with no signal | **No default timeout** — hangs forever, leaks the handle | `AbortSignal.timeout(5000)` |135| `axios` and `fetch` in one codebase | Two timeout stories, two error shapes, two interceptor mechanisms | Pick one — undici in Node |136| Client created per request | Discards connection pooling; TLS handshake every call | Construct once, inject |137| Module-level "current request" variable | Silently leaks context across concurrent requests | `AsyncLocalStorage` |138| Sync work in a handler | Blocks **every** concurrent request | `worker_threads` / async APIs |139| No `engines.node` | npm won't warn; prod silently differs from local | Declare it |140| ESM/CJS decided per-file | Import errors that only appear in CI | `"type": "module"` project-wide |141| Prisma default pool in Lambda | Each invocation opens a pool → Postgres exhausted | PgBouncer/RDS Proxy/Accelerate |142| No `--max-old-space-size` | V8 sizes from the host, not the cgroup → OOMKill | Set below the container limit |143| Node as PID 1 without `--init` | Zombies accumulate; SIGTERM mishandled | `--init` or dumb-init |144| `console.log` in production | Unstructured, unqueryable, sync on some streams | pino, JSON to stdout |145| Migrations from app startup | N replicas race the same migration | Separate job/step |146| Liveness probe hitting the DB | One blip restarts every pod at once | Liveness = process-local only |147| Raw path in a metric label | Unbounded cardinality kills Prometheus | Route template |148| Zod majors split across repos | Schemas can't be shared across services | Align majors org-wide |