# Node.js

The Node adapter runs your REST API on the built-in `node:http` server. It's the default target for containers, VMs, and any Node-based PaaS (Heroku, Railway, Render, Fly.io). Use it when you control the process, long-lived, observable, and easy to debug.

**Diagram: Node adapter request path**

- **HTTP request** (client) - TCP socket
- **serve(app)** (node:http) - timeouts · maxConnections
- **app.fetch(Request)** (core) - routing · hooks · handler
- **Response** (client)

The adapter turns a node:http socket into a web-standard Request, applies production timeouts and optional connection-layer admission control, then hands off to app.fetch. SIGTERM and SIGINT drain in-flight requests before closing.

## When to choose Node

- You deploy to a container, VM, or Node PaaS (no per-request billing).
- You need `node:*` modules (filesystem, child processes, native addons).
- You want the broadest npm package compatibility.

## Scaffold

The fastest way to start is the `node-basic` template. It ships with TypeScript, pnpm workspaces, a `/healthz` route, graceful shutdown, and Hey API codegen wired up.

```bash
pnpm create daloy@latest my-api --template node-basic
cd my-api
pnpm dev    # hot-reload via daloy dev
```

## Install

Requires **Node.js 24 LTS or Node.js 26+**. The adapter ships with `@daloyjs/core`; no extra dependency.

```bash
pnpm add @daloyjs/core
```

## Minimal server

```ts
// src/server.ts
import { serve } from "@daloyjs/core/node";
import { app } from "./app.ts";

const { port, close } = serve(app, {
  port: Number(process.env.PORT ?? 3000),
  hostname: "0.0.0.0",
  connectionTimeoutMs: 30_000,
  shutdownTimeoutMs: 10_000,
  handleSignals: true,       // SIGTERM / SIGINT trigger graceful shutdown
  maxHeaderBytes: 16 * 1024, // 16 KiB cap (default)
  trustProxy: false,         // set true only behind a trusted reverse proxy
  maxConnections: 200,       // optional: cap concurrent sockets (off by default)
});

console.log(`listening on :${port}`);

// later - drain in-flight requests, then close
await close();
```

## What the adapter wires for you

- `requestTimeout`, `headersTimeout`, and `keepAliveTimeout` set to safe production values. Both request timeouts derive from `connectionTimeoutMs`, and the adapter also lowers Node's connection-check interval to a fraction of that value so a slowloris (a client that stalls or trickles its request headers to hold a socket open) is reaped close to the deadline with a `408`, instead of surviving until Node's default 30 second sweep. Set `connectionTimeoutMs: 0` to disable the timeouts entirely.
- SIGTERM / SIGINT handlers that call `server.close()` followed by `server.closeAllConnections()` after `shutdownTimeoutMs`: the pattern that became stable in Node 18.2 and is recommended on supported Node versions.
- When `trustProxy: true`, the adapter reads `x-forwarded-proto` and `x-forwarded-host` when constructing the request URL. Leave it off unless TLS is terminated at a known proxy you control.

## Behind a load balancer

Two rules to avoid the classic 502/504 race:

- Make your load balancer's idle timeout **greater** than DaloyJS's `requestTimeoutMs`.
- Make DaloyJS's `keepAliveTimeout` **greater** than the load balancer's, the Node adapter does this for you.

## Graceful degradation under overload

Steady-state throughput is only half the story. Once a Node process is pushed *past* saturation, the multi-second part of the tail latency no longer lives in your handler, it lives in the **accept queue**, where overflow connections sit waiting for the event loop to get to them. A connection sweep makes this visible: at high concurrency an unbounded server's p99.9 can *cliff* from tens of milliseconds into the multi-second range, even though median throughput still looks healthy.

The cheapest fix that actually works is **connection-layer admission control**: `maxConnections` forwards to Node's `server.maxConnections`, so once the cap is reached the server refuses additional sockets **at accept time** instead of queuing them into the event loop. Admitted traffic stays fast; overflow is rejected fast. It is **off by default** and sits off the request hot path, so it adds no per-request cost.

```ts
import { serve } from "@daloyjs/core/node";
import { app } from "./app.ts";

serve(app, {
  port: 3000,
  // Keep concurrency near the process's measured sweet spot. Above this,
  // overflow sockets are refused at accept time rather than queued into
  // multi-second tail latencies. Leave unset for Node's default (unbounded).
  maxConnections: Number(process.env.MAX_CONNECTIONS ?? 200),
});
```

Pick the cap empirically: run a connection sweep against your real routes and set `maxConnections` at (or just below) the concurrency where p99/p99.9 latency stays in its healthy range. The right value is workload-specific, CPU-bound JSON validation saturates at a very different point than I/O-bound proxying.

### Pair it with an upstream gateway

When the cap is hit, the overflow socket is refused at the TCP layer, the client sees a connection reset, not an HTTP response. In production you want a load balancer or API gateway in front that translates that refusal into a clean `503 Service Unavailable` with a `Retry-After` header, so well-behaved clients back off and retry instead of hammering a saturated process.

### Pair it with loadShedding

`maxConnections` and [`loadShedding()`](/docs/security/lifecycle-leftovers) solve different layers of the same problem and compose well:

- `maxConnections` (connection layer) caps how many sockets are ever accepted, keeping the event loop in its measured sweet spot.
- `loadShedding()` (application layer) sheds requests when an reliable overload signal, event-loop *delay* (queue backlog) or in-flight concurrency, trips a threshold.

A note on the load-shedding signal: event-loop **utilization** is the wrong knob for an always-busy, CPU-bound server, which can sit near 100% utilization while perfectly healthy and would shed good traffic. Event-loop **delay** (how far behind the loop has fallen) is the reliable overload signal. Likewise, `requestTimeoutMs` alone does *not* fix the cliff: it wraps handler execution, not the accept-queue wait where the multi-second tail actually lives.

Treat `maxConnections` as a resilience/latency lever, not a throughput lever, under overload it turns “everyone waits seconds” into “admitted traffic stays fast, overflow is refused fast.”

## Dockerfile

```docker
FROM node:24-slim AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile --prod

FROM node:24-slim AS build
WORKDIR /app
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile && pnpm build

FROM gcr.io/distroless/nodejs24-debian12
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER nonroot
EXPOSE 3000
CMD ["dist/server.js"]
```

## Gotchas

- Don't put `process.exit()` in a SIGTERM handler, let `close()` drain. The adapter handles the hard kill after the timeout.
- Set `hostname: "0.0.0.0"` in containers; Node binds to `localhost` by default and that's invisible from outside the container.

## See also

- [Adapters overview](/docs/adapters)
- [Fly.io](/docs/deployment/fly-io) · [Render](/docs/deployment/render) · [Railway](/docs/deployment/railway) · [Heroku](/docs/deployment/heroku)
- [Lifecycle & health](/docs/security/lifecycle-health)

---

Source: https://daloyjs.dev/docs/adapters/node