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.
- clientHTTP requestTCP socket
- node:httpserve(app)timeouts · maxConnections
- coreapp.fetch(Request)routing · hooks · handler
- clientResponse
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.
Install
Requires Node.js 24 LTS or Node.js 26+. The adapter ships with @daloyjs/core. No extra dependency.
Minimal server
What the adapter wires for you
requestTimeout,headersTimeout, andkeepAliveTimeoutset to safe production values. Both request timeouts derive fromconnectionTimeoutMs, 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 a408, instead of surviving until Node's default 30 second sweep. SetconnectionTimeoutMs: 0to disable the timeouts entirely.- SIGTERM / SIGINT handlers (disable with
handleSignals: false) that runclose():onShutdownlisteners run, idle keep-alive sockets are closed right away withserver.closeIdleConnections(), in-flight requests get up toshutdownTimeoutMs(default 10 seconds) to finish,onClosehooks run, thenserver.close()is called and the process exits with code0. - When
trustProxy: true, the adapter readsx-forwarded-protoandx-forwarded-hostwhen constructing the request URL. Leave it off unless TLS is terminated at a known proxy you control. Since 1.3.7 onlyhttporhttpsis honoured fromx-forwarded-proto; any other value falls back to the socket's own scheme. - One path view for router and middleware (since 1.3.7). The router matches the raw request-target while middleware such as
except(), tenancy, and the WAF re-parserequest.url. So the two can never disagree, the adapter canonicalizes a request-target containing\,./..segments, their%2espellings, or bytes that URL parsing would percent-encode, exactly asnew URL()would. Ordinary paths skip this with no allocation. serve()runsapp.assertSecureConfig()before listening, so in production an insecure route table throws at startup instead of answering500to the first request.- In development (
env: "development", orNODE_ENV=development, whichdaloy devsets) and with noallowedHosts, onlylocalhost,*.localhostand IP-literalHostvalues are accepted, so a DNS-rebinding page cannot drive your dev server. SetallowedHoststo use a custom local hostname. - A
Hostheader (or trustedx-forwarded-host) that is not a plainhost[:port]or bracketed IPv6 literal is refused with400on both HTTP requests and WebSocket upgrades. Only letters, digits,. - _ :and[ ]around an IPv6 address are accepted, so a value such ash\health?cannot move bytes into the path the router sees.
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 the server's
keepAliveTimeoutgreater than the load balancer's idle timeout. The Node adapter sets it to 5 seconds and has no option for it, so behind a load balancer with a longer idle timeout (AWS ALB defaults to 60 seconds) raise it on the returned handle:serve(app).server.keepAliveTimeout = 65_000.
Graceful degradation under overload
Steady-state throughput is incomplete. 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 practical fix 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.
Pick the cap empirically: run a connection sweep against your real routes and set maxConnections at (or slightly 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 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 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 covers hooks, the body read, and the handler once a request is dispatched, not the accept-queue wait where the multi-second tail actually lives.
Treat maxConnections as a latency cap, not a throughput knob. Under overload it turns “everyone waits seconds” into “admitted traffic stays fast, overflow is refused fast.”
Dockerfile
Gotchas
- Don't put
process.exit()in a SIGTERM handler, letclose()drain. WithhandleSignalson (the default), the adapter exits the process itself onceclose()resolves. serve()binds to0.0.0.0(every interface) by default, which containers need. Passhostname: "127.0.0.1"to keep a local dev server off your network.