Skip to content

Search docs

Jump between documentation pages.

Browse docs

Node.js

The Node adapter runs your REST API on the built-in node:httpserver. 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.

Node adapter request path
  1. clientHTTP requestTCP socket
  2. node:httpserve(app)timeouts · maxConnections
  3. coreapp.fetch(Request)routing · hooks · handler
  4. clientResponse
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 /healthzroute, 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. Node.js 25 is not supported because it is already end-of-life.

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 greaterthan 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() 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 honest 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 honest 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 maxConnectionsas 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 localhostby default and that's invisible from outside the container.

See also