Create your own
Lesson illustration

Graceful Startup and Shutdown for a Node.js HTTP Service

A service lifecycle is the larger version of the resource lifecycle you handled in the last lesson. For streams, a reliable operation starts, processes data with bounded resources, reports errors, and cleans up. For an HTTP service, the same discipline applies at process scope: do not receive traffic until critical dependencies are usable, and do not disappear while requests and connections are still active.

In this lesson, you will implement a lifecycle boundary for a Node.js HTTP service: startup readiness, signal-driven draining, bounded shutdown, and resource cleanup. This is directly relevant to container deployments, rolling releases, and the operational questions expected of a senior full-stack engineer. Plan for about 40 minutes.


1. Graceful is a bounded attempt to protect work

A graceful shutdown is not merely “listen for SIGTERM and call server.close().” It is a coordinated policy for deciding:

  • when the service may receive traffic;
  • what happens to requests already in progress;
  • when new work must stop;
  • how long the service is allowed to drain;
  • which resources must be released before process termination.

The desired lifecycle has four states:

StateMeaning/ready response
startingDependencies are being initialized.503 Service Unavailable
readyThe service can safely handle normal traffic.200 OK
drainingThe service is shutting down and must not receive new work.503 Service Unavailable
stoppedHTTP server and owned resources are closed.No response

A second health endpoint usually represents a different question:

  • GET /live asks: “Is this process alive enough that the platform should leave it running?”
  • GET /ready asks: “Should a load balancer send new application traffic here?”

During a planned shutdown, /live can remain successful while /ready fails. The process is functioning, but it is deliberately withdrawing from service.

Read the relevant parts of Graceful shutdown with Node.js and Kubernetes from RisingStack Engineering. The Kubernetes-specific implementation is older, but its lifecycle model remains useful: become unready, let traffic drain, then release resources.

Graceful shutdown with Node.js and Kubernetes - RisingStack Engineering

Read this RisingStack Engineering article for the overall contract between an application, its traffic router, and its dependencies during startup and shutdown.

In “Graceful shutdown,” read the definition and sequence. Notice that “closing the server” is only one part of shutdown: databases, queues, and in-progress work also need an explicit policy. Then, in “Graceful start,” read the startup sequence. Focus on the order: establish critical dependencies before declaring the application ready. Finally, in “Setting up graceful shutdown,” read the termination timeline. Treat the probe delay as a deployment-specific value to measure and configure, not as a magic constant to copy.

The supplied Graceful shutdown signal and readiness timeline shows the core deployment concern: a process can receive a termination signal before every routing layer has stopped targeting it. The service first becomes unready; only after traffic has had time to drain does it stop the HTTP server and release dependencies.

A service receives `SIGTERM`, reports that readiness is no longer OK so traffic is removed, then stops the server and releases resources before a possible forced `SIGKILL` deadline.

One important refinement: a readiness failure should normally use 503 Service Unavailable, not 500. A 500 says an unexpected server failure occurred; 503 communicates that the service is intentionally or temporarily unavailable.


2. Startup: establish dependencies before advertising readiness

A process can bind a TCP port while still being unable to do useful work. For example, it might have loaded its HTTP routes but not yet connected to PostgreSQL, initialized a queue client, or validated its configuration.

If a load balancer begins sending requests during that period, clients see avoidable failures. The startup rule is therefore:

  1. Validate configuration and create required clients.
  2. Verify that critical dependencies are usable.
  3. Start listening.
  4. Mark the service ready.

Not every dependency needs to block startup. For a project-management API, PostgreSQL is likely critical because most requests require it. An optional analytics client may be non-critical if its failure merely reduces telemetry. This classification is an architectural decision: startup should block on dependencies whose absence would make ordinary requests unsafe or consistently fail.

Avoid putting expensive database queries directly inside /ready. Health endpoints are called frequently. A good readiness check is cheap and bounded, such as a known pool state or a cached recent dependency check. If you do make a live dependency call, give it a strict timeout; a health endpoint that waits forever cannot help the platform make a decision.

Here is a small native-HTTP service that makes lifecycle state explicit. The ManagedResources implementation is where you would initialize and later close your database pool, queue consumer, scheduler, or telemetry exporter.

// src/infrastructure/http/service-runtime.ts

import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { setTimeout as sleep } from "node:timers/promises";

type ServiceState = "starting" | "ready" | "draining" | "stopped";

interface ManagedResources {
  connect(): Promise<void>;
  pauseIntake(): Promise<void>;
  close(): Promise<void>;
}

interface RuntimeOptions {
  port: number;
  trafficPropagationDelayMs: number;
  shutdownDeadlineMs: number;
}

export function createServiceRuntime(
  resources: ManagedResources,
  options: RuntimeOptions,
) {
  let state: ServiceState = "starting";
  let shutdownPromise: Promise<void> | undefined;

  const server = createServer(function handleRequest(
    request: IncomingMessage,
    response: ServerResponse,
  ) {
    const path = request.url ?? "/";

    if (path === "/live") {
      sendText(response, 200, "live");
      return;
    }

    if (path === "/ready") {
      const statusCode = state === "ready" ? 200 : 503;
      sendText(response, statusCode, state);
      return;
    }

    if (state !== "ready") {
      response.setHeader("connection", "close");
      sendText(response, 503, "Service is draining");
      return;
    }

    // Route normal application traffic only while ready.
    sendText(response, 200, "Project API response");
  });

  async function start(): Promise<void> {
    try {
      await resources.connect();
      await listen(server, options.port);

      state = "ready";
      console.info({ port: options.port }, "Service is ready");
    } catch (cause: unknown) {
      state = "stopped";

      try {
        await resources.close();
      } catch (cleanupCause: unknown) {
        console.error({ cleanupCause }, "Startup cleanup failed");
      }

      throw cause;
    }
  }

  function requestShutdown(signal: string): Promise<void> {
    if (shutdownPromise !== undefined) {
      return shutdownPromise;
    }

    shutdownPromise = shutdown(signal);
    return shutdownPromise;
  }

  async function shutdown(signal: string): Promise<void> {
    if (state === "stopped") {
      return;
    }

    state = "draining";
    console.info({ signal }, "Service is draining");

    try {
      // Stop accepting new background jobs immediately. This method should
      // pause consumption, not wait indefinitely for every queued job.
      await resources.pauseIntake();

      // Give readiness checks, load balancers, and clients time to observe
      // that this instance should no longer receive new requests.
      await sleep(options.trafficPropagationDelayMs);

      const remainingDeadlineMs =
        options.shutdownDeadlineMs - options.trafficPropagationDelayMs;

      await closeServerWithinDeadline(server, Math.max(1, remainingDeadlineMs));
      await resources.close();

      console.info("Service shut down cleanly");
    } catch (cause: unknown) {
      // A nonzero exit status tells the runtime that shutdown was incomplete.
      // Do not call process.exit() here: it can cut off pending logs or cleanup.
      process.exitCode = 1;
      console.error({ cause }, "Service shutdown did not complete cleanly");

      try {
        await resources.close();
      } catch (cleanupCause: unknown) {
        console.error({ cleanupCause }, "Final resource cleanup failed");
      }
    } finally {
      state = "stopped";
    }
  }

  return {
    requestShutdown,
    server,
    start,
  };
}

function sendText(
  response: ServerResponse,
  statusCode: number,
  body: string,
): void {
  response.writeHead(statusCode, {
    "content-type": "text/plain; charset=utf-8",
  });
  response.end(body);
}

function listen(server: Server, port: number): Promise<void> {
  return new Promise(function waitForListening(resolve, reject) {
    function onError(error: Error): void {
      server.off("error", onError);
      reject(error);
    }

    server.once("error", onError);

    server.listen(port, function onListening() {
      server.off("error", onError);
      resolve();
    });
  });
}

function closeServerWithinDeadline(
  server: Server,
  deadlineMs: number,
): Promise<void> {
  return new Promise(function closeWithDeadline(resolve, reject) {
    const timer = setTimeout(function forceClose() {
      server.closeAllConnections();
      reject(new Error(`HTTP server did not close within ${deadlineMs} ms`));
    }, deadlineMs);

    server.close(function onClosed(error?: Error) {
      clearTimeout(timer);

      if (error !== undefined) {
        reject(error);
        return;
      }

      resolve();
    });

    // Harmless on modern Node versions and useful if supporting older ones.
    // It must be called after server.close() to avoid a connection race.
    server.closeIdleConnections();
  });
}

The code is intentionally organized around ownership:

  • resources.connect() completes before the service is ready.
  • resources.pauseIntake() prevents new background work from starting.
  • server.close() waits for current HTTP work to complete.
  • resources.close() happens after HTTP has stopped, so an in-flight request is not suddenly left without its database pool.
  • A deadline prevents a stuck connection from making shutdown indefinite.

In your capstone, ManagedResources.close() might close a PostgreSQL pool and disconnect a queue client. It should not perform destructive work that belongs to an active request. The request itself still owns its transaction, file stream, or outbound operation.


3. Signals initiate shutdown; they do not guarantee time

Two signals matter for normal development and container operation:

  • SIGINT commonly comes from pressing Ctrl+C locally.
  • SIGTERM commonly comes from Docker, ECS, Kubernetes, or a process manager during a stop or deployment.
  • SIGKILL is forceful and cannot be intercepted or handled by your Node.js process.

Watch the first part of Here’s how to Gracefully Shutdown your apps by Software Developer Diaries for the failure modes and signal vocabulary behind the implementation.

Here's how to Gracefully Shutdown your apps (with Node.js examples)

Watch “Here’s how to Gracefully Shutdown your apps” from Software Developer Diaries to connect the code pattern with database transactions, WebSockets, and container termination signals.

Watch the motivation for examples of work that abrupt termination can interrupt. Then watch the signals to distinguish local interruption, requested termination, and uncatchable forced termination. Finish with cleanup examples, noting that every resource type needs a deliberate close operation.

Install handlers near the composition root of the application, after constructing the runtime:

// src/main.ts

import { createServiceRuntime } from "./infrastructure/http/service-runtime.js";
import { createManagedResources } from "./infrastructure/resources.js";

const runtime = createServiceRuntime(createManagedResources(), {
  port: 3000,
  trafficPropagationDelayMs: 2_000,
  shutdownDeadlineMs: 25_000,
});

function beginShutdown(signal: string): void {
  void runtime.requestShutdown(signal);
}

process.on("SIGTERM", function onSigterm() {
  beginShutdown("SIGTERM");
});

process.on("SIGINT", function onSigint() {
  beginShutdown("SIGINT");
});

void runtime.start().catch(function onStartupFailure(cause: unknown) {
  process.exitCode = 1;
  console.error({ cause }, "Service failed to start");
});

The shutdownPromise makes shutdown idempotent. If both a deployment tool and a local command deliver termination requests, cleanup still occurs only once. Without this guard, two shutdown paths can race to close the same pool, close the server twice, or produce misleading logs.

Avoid calling process.exit() immediately in a signal handler:

process.on("SIGTERM", function unsafeShutdown() {
  process.exit(0);
});

That ends the process before server callbacks, database cleanup, buffered logs, and in-flight work can reliably finish. Instead, close the handles that keep Node alive. Once the HTTP server, database pool, queue clients, timers, and other owned handles are closed, Node can exit naturally. Set process.exitCode = 1 if the shutdown deadline was exceeded or cleanup failed.

A deployment platform still owns the final deadline. If the process exceeds its stop timeout, it may receive SIGKILL. Your application cannot recover after that point, so its shutdown budget should be shorter than the platform’s configured grace period.


4. What server.close() does—and does not—solve

server.close() is the correct primary operation for an ordinary Node HTTP server, but its semantics should be precise:

  • It stops accepting new connections.
  • It closes idle HTTP connections.
  • It waits for active requests and responses to finish.
  • It calls its callback when the server has closed.

Read the Node.js HTTP documentation section that defines these APIs. It distinguishes graceful closure of idle and active work from a forceful connection termination.

HTTP | Node.js v25.4.0 Documentation

Read the official Node.js HTTP documentation for the exact behavior of the methods used in the shutdown coordinator.

Under server.close([callback]), read the close semantics. This is the normal drain mechanism. Under server.closeIdleConnections(), read the idle-connection behavior, including the Node.js 19 version note. Finally, under server.closeAllConnections(), read the force-close warning. Read the rest of that subsection as well: upgraded connections such as WebSockets are not covered by this method.

The order in the earlier implementation is important:

  1. Set state to draining, making /ready return 503.
  2. Pause background intake.
  3. Wait briefly for traffic routing to converge.
  4. Call server.close().
  5. Close idle connections after calling server.close().
  6. Let active HTTP requests complete within the remaining deadline.
  7. Close database, queue, and telemetry resources.
  8. If the deadline expires, force-close ordinary HTTP connections and report failure.

server.closeAllConnections() is a last resort, not the normal shutdown path. It can cut off active requests. Calling it before server.close() also leaves a race in which new connections may arrive between operations.

Keep-alive, long requests, and WebSockets

HTTP keep-alive means one TCP connection can serve multiple requests. During drain, a client with an already-open connection might attempt another request. The application-level state !== "ready" guard returns 503 and the connection: close header discourages reuse. server.close() then handles closing the connection once it is idle.

Long-running requests create a product decision:

  • A report generation request that normally takes two seconds can reasonably be allowed to finish within a 25-second shutdown budget.
  • A request that performs a 20-minute video transcode should not be held open during a deployment. It belongs in a background job system with persistent state, retries, and idempotency.
  • A WebSocket is an upgraded connection. Track such connections explicitly, stop sending new work through them, notify clients if your protocol supports it, and close them before the deadline. closeAllConnections() does not close upgraded sockets.

Graceful shutdown improves availability during deployments, but it cannot make an unbounded operation safe. The senior-level question is always: what is the maximum time this unit of work is allowed to own a service instance?


5. Verify the lifecycle rather than trusting the happy path

Graceful shutdown bugs often remain invisible in a local request-response test. Verify the behavior with a deliberately slow endpoint and a real signal.

A practical manual check:

  1. Start the service and confirm that /ready returns 200.
  2. Send a request that takes several seconds to complete.
  3. While it is running, send SIGTERM with kill -TERM <pid>.
  4. Check that /ready changes to 503, while /live remains 200 until the process stops.
  5. Confirm that the in-progress request completes within the configured deadline.
  6. Confirm that new normal requests receive 503 during the propagation period and then cease once the listener closes.
  7. Confirm that the process exits with code 0 when cleanup succeeds.

For automated tests, isolate the coordinator from real infrastructure. Use a fake ManagedResources implementation that records calls. The key assertion is ordering:

  • connect finishes before readiness.
  • pauseIntake occurs before server closure.
  • close occurs after HTTP drain.
  • a second requestShutdown returns the same promise and does not repeat cleanup.

Also test the negative path: simulate a server that does not close before the deadline. Your expected result is not “everything succeeded”; it is a clear error log, a nonzero exit code, and force-close behavior.


Key takeaways

A production service should not equate “process started” with “ready for traffic,” or “received SIGTERM” with “terminate immediately.”

  • Initialize and verify critical dependencies before declaring readiness.
  • Use /ready to control whether new traffic should arrive and /live to report basic process health.
  • Handle both SIGTERM and SIGINT; SIGKILL cannot be handled.
  • Make shutdown idempotent so repeated signals do not race.
  • Drain in a deliberate order: become unready, stop new background intake, close HTTP, then close dependencies.
  • Use server.close() as the primary HTTP drain mechanism.
  • Use closeAllConnections() only when the deadline has been exceeded.
  • Set a shutdown budget that fits inside the deployment platform’s stop timeout, and design long-running work so it does not depend on an HTTP server remaining alive indefinitely.

Next, you will learn how to identify an event-loop bottleneck with Node.js profiling tools and select an appropriate remedy.

Can't find a good explanation? Sign up and we'll make it for you

Sign up