Reliability
Graceful shutdown, health checks, and request timeouts for NextRush apps running in production.
Running in production means surviving deploys, restarts, and slow or hanging
requests without dropping in-flight work or hanging forever. NextRush gives
you three real primitives to build this on — Application.close(), the
OnInit/OnShutdown lifecycle hooks, and the Node adapter's timeout/
shutdownTimeout options. There is no single gracefulShutdown() call or
built-in /health route — you compose these primitives yourself, as shown
below.
Graceful shutdown
When your process receives SIGTERM (the signal most orchestrators — Docker,
Kubernetes, systemd — send before killing a container), it should stop
accepting new connections, let in-flight requests finish, clean up resources,
and exit. Cutting connections immediately drops requests mid-flight.
Application.close() (verified in packages/core/src/application.ts) runs
every registered extension's destroy() in reverse registration order:
async close(): Promise<Error[]> {
this._closePromise ??= this._shutdown();
return this._closePromise;
}It returns an array of errors from any extension that failed to tear down
cleanly — it never throws. The Node adapter's serve() (verified in
packages/adapters/node/src/adapter.ts) already calls close() for you as
part of ServerInstance.close(): it stops the HTTP server, drains
connections up to shutdownTimeout, force-closes remaining sockets if the
drain doesn't finish in time, and only then calls app.close().
The pattern is: listen for the signal, call the server's close(), exit.
import { createApp, listen } from 'nextrush';
const app = createApp();
app.use(async (ctx) => {
ctx.json({ message: 'Hello World' });
});
const server = await listen(app, 8080);
async function shutdown(signal: string): Promise<void> {
app.logger.info(`${signal} received, shutting down`);
await server.close();
process.exit(0);
}
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));This is a pattern, not a built-in
NextRush has no global signal handler and no gracefulShutdown() helper.
You register process.on('SIGTERM', ...) yourself and call
server.close() — shown above — which internally drains connections and
calls app.close() for you.
Cleanup with OnShutdown (class-based apps)
If you use the class runtime, a @Service or @Repository can implement
OnShutdown to release resources — closing a database pool, flushing a
queue — without wiring anything into your signal handler. registerControllers
bridges this automatically: it registers an internal extension that calls
every resolved service's onShutdown() when app.close() runs, in the
reverse of onInit order. This is verified in
packages/class/src/lifecycle/lifecycle-types.ts and
packages/class/src/lifecycle/lifecycle.ts.
import { Service } from '@nextrush/di';
import type { OnInit, OnShutdown } from 'nextrush/class';
@Service()
class Database implements OnInit, OnShutdown {
private pool = createPool();
async onInit(): Promise<void> {
await this.pool.connect();
}
async onShutdown(): Promise<void> {
await this.pool.end();
}
}OnInit/OnShutdown are duck-typed interfaces — there is no decorator. A
class opts in purely by declaring the method; registerControllers detects
it via isOnShutdown(), which checks for a callable onShutdown member.
Your SIGTERM handler still only needs to call server.close() — the
service's cleanup runs as part of that same app.close() call.
Health checks
@nextrush/health ships /livez (liveness) and /readyz (readiness) as a
mountable middleware plus a check registry — you don't need to hand-write
these routes. Orchestrators poll a health endpoint to decide whether to
route traffic to an instance (readiness) or restart it (liveness).
import { createApp, listen } from 'nextrush';
import { health } from '@nextrush/health';
const app = createApp();
const { middleware, registerCheck } = health();
app.use(middleware);
registerCheck('database', async () => {
await db.ping();
return true;
});
listen(app, 8080);/livez never evaluates the check registry — it always responds 200 once
mounted, reflecting only that the process can respond at all. /readyz runs
every registered check (bounded by checkTimeoutMs, default 5000) and
returns 503 if any fails, throws, or times out. See the
@nextrush/health reference for the
full options and response shape.
Liveness vs. readiness
Keep these as two separate paths — health() already does. A liveness
check that depends on a database can cause an orchestrator to kill a
healthy process solely because a downstream dependency is temporarily
slow; restarting the process doesn't fix a database outage. Readiness
checks are for traffic routing; liveness checks are for "should this
process be restarted."
Request timeouts
The Node adapter enforces a request timeout at the socket level via
server.timeout — this is a real, built-in option, not something you need to
build yourself. Verified in packages/adapters/node/src/adapter.ts:
export interface ServeOptions {
/**
* Request timeout in milliseconds
* @default 30000 (30 seconds)
*/
timeout?: number;
/**
* Graceful shutdown timeout in milliseconds.
* @default 30000 (30 seconds)
*/
shutdownTimeout?: number;
/**
* Keep-alive timeout in milliseconds
* @default 5000 (5 seconds)
*/
keepAliveTimeout?: number;
}The defaults come from @nextrush/runtime's shared constants
(DEFAULT_TIMEOUT_MS = 30_000, DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000,
DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 5_000), so every adapter documents and
defaults to the same values.
import { listen, serve } from 'nextrush';
const server = await serve(app, {
port: 8080,
timeout: 15_000, // socket-level request timeout
shutdownTimeout: 10_000, // drain window used by server.close()
keepAliveTimeout: 5_000,
});Node's timeout is socket-level, not per-handler
On the Node adapter, timeout closes the underlying socket via
server.timeout — it does not run your handler on a race and does not
produce a 504 response body. This is confirmed by the adapter conformance
suite (packages/adapters/conformance/src/drivers/node-driver.ts:
handlerTimeout504: false). The Web-standard adapters (Bun, Deno, Edge) use
an AbortController to race the handler and return a real 504 Gateway Timeout, aborting ctx.signal so a cooperative handler can stop early
(handlerTimeout504: true on those adapters). If you deploy on Node and
need a 504 body plus cooperative cancellation inside the handler, wrap
the handler yourself with AbortSignal.timeout() and check ctx.signal,
or switch to a Web-standard adapter.
For handlers that call a slow downstream service, use ctx.signal (combined
by the adapter with the request's own abort signal) to cancel outbound work
cooperatively rather than relying solely on the transport-level timeout:
app.get('/report', async (ctx) => {
const response = await fetch('https://slow-service.example.com/data', {
signal: ctx.signal,
});
ctx.json(await response.json());
});Next steps
- Configuration — environment values these patterns read (ports, shutdown windows, database URLs).
- Deployment — how orchestrators use these health checks and signals in practice.
- Observability — logging what happens during shutdown and timeout events.