Like Blockend? Give it a ⭐ on GitHub.

Star
blockend

Blockend

02 blocks

Graceful Shutdown

Coordinated, dependency-free shutdown with in-flight request draining, priority-ordered tasks, and adapters for Express, Fastify, and Hono.

The Graceful Shutdown block coordinates application cleanup when your process is terminating. Instead of letting the runtime kill everything abruptly, it stops accepting new traffic, drains in-flight requests, and runs registered cleanup tasks in a controlled order.

When a deploy or signal arrives, three things must happen in sequence: stop accepting new requests, finish every request already in flight, and close resources (database pools, caches, queues) only after they are no longer needed.

Pair this block with the Health Check block. Use isShuttingDownState in your readiness endpoint so load balancers and Kubernetes stop routing traffic the moment shutdown begins.


Features

  • Framework-agnostic core with zero runtime dependencies
  • Priority-ordered task execution (highest first)
  • Per-task timeouts plus a hard process-level timeout
  • In-flight request tracking and draining before tasks run
  • 503 responses with Retry-After and Connection: close during shutdown
  • Idempotent shutdown() — every caller receives the same result promise
  • Lifecycle events: beforeShutdown, draining, drained, drainTimeout, afterShutdown
  • Built-in SIGTERM / SIGINT / SIGQUIT handling with a second-signal force-exit
  • Adapters for Express, Fastify, and Hono

File Structure

graceful-shutdown
├── adapters
│   ├── express.ts
│   ├── fastify.ts
│   └── hono.ts
├── core
│   ├── connection-tracker.ts
│   └── shutdown.ts
├── utils
│   ├── http.ts
│   ├── index.ts
│   ├── state.ts
│   └── timeout.ts
├── constants.ts
├── index.ts
└── types.ts
  • adapters/ — Framework-specific 503 middleware/hooks, connection trackers, and shutdown task factories.
  • core/ — The GracefulShutdown manager and the NodeConnectionTracker in-flight counter.
  • utils/ — HTTP server shutdown task, 503 helper, shutdown-state check, and a promise timeout wrapper.
  • constants.ts — Defaults for timeouts and the PRIORITY levels.
  • index.ts — Framework-agnostic public barrel (adapters are imported directly from their own module).
  • types.ts — Shared contracts: tasks, results, options, and tracker interfaces.

Installation

pnpm dlx blockend-cli add graceful-shutdown

Detect Project

Blockend reads your project configuration and determines the output location.

Select Adapter

Choose the framework adapter for your application: Express, Fastify, or Hono.

Install Dependencies

The packages required by the selected adapter are installed automatically.

Generate Files

The core files, the selected adapter, and the test files are copied into your configured blocks directory.

Copy the files below into your project's blocks directory. The core block has zero runtime dependencies — you only install the packages for the framework adapter you use.

Peer Dependencies

PackageRequired for
expressExpress adapter
fastifyFastify adapter
hono + @hono/node-serverHono adapter

blocks/graceful-shutdown/index.ts

Framework-agnostic public barrel. Re-exports the manager, tracker, utils, constants, and types.

/**
 * graceful-shutdown — a small, dependency-free utility for coordinated,
 * dependable application shutdown.
 *
 * Usage:
 * ```ts
 * import { GracefulShutdown, createHttpShutdownTask } from 'graceful-shutdown';
 *
 * const shutdown = new GracefulShutdown();
 * shutdown.addTask(createHttpShutdownTask(server)); // REQUIRED — closes the server
 * shutdown.addTask({ name: 'db', priority: 40, handler: () => pool.end() });
 * ```
 *
 * Framework adapters (Express / Fastify / Hono) are deliberately NOT
 * re-exported here so this entry stays framework-agnostic and installs cleanly
 * alongside a single adapter. Import adapters directly from their own module:
 * ```ts
 * import { createShutdownMiddleware } from './adapters/express';
 * ```
 */

// Core
export { GracefulShutdown, gracefulShutdown } from "./core/shutdown";
export { NodeConnectionTracker, isAttachable } from "./core/connection-tracker";

// Types
export type {
  AttachableConnectionTracker,
  ConnectionTracker,
  GracefulShutdownOptions,
  Node503Options,
  ShutdownHandler,
  ShutdownReason,
  ShutdownResult,
  ShutdownStateProvider,
  ShutdownTask,
  TaskFailure
} from "./types";

// Constants
export * from "./constants";

// Utils
export {
  createHttpShutdownTask,
  handleNode503,
  shouldRejectRequest,
  withTimeout
} from "./utils/index";

blocks/graceful-shutdown/constants.ts

Centralized defaults and the suggested PRIORITY levels used to order tasks.

/**
 * Centralised defaults and suggested priority levels.
 *
 * Keeping them here lets callers tune behaviour and guarantees the core and its
 * utilities never drift out of sync.
 */

/** Priority used when a task does not specify one. */
export const DEFAULT_PRIORITY = 50;

/** Hard kill timeout — process exits regardless once this elapses. */
export const DEFAULT_HARD_TIMEOUT_MS = 30_000;

/** Default per-task timeout when `task.timeout` is not set. */
export const DEFAULT_TASK_TIMEOUT_MS = 10_000;

/** Default timeout for waiting on active connections to drain. */
export const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;

/** HTTP servers need more time to drain active requests than most tasks. */
export const DEFAULT_HTTP_CLOSE_TIMEOUT_MS = 15_000;

/** How often `waitForDrain` polls whether requests have finished. */
export const DRAIN_POLL_INTERVAL_MS = 50;

/**
 * Suggested priority levels. Higher values run first.
 *
 * The ordering is deliberate: stop accepting new work before flushing the
 * resources that depend on it.
 */
export const PRIORITY = {
  /** HTTP servers — stop traffic first. */
  HTTP_SERVER: 100,
  /** Message queue consumers — stop consuming before flushing. */
  QUEUE_CONSUMER: 80,
  /** Job queues / workers. */
  JOB_QUEUE: 60,
  /** Database connection pools. */
  DB_POOL: 40,
  /** Caches (Redis etc.). */
  CACHE: 20,
  /** Loggers / telemetry — flush last so we keep logs from everything above. */
  LOGGERS: 10
} as const;

blocks/graceful-shutdown/types.ts

Shared contracts: ShutdownTask, ShutdownResult, GracefulShutdownOptions, tracker interfaces, and helpers.

/**
 * Shared types and contracts for the graceful-shutdown block.
 */

// ─── Tasks ────────────────────────────────────────────────────────────────────

/** A function that performs part of the cleanup. May be sync or async. */
export type ShutdownHandler = () => Promise<void> | void;

/**
 * The reason a shutdown was triggered. Free-form strings are allowed so callers
 * can use custom reasons, e.g. `'deploy'`.
 */
export type ShutdownReason =
  | "SIGTERM"
  | "SIGINT"
  | "SIGQUIT"
  | "uncaughtException"
  | "unhandledRejection"
  | "manual"
  | (string & {});

/** A single unit of cleanup work registered against the shutdown manager. */
export interface ShutdownTask {
  /** Human-readable name, used in logs and results. Should be unique. */
  name: string;
  /** The cleanup work to run. */
  handler: ShutdownHandler;
  /** Per-task timeout in ms. Overrides `defaultTaskTimeoutMs`. */
  timeout?: number;
  /**
   * Higher = runs first. See {@link PRIORITY} for suggested conventions,
   * e.g. `PRIORITY.HTTP_SERVER` (100).
   */
  priority?: number;
}

/** A task that failed during shutdown, together with the error it threw. */
export interface TaskFailure {
  name: string;
  error: Error;
}

/** The final, aggregate outcome of a shutdown run. */
export interface ShutdownResult {
  /** `true` when no task failed. */
  success: boolean;
  /** Names of tasks that completed successfully. */
  completed: string[];
  /** Tasks that threw an error or timed out. */
  failed: TaskFailure[];
  /** Total time taken to run the tasks, in ms. */
  durationMs: number;
}

// ─── Shutdown state ───────────────────────────────────────────────────────────

/** Any object that exposes the current shutdown status, for observability. */
export interface ShutdownStateProvider {
  /** `true` while the application is shutting down. Used by readiness probes. */
  readonly isShuttingDownState: boolean;
}

// ─── Options ──────────────────────────────────────────────────────────────────

/** Options accepted by `GracefulShutdown`. */
export interface GracefulShutdownOptions {
  /** Hard kill timeout. After this, `process.exit(1)` fires regardless. Default: 30s */
  hardTimeoutMs?: number;
  /** Default per-task timeout if `task.timeout` is not set. Default: 10s */
  defaultTaskTimeoutMs?: number;
  /** If `true`, stop running tasks on the first failure. Default: `false` */
  stopOnError?: boolean;
  /**
   * If `true`, register SIGTERM/SIGINT/SIGQUIT handlers automatically.
   * Set to `false` to call `shutdown()` manually (e.g. in tests). Default: `true`
   */
  installSignalHandlers?: boolean;
  /**
   * Called when a task completes or fails. Use for metrics/observability,
   * e.g. recording task duration to Prometheus.
   */
  onTaskComplete?: (name: string, durationMs: number, error?: Error) => void;
  /** Optional connection tracker the shutdown waits on before running tasks. */
  connectionTracker?: ConnectionTracker;
  /** How long to wait for active connections to drain. Default: 10s */
  drainTimeoutMs?: number;
}

// ─── HTTP 503 helper ──────────────────────────────────────────────────────────

/** Options for the `handleNode503` request-interception helper. */
export interface Node503Options {
  /** HTTP status code returned. Default: `503`. */
  statusCode?: number;
  /** Response body. Objects are JSON-serialised. Default: a JSON error body. */
  body?: Record<string, unknown> | string;
  /** Additional response headers to set. */
  headers?: Record<string, string | number | readonly string[]>;
}

// ─── Connection tracker ───────────────────────────────────────────────────────

/**
 * The contract an in-flight-request tracker must fulfill.
 *
 * The shutdown manager depends on this interface only — never on a concrete
 * implementation.
 */
export interface ConnectionTracker {
  /**
   * Current number of requests that have started but not yet finished.
   *
   * Readonly — only the tracker itself mutates it. External code reads it for
   * observability (metrics, logging, health checks).
   */
  readonly activeCount: number;

  /**
   * Resolves when `activeCount` reaches `0`, or rejects if that has not
   * happened within `timeoutMs`. Resolves immediately if already drained.
   *
   * @param timeoutMs How long to wait before giving up.
   */
  waitForDrain(timeoutMs: number): Promise<void>;
}

/**
 * Optional extension for trackers that attach to and detach from a server.
 *
 * Not required by the core, but useful for adapters that need explicit
 * lifecycle control (e.g. `NodeConnectionTracker`).
 */
export interface AttachableConnectionTracker extends ConnectionTracker {
  /**
   * Attach the tracker to a server instance. Must be called before the server
   * starts accepting requests. Returns `this` for fluent chaining.
   */
  attach(server: unknown): this;
  /** Detach from the server. Implement when secure reuse across tests is needed. */
  detach?(): void;
}

blocks/graceful-shutdown/core/shutdown.ts

The GracefulShutdown manager: registers tasks, runs the drain + task sequence, and emits lifecycle events.

import { EventEmitter } from "node:events";

import {
  DEFAULT_DRAIN_TIMEOUT_MS,
  DEFAULT_HARD_TIMEOUT_MS,
  DEFAULT_PRIORITY,
  DEFAULT_TASK_TIMEOUT_MS
} from "../constants";
import { withTimeout } from "../utils/index";
import type { ConnectionTracker } from "../types";
import type {
  GracefulShutdownOptions,
  ShutdownReason,
  ShutdownResult,
  ShutdownStateProvider,
  ShutdownTask,
  TaskFailure
} from "../types";

/**
 * Coordinates and executes the cleanup tasks that run on shutdown.
 *
 * Emits lifecycle events: `beforeShutdown`, `draining`, `drained`,
 * `drainTimeout`, and `afterShutdown`.
 *
 * Lifecycle:
 *   1. `beforeShutdown` — listeners stop starting new work.
 *   2. Drain active connections (if a tracker is configured) — in-flight
 *      requests keep their resources until they finish.
 *   3. Run registered tasks in priority order (highest first).
 *   4. `afterShutdown` — with the aggregate result.
 *
 * A hard timeout (unconfigurable to disable) forces `process.exit(1)` if the
 * whole run exceeds `hardTimeoutMs`.
 */
export class GracefulShutdown extends EventEmitter implements ShutdownStateProvider {
  // States
  private _isShuttingDown = false;
  private shutdownPromise: Promise<ShutdownResult> | null = null;

  // Configuration
  private readonly hardTimeoutMs: number;
  private readonly defaultTaskTimeoutMs: number;
  private readonly stopOnError: boolean;
  private readonly onTaskComplete?: GracefulShutdownOptions["onTaskComplete"];
  private readonly connectionTracker: ConnectionTracker | undefined;
  private readonly drainTimeoutMs: number;

  // Registry
  private tasks: Map<string, ShutdownTask> = new Map();

  constructor(options: GracefulShutdownOptions = {}) {
    super();
    this.hardTimeoutMs = options.hardTimeoutMs ?? DEFAULT_HARD_TIMEOUT_MS;
    this.defaultTaskTimeoutMs = options.defaultTaskTimeoutMs ?? DEFAULT_TASK_TIMEOUT_MS;
    this.stopOnError = options.stopOnError ?? false;
    this.onTaskComplete = options.onTaskComplete;
    this.connectionTracker = options.connectionTracker;
    this.drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;

    // Disable signal handling when the caller invokes shutdown() manually
    // (e.g. in tests, or when the app provides its own signal wiring).
    if (options.installSignalHandlers !== false) {
      this._installSignalHandlers();
    }
  }

  // ─── Public API ─────────────────────────────────────────────────────────────

  /** `true` while the application is shutting down. Used by readiness probes. */
  get isShuttingDownState(): boolean {
    return this._isShuttingDown;
  }

  /**
   * Register a shutdown task. Tasks with the same name replace existing ones.
   *
   * If shutdown has already started, the task runs immediately instead.
   */
  addTask(task: ShutdownTask): this {
    if (this._isShuttingDown) {
      console.warn(
        `[shutdown] Task "${task.name}" added after shutdown started — running immediately`
      );
      Promise.resolve(task.handler()).catch((err) =>
        console.error(`[shutdown] Immediate task "${task.name}" failed:`, err)
      );
      return this;
    }

    this.tasks.set(task.name, task);
    return this;
  }

  /**
   * Remove a previously registered task by name. Returns `true` if it existed.
   */
  removeTask(name: string): boolean {
    return this.tasks.delete(name);
  }

  /** Returns registered task names, ordered highest priority first. */
  getTaskNames(): string[] {
    return [...this.tasks.values()]
      .sort((a, b) => (b.priority ?? DEFAULT_PRIORITY) - (a.priority ?? DEFAULT_PRIORITY))
      .map((task) => task.name);
  }

  /**
   * Start (or resume) a graceful shutdown.
   *
   * Idempotent — every caller receives the exact same in-flight result promise,
   * identical by reference. Concurrent callers can race to trigger shutdown and
   * all await the same run.
   *
   * Deliberately NOT `async`: an `async` function wraps the value it returns in
   * a fresh adapter promise, which would break reference identity and make the
   * second call return a different object than the first.
   */
  shutdown(reason: ShutdownReason = "manual"): Promise<ShutdownResult> {
    // Each caller shares the same promise, so shutdown runs exactly once.
    if (this.shutdownPromise) return this.shutdownPromise;

    this._isShuttingDown = true;
    const startMs = Date.now();
    console.info(`[shutdown] Starting graceful shutdown. Reason: ${reason}`);

    // Reserve the shared promise NOW so any re-entrant call — e.g. from a
    // beforeShutdown listener emitted below — receives the same run instead of
    // recursively starting a second one.
    let finish!: (result: ShutdownResult) => void;
    let fail!: (err: unknown) => void;
    const reserved = new Promise<ShutdownResult>((res, rej) => {
      finish = res;
      fail = rej;
    });
    this.shutdownPromise = reserved;

    // Hard timeout — last resort. Cannot be disabled.
    const hardTimer = setTimeout(() => {
      console.error(`[shutdown] Hard timeout of ${this.hardTimeoutMs}ms exceeded. Forcing exit.`);
      process.exit(1);
    }, this.hardTimeoutMs);
    hardTimer.unref(); // Don't keep the event loop alive if we finish first.

    // Defer everything that can throw (listeners, draining, tasks) into an
    // async runner so any failure settles the shared `reserved` promise. This
    // keeps `shutdown()` a plain synchronous function that hands every caller
    // the exact same promise object — the property both idempotency and
    // re-entrancy rely on.
    void (async () => {
      try {
        // Allow listeners to do pre-shutdown work (e.g. stop cron jobs) BEFORE
        // we begin draining.
        this.emit("beforeShutdown", reason);
        // Kick off the drain + task sequence and settle the reserved promise
        // with its outcome, clearing the hard timer when it completes.
        finish(await this._performShutdown(startMs));
      } catch (err) {
        fail(err);
      } finally {
        clearTimeout(hardTimer);
      }
    })();

    return this.shutdownPromise;
  }

  // ─── Internals ──────────────────────────────────────────────────────────────

  /** Drain active connections, then run registered tasks. */
  private async _performShutdown(startMs: number): Promise<ShutdownResult> {
    // Drain step — skipped entirely if no tracker is configured.
    // Connections are drained FIRST so the resources (DB, cache) they may be
    // using are still alive until every in-flight request has finished.
    if (this.connectionTracker) {
      this.emit("draining");
      try {
        await this.connectionTracker.waitForDrain(this.drainTimeoutMs);
        this.emit("drained");
      } catch {
        // Drain timed out — log and continue; tasks still need to run.
        console.warn("[shutdown] Connection drain timed out — proceeding to tasks");
        this.emit("drainTimeout");
      }
    }

    // Stop the server and run the remaining registered tasks.
    return this._runTasks(startMs);
  }

  /** Run all registered tasks in priority order, collecting results. */
  private async _runTasks(startMs: number): Promise<ShutdownResult> {
    const completed: string[] = [];
    const failed: TaskFailure[] = [];

    const sorted = [...this.tasks.values()].sort(
      (a, b) => (b.priority ?? DEFAULT_PRIORITY) - (a.priority ?? DEFAULT_PRIORITY)
    );

    for (const task of sorted) {
      const taskStart = Date.now();
      const timeout = task.timeout ?? this.defaultTaskTimeoutMs;

      try {
        await withTimeout(Promise.resolve(task.handler()), timeout, task.name);
        const duration = Date.now() - taskStart;
        completed.push(task.name);
        this.onTaskComplete?.(task.name, duration);
        console.info(`[shutdown] Task "${task.name}" completed in ${duration}ms`);
      } catch (err) {
        const duration = Date.now() - taskStart;
        const error = err instanceof Error ? err : new Error(String(err));
        failed.push({ name: task.name, error });
        this.onTaskComplete?.(task.name, duration, error);
        console.error(`[shutdown] Task "${task.name}" failed after ${duration}ms:`, error.message);

        if (this.stopOnError) {
          console.error(`[shutdown] stopOnError=true — aborting remaining tasks`);
          break;
        }
      }
    }

    const result: ShutdownResult = {
      success: failed.length === 0,
      completed,
      failed,
      durationMs: Date.now() - startMs
    };

    // Emit after all tasks, whether successful or not.
    this.emit("afterShutdown", result);
    console.info(
      `[shutdown] Complete. ${completed.length} succeeded, ${failed.length} failed. Total: ${result.durationMs}ms`
    );

    return result;
  }

  /**
   * Install OS signal handlers. Called once at construction, unless
   * `installSignalHandlers: false`.
   *
   * `uncaughtException` / `unhandledRejection` are intentionally NOT handled
   * here — those are application-level concerns. Handle them in your app entry
   * point, then call `shutdown('uncaughtException')`.
   */
  private _installSignalHandlers(): void {
    const handle = (signal: ShutdownReason) => {
      // If shutdown is already underway, a further signal means "hurry up": the
      // first signal starts a graceful drain, any subsequent signal forces exit
      // so a stuck process can never outlive the caller's patience forever.
      if (this._isShuttingDown) {
        console.error(`[shutdown] Second signal (${signal}) — forcing immediate exit.`);
        process.exit(1);
        return;
      }

      console.info(`[shutdown] Signal received: ${signal}`);
      this.shutdown(signal)
        .then((result) => process.exit(result.success ? 0 : 1))
        .catch((err) => {
          console.error("[shutdown] Unexpected error during shutdown:", err);
          process.exit(1);
        });
    };

    // `on` (not `once`) so a repeated signal is still observed and can force exit.
    process.on("SIGTERM", () => handle("SIGTERM"));
    process.on("SIGINT", () => handle("SIGINT"));
    process.on("SIGQUIT", () => handle("SIGQUIT"));
  }
}

/**
 * Pre-built singleton for apps that need a single shutdown manager.
 *
 * Note: a singleton carries global state. Prefer creating a fresh
 * `GracefulShutdown` per process so tests and workers stay isolated.
 */
export const gracefulShutdown = new GracefulShutdown();

blocks/graceful-shutdown/core/connection-tracker.ts

NodeConnectionTracker counts in-flight requests and exposes waitForDrain with a timeout.

import type { Server } from "node:http";

import { DRAIN_POLL_INTERVAL_MS } from "../constants";
import type { AttachableConnectionTracker, ConnectionTracker } from "../types";

/**
 * A Node.js HTTP connection tracker that attaches to a `Server` and counts
 * in-flight requests.
 *
 * `waitForDrain` resolves as soon as `activeCount` reaches zero, or rejects
 * once `timeoutMs` elapses.
 */
export class NodeConnectionTracker implements AttachableConnectionTracker {
  private _activeCount = 0;

  get activeCount(): number {
    return this._activeCount;
  }

  /**
   * Attach the tracker to a Node `http.Server`. Must be called before the
   * server starts accepting requests so no request is missed.
   */
  attach(server: Server): this {
    server.on("request", (_req, res) => {
      this._activeCount++;

      // Both 'finish' and 'close' fire on a completed request, and 'close' also
      // fires when the client disconnects mid-request. Use a one-shot guard so
      // a single request is only ever decremented once.
      let released = false;
      const done = () => {
        if (released) return;
        released = true;
        this._activeCount = Math.max(0, this._activeCount - 1);
      };

      res.once("finish", done); // response sent successfully
      res.once("close", done); // client disconnected mid-request
    });

    return this;
  }

  waitForDrain(timeoutMs: number): Promise<void> {
    if (this._activeCount === 0) return Promise.resolve();

    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        clearInterval(interval);
        reject(new Error(`Drain timed out: ${this._activeCount} requests still active`));
      }, timeoutMs);
      timer.unref();

      const interval = setInterval(() => {
        if (this._activeCount === 0) {
          clearInterval(interval);
          clearTimeout(timer);
          resolve();
        }
      }, DRAIN_POLL_INTERVAL_MS);
    });
  }
}

/**
 * Type guard — checks at runtime whether a tracker supports attachment,
 * without casting.
 */
export function isAttachable(tracker: ConnectionTracker): tracker is AttachableConnectionTracker {
  return typeof (tracker as AttachableConnectionTracker).attach === "function";
}

blocks/graceful-shutdown/utils/http.ts

createHttpShutdownTask builds the HTTP server close task and handleNode503 writes the 503 response.

import type { IncomingMessage, Server, ServerResponse } from "node:http";

import { DEFAULT_HTTP_CLOSE_TIMEOUT_MS, PRIORITY } from "../constants";
import type { Node503Options, ShutdownStateProvider, ShutdownTask } from "../types";
import { shouldRejectRequest } from "./state";
import { withTimeout } from "./timeout";

/**
 * Rejects an incoming Node.js HTTP request while the app is shutting down.
 * Returns `true` if the request was handled (rejected), `false` otherwise.
 *
 * Used inside framework adapters, not directly in application code.
 */
export function handleNode503(
  _req: IncomingMessage,
  res: ServerResponse,
  provider: ShutdownStateProvider,
  options?: Node503Options
): boolean {
  if (!shouldRejectRequest(provider)) return false;

  const statusCode = options?.statusCode ?? 503;
  const body =
    typeof options?.body === "string"
      ? options.body
      : JSON.stringify(
          options?.body ?? { error: "service_unavailable", message: "Server is shutting down" }
        );

  res.setHeader("Connection", "close");
  res.setHeader("Content-Type", "application/json");
  res.setHeader("Retry-After", "30");

  if (options?.headers) {
    for (const [key, value] of Object.entries(options.headers)) {
      res.setHeader(key, value);
    }
  }

  res.statusCode = statusCode;
  res.end(body);
  return true;
}

/**
 * Creates a shutdown task that gracefully closes a Node.js HTTP server.
 *
 * Stops accepting new connections, closes idle keep-alive connections, then
 * waits for active requests to finish (up to the task timeout).
 *
 * Uses `PRIORITY.HTTP_SERVER` (100) so traffic stops before DB pools close.
 */
export function createHttpShutdownTask(
  server: Server,
  options?: { timeout?: number }
): ShutdownTask {
  const timeout = options?.timeout ?? DEFAULT_HTTP_CLOSE_TIMEOUT_MS;
  return {
    name: "http-server",
    priority: PRIORITY.HTTP_SERVER,
    timeout,
    handler: () =>
      // Enforce the deadline here, inside the task itself, so the task stays
      // robust even if it is invoked directly rather than scheduled through
      // GracefulShutdown (its runner also wraps handlers with withTimeout, so
      // this is belt-and-braces rather than duplication).
      withTimeout(
        new Promise<void>((resolve, reject) => {
          server.close((err) => {
            if (err) {
              // ERR_SERVER_NOT_RUNNING means it was already closed — not a real error.
              if ((err as NodeJS.ErrnoException).code === "ERR_SERVER_NOT_RUNNING") {
                return resolve();
              }
              return reject(err);
            }
            resolve();
          });

          // Close idle keep-alive connections immediately so `close()` resolves
          // faster; an idle connection would otherwise block shutdown for the
          // full timeout.
          if (typeof server.closeIdleConnections === "function") {
            server.closeIdleConnections();
          }
        }),
        timeout,
        "http-server"
      )
  };
}

blocks/graceful-shutdown/utils/timeout.ts

withTimeout races any promise against a deadline and rejects with a labeled error.

/**
 * Wraps a promise so it rejects if it does not settle within `ms` milliseconds.
 *
 * @param promise The promise to race against the timeout.
 * @param ms Timeout in milliseconds.
 * @param label Used in the thrown error message to identify the task.
 */
export async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
  let timerId: ReturnType<typeof setTimeout> | undefined;

  const timeout = new Promise<never>((_, reject) => {
    timerId = setTimeout(
      () => reject(new Error(`Shutdown task "${label}" timed out after ${ms}ms`)),
      ms
    );
  });

  try {
    return await Promise.race([promise, timeout]);
  } finally {
    clearTimeout(timerId);
  }
}

blocks/graceful-shutdown/utils/state.ts

shouldRejectRequest decides whether a request should be rejected during shutdown.

import type { ShutdownStateProvider } from "../types";

/**
 * Returns `true` when the request should be rejected because the application
 * is shutting down.
 */
export function shouldRejectRequest(provider: ShutdownStateProvider): boolean {
  return provider.isShuttingDownState;
}

blocks/graceful-shutdown/adapters/express.ts

Express middleware, tracker factory, and shutdown task factory.

import type { NextFunction, Request, Response } from "express";
import type { Server } from "node:http";
import type { ShutdownStateProvider, ShutdownTask } from "../types";
import { NodeConnectionTracker } from "../core/connection-tracker";
import { createHttpShutdownTask } from "../utils/http";

// ─── Middleware ───────────────────────────────────────────────────────────────

/**
 * Express middleware that rejects incoming requests with 503
 * when the application is shutting down.
 *
 * Register this as the FIRST middleware — before routes, before auth,
 * before everything. A request that arrives during shutdown should
 * never reach your route handlers.
 *
 * Usage:
 *   app.use(createShutdownMiddleware(shutdown));
 */
export function createShutdownMiddleware(provider: ShutdownStateProvider) {
  return (req: Request, res: Response, next: NextFunction): void => {
    if (!provider.isShuttingDownState) {
      next();
      return;
    }

    res
      .set("Connection", "close")
      .set("Retry-After", "30")
      .set("Content-Type", "application/json")
      .status(503)
      .json({
        error: "service_unavailable",
        message: "Server is shutting down"
      });
  };
}

// ─── Tracker factory ──────────────────────────────────────────────────────────

/**
 * Creates and attaches a NodeConnectionTracker to an Express server.
 *
 * Call this after createServer() but before server.listen().
 * Pass the returned tracker to GracefulShutdown via the constructor
 * or setConnectionTracker().
 *
 * Usage:
 *   const server = createServer(app);
 *   const tracker = createExpressTracker(server);
 *   const shutdown = new GracefulShutdown({ connectionTracker: tracker });
 */
export function createExpressTracker(server: Server): NodeConnectionTracker {
  return new NodeConnectionTracker().attach(server);
}

// ─── Shutdown task factory ────────────────────────────────────────────────────

/**
 * Convenience re-export — Express uses the standard Node HTTP shutdown task.
 * No Express-specific logic needed here because server.close() works the same.
 *
 * IMPORTANT: registering this task is required. A ConnectionTracker only waits
 * for requests to drain; it never closes the server. Without this task the
 * listen handle stays alive and the process never exits.
 *
 * Usage:
 *   shutdown.addTask(createExpressShutdownTask(server));
 */
export function createExpressShutdownTask(
  server: Server,
  options?: { timeout?: number }
): ShutdownTask {
  // Express doesn't need anything beyond the base Node HTTP task.
  // This re-export exists so adapter users import from one place.
  return createHttpShutdownTask(server, options);
}

blocks/graceful-shutdown/adapters/fastify.ts

Fastify onRequest hook, tracker factory, and shutdown task factory.

// Fastify is built directly on the Node http server, so the generic
// NodeConnectionTracker can attach to `fastify.server`. The 503 behaviour is
// wired through an onRequest hook (it must be registered before listen()).

import type { FastifyInstance } from "fastify";

import { DEFAULT_HTTP_CLOSE_TIMEOUT_MS, PRIORITY } from "../constants";
import type { ShutdownStateProvider, ShutdownTask } from "../types";
import { NodeConnectionTracker } from "../core/connection-tracker";

// ─── Tracker factory ──────────────────────────────────────────────────────────

/**
 * Creates and attaches a NodeConnectionTracker to a Fastify instance.
 *
 * Fastify wraps a Node http server (`fastify.server`), so the same request /
 * finish / close events drive the in-flight counter.
 *
 * Usage:
 *   const tracker = createFastifyTracker(fastify);
 *   const shutdown = new GracefulShutdown({ connectionTracker: tracker });
 */
export function createFastifyTracker(fastify: FastifyInstance): NodeConnectionTracker {
  return new NodeConnectionTracker().attach(fastify.server);
}

// ─── Shutdown hook (503) ──────────────────────────────────────────────────────

/**
 * Registers an `onRequest` hook that rejects new requests with 503 once the
 * application is shutting down.
 *
 * Must be called BEFORE `fastify.listen()`.
 *
 * Alternative (built into Fastify): call
 * `await fastify.listen({ port, return503OnClosing: true })` — that returns 503
 * only while `fastify.close()` is running. This hook covers the larger window
 * from when `shutdown()` starts until the server actually closes.
 *
 * Usage:
 *   registerFastifyShutdownHooks(fastify, shutdown);
 */
export function registerFastifyShutdownHooks(
  fastify: FastifyInstance,
  provider: ShutdownStateProvider
): void {
  fastify.addHook("onRequest", async (_request, reply) => {
    if (provider.isShuttingDownState) {
      return reply
        .code(503)
        .header("Connection", "close")
        .header("Retry-After", "30")
        .type("application/json")
        .send({ error: "service_unavailable", message: "Server is shutting down" });
    }
  });
}

// ─── Shutdown task factory ────────────────────────────────────────────────────

/**
 * Creates a shutdown task that gracefully closes a Fastify instance.
 *
 * `fastify.close()` stops accepting connections and waits for in-flight
 * requests and onClose hooks to finish. Idle keep-alive connections are closed
 * first so `close()` does not block on them.
 *
 * IMPORTANT: registering this task is required. A ConnectionTracker only waits
 * for requests to drain; it never closes the server.
 *
 * Usage:
 *   shutdown.addTask(createFastifyShutdownTask(fastify));
 */
export function createFastifyShutdownTask(
  fastify: FastifyInstance,
  options?: { timeout?: number }
): ShutdownTask {
  return {
    name: "fastify-server",
    priority: PRIORITY.HTTP_SERVER,
    timeout: options?.timeout ?? DEFAULT_HTTP_CLOSE_TIMEOUT_MS,
    handler: async () => {
      // Release idle keep-alive sockets so close() resolves promptly.
      if (typeof fastify.server.closeIdleConnections === "function") {
        fastify.server.closeIdleConnections();
      }

      try {
        await fastify.close();
      } catch (err) {
        // Already closed is not an error.
        if ((err as NodeJS.ErrnoException)?.code === "ERR_SERVER_NOT_RUNNING") {
          return;
        }
        throw err;
      }
    }
  };
}

blocks/graceful-shutdown/adapters/hono.ts

Hono middleware, tracker factory, and shutdown task factory.

// Hono on Node (`@hono/node-server`) runs on a plain Node http server, so the
// generic NodeConnectionTracker attaches to that server and the Node HTTP
// shutdown task closes it. The 503 behaviour is a standard Hono middleware.

import type { Server } from "node:http";
import type { MiddlewareHandler } from "hono";

import type { ShutdownStateProvider, ShutdownTask } from "../types";
import { NodeConnectionTracker } from "../core/connection-tracker";
import { createHttpShutdownTask } from "../utils/http";

// ─── Middleware (503) ─────────────────────────────────────────────────────────

/**
 * Hono middleware that rejects incoming requests with 503 once the
 * application is shutting down.
 *
 * Register it before your routes:
 *   app.use('*', createHonoShutdownMiddleware(shutdown));
 */
export function createHonoShutdownMiddleware(provider: ShutdownStateProvider): MiddlewareHandler {
  return async (c, next) => {
    if (!provider.isShuttingDownState) {
      return next();
    }

    return c.json({ error: "service_unavailable", message: "Server is shutting down" }, 503, {
      Connection: "close",
      "Retry-After": "30"
    });
  };
}

// ─── Tracker factory ──────────────────────────────────────────────────────────

/**
 * Creates and attaches a NodeConnectionTracker to the Node http server that
 * `@hono/node-server` created for a Hono app.
 *
 * Usage:
 *   const server = createServer(getRequestListener(app.fetch));
 *   const tracker = createHonoTracker(server);
 *   const shutdown = new GracefulShutdown({ connectionTracker: tracker });
 */
export function createHonoTracker(server: Server): NodeConnectionTracker {
  return new NodeConnectionTracker().attach(server);
}

// ─── Shutdown task factory ────────────────────────────────────────────────────

/**
 * Creates a shutdown task that closes the Node http server backing a Hono app.
 *
 * IMPORTANT: registering this task is required. A ConnectionTracker only waits
 * for requests to drain; it never closes the server.
 *
 * Usage:
 *   shutdown.addTask(createHonoShutdownTask(server));
 */
export function createHonoShutdownTask(
  server: Server,
  options?: { timeout?: number }
): ShutdownTask {
  return createHttpShutdownTask(server, options);
}

Configuration

GracefulShutdownOptions

OptionTypeDefaultDescription
hardTimeoutMsnumber30000Hard kill timeout. process.exit(1) fires regardless once this elapses.
defaultTaskTimeoutMsnumber10000Fallback per-task timeout when a task does not set timeout.
stopOnErrorbooleanfalseWhen true, abort remaining tasks after the first failure.
installSignalHandlersbooleantrueRegister SIGTERM / SIGINT / SIGQUIT handlers. Set false for manual wiring.
onTaskComplete(name, durationMs, error?) => voidCalled after each task completes or fails. Use for metrics.
connectionTrackerConnectionTrackerTracker awaited before tasks run, so in-flight requests finish first.
drainTimeoutMsnumber10000How long to wait for active connections to drain.

ShutdownTask

NameTypeRequiredDescription
namestringYesUnique task name, used in logs and results.
handler() => Promise<void> | voidYesThe cleanup work to run.
timeoutnumberNoPer-task timeout in ms, overrides defaultTaskTimeoutMs.
prioritynumberNoHigher runs first. See PRIORITY for conventions (default 50).

Architecture

shutdown(reason)


isShuttingDownState = true   ← readiness probes must start returning 503 here


Emit "beforeShutdown" — listeners stop starting new work


Drain (only if a connectionTracker is configured)
   ├── emit "draining" ──► waitForDrain(drainTimeoutMs) ──► emit "drained"
   └── timeout ──► emit "drainTimeout" and continue


Run tasks in priority order (highest first)
   │ each handler raced against its timeout via withTimeout
   │ stopOnError → abort remaining tasks on first failure


Emit "afterShutdown" with the aggregate ShutdownResult


Hard timeout (hardTimeoutMs) → process.exit(1) as a last resort

The manager keeps the whole sequence behind a single reserved promise. shutdown() is idempotent: the first call starts the run, and every subsequent call returns the exact same promise object. A hard timeout runs independently and calls process.exit(1) if the run never finishes.


When to Use

  • You run a long-lived Node.js service (HTTP API, worker, WebSocket server) that must finish in-flight work before exiting.
  • You want to stop accepting new traffic with a clean 503 during a rolling deploy.
  • You have cleanup tasks with ordering constraints (for example, close the HTTP server before closing the database pool).
  • You want a guaranteed hard kill if cleanup gets stuck.

When Not to Use

  • You run short-lived, stateless workloads (for example, one-shot serverless functions) where the platform owns the lifecycle.
  • Your process has no shared resources that need closing.
  • You need complex orchestration with task-to-task dependencies or retries — the runner executes tasks sequentially in priority order only.

Usage

Express

import express from "express";
import { createServer } from "node:http";
import { GracefulShutdown, PRIORITY } from "@/blocks/graceful-shutdown";
import {
  createShutdownMiddleware,
  createExpressTracker,
  createExpressShutdownTask
} from "@/blocks/graceful-shutdown/adapters/express";

const app = express();
const server = createServer(app);

const tracker = createExpressTracker(server);
const shutdown = new GracefulShutdown({ connectionTracker: tracker });

// First middleware — rejects new requests with 503 during shutdown
app.use(createShutdownMiddleware(shutdown));

// Register tasks (highest priority first)
shutdown.addTask(createExpressShutdownTask(server)); // closes the HTTP server
shutdown.addTask({ name: "db", priority: PRIORITY.DB_POOL, handler: () => pool.end() });
shutdown.addTask({ name: "redis", priority: PRIORITY.CACHE, handler: () => redis.quit() });
shutdown.addTask({ name: "logger", priority: PRIORITY.LOGGERS, handler: () => logger.flush() });

server.listen(3000, "0.0.0.0");
// SIGTERM / SIGINT are handled automatically

Fastify

import Fastify from "fastify";
import { GracefulShutdown, PRIORITY } from "@/blocks/graceful-shutdown";
import {
  createFastifyTracker,
  registerFastifyShutdownHooks,
  createFastifyShutdownTask
} from "@/blocks/graceful-shutdown/adapters/fastify";

const app = Fastify();
const tracker = createFastifyTracker(app);
const shutdown = new GracefulShutdown({ connectionTracker: tracker });

registerFastifyShutdownHooks(app, shutdown); // returns 503 for new requests

shutdown.addTask(createFastifyShutdownTask(app));
shutdown.addTask({ name: "db", priority: PRIORITY.DB_POOL, handler: () => pool.end() });

await app.listen({ port: 3000, host: "0.0.0.0" });

Hono

import { createServer } from "node:http";
import { getRequestListener } from "@hono/node-server";
import { Hono } from "hono";
import { GracefulShutdown, PRIORITY } from "@/blocks/graceful-shutdown";
import {
  createHonoShutdownMiddleware,
  createHonoTracker,
  createHonoShutdownTask
} from "@/blocks/graceful-shutdown/adapters/hono";

const app = new Hono();
const server = createServer(getRequestListener(app.fetch));

const tracker = createHonoTracker(server);
const shutdown = new GracefulShutdown({ connectionTracker: tracker });

app.use("*", createHonoShutdownMiddleware(shutdown));

shutdown.addTask(createHonoShutdownTask(server));
shutdown.addTask({ name: "db", priority: PRIORITY.DB_POOL, handler: () => pool.end() });

server.listen(3000, "0.0.0.0");

Readiness Probe During Shutdown

Wire isShuttingDownState into your readiness endpoint so traffic stops before you drain:

app.get("/health/ready", async (_req, res) => {
  if (shutdown.isShuttingDownState) {
    return res.status(503).json({
      status: "shutting_down",
      message: "Instance is draining and will not accept new work"
    });
  }
  res.status(200).json({ status: "ready" });
});

API Reference

GracefulShutdown

class GracefulShutdown extends EventEmitter {}

Coordinates and executes cleanup tasks on shutdown. Emits beforeShutdown, draining, drained, drainTimeout, and afterShutdown.

new GracefulShutdown(options?)

ParameterTypeRequiredDescription
optionsGracefulShutdownOptionsNoConfiguration (see above).

shutdown

shutdown(reason?: ShutdownReason): Promise<ShutdownResult>;

Starts a graceful shutdown. Idempotent — every caller receives the same in-flight result promise.

addTask

addTask(task: ShutdownTask): this;

Registers a task. Tasks with the same name replace existing ones.

isShuttingDownState

readonly isShuttingDownState: boolean;

true while the application is shutting down. Use this in your readiness probe.

NodeConnectionTracker

class NodeConnectionTracker implements AttachableConnectionTracker {}

Counts in-flight requests on a Node.js HTTP server and drains to zero.

attach

attach(server: Server): this;

Attaches the tracker to an http.Server. Must be called before the server accepts requests.

waitForDrain

waitForDrain(timeoutMs: number): Promise<void>;

Resolves when activeCount reaches zero, or rejects if that has not happened within timeoutMs.

createHttpShutdownTask

function createHttpShutdownTask(server: Server, options?: { timeout?: number }): ShutdownTask;

Creates the HTTP server shutdown task. Closes the server, closes idle keep-alive connections, and waits for active requests.

handleNode503

function handleNode503(
  req: IncomingMessage,
  res: ServerResponse,
  provider: ShutdownStateProvider,
  options?: Node503Options
): boolean;

Rejects an incoming Node.js HTTP request while shutting down. Sets status 503, Connection: close, Retry-After: 30.

ShutdownResult

interface ShutdownResult {
  success: boolean;
  completed: string[];
  failed: TaskFailure[];
  durationMs: number;
}

PRIORITY

ConstantValueTypical use
PRIORITY.HTTP_SERVER100Close the HTTP server first
PRIORITY.QUEUE_CONSUMER80Stop consuming new jobs
PRIORITY.JOB_QUEUE60Drain / close job queues
PRIORITY.DB_POOL40Close database pools
PRIORITY.CACHE20Close Redis / cache clients
PRIORITY.LOGGERS10Flush logs last

Examples

Manual Signal Wiring

When you control your own signal handling:

const shutdown = new GracefulShutdown({ installSignalHandlers: false });

process.on("SIGTERM", () => {
  shutdown
    .shutdown("SIGTERM")
    .then((result) => process.exit(result.success ? 0 : 1))
    .catch(() => process.exit(1));
});

Reusing the Tracker without a Framework Adapter

The core tracker works with any Node HTTP server:

import { createServer } from "node:http";
import {
  GracefulShutdown,
  NodeConnectionTracker,
  createHttpShutdownTask
} from "@/blocks/graceful-shutdown";

const server = createServer(app);
const tracker = new NodeConnectionTracker().attach(server);
const shutdown = new GracefulShutdown({ connectionTracker: tracker });

shutdown.addTask(createHttpShutdownTask(server));

  • Health Check — Combine with a readiness endpoint that returns 503 when isShuttingDownState is true.
  • Logger — Register a PRIORITY.LOGGERS (10) task to flush telemetry last.

FAQ

What happens if a task exceeds its timeout?

It is recorded in result.failed with a timeout error and the remaining tasks continue, unless stopOnError is enabled.

What happens when the hard timeout is exceeded?

process.exit(1) fires regardless of task state. It cannot be disabled.

How is shutdown() idempotent?

The first call creates the run and stores its promise. Every later call returns the exact same promise object, so the run executes once no matter how many callers trigger it.

What does the second OS signal do?

With installSignalHandlers: true, the first signal starts a graceful drain and any subsequent signal forces an immediate process.exit(1).

How do new requests behave during shutdown?

The framework adapters return 503 with Connection: close and Retry-After: 30 while the application is shutting down. The server stops accepting new connections once the HTTP-server task runs.

On this page