How to Handle Graceful API Shutdowns

Brew #3·September 17, 2026·11 minutes read

Graceful shutdown is the controlled termination of an API that allows ongoing requests to complete before its dependencies are closed or released.

Abrupt termination can cause:

  • Ongoing requests to be interrupted before completion, potentially leaving partially completed operations that require recovery.
  • Clients to lose their connection before receiving a complete response and retry an operation that has already completed.

These failures are particularly undesirable during routine operations such as deployments, container restarts, autoscaling, or host maintenance.

The short answer

The most common way to perform a graceful API shutdown is to:

  1. Handle the termination signal.
  2. Mark the application as shutting down.
  3. Reject new incoming requests.
  4. Wait for ongoing operations to finish.
  5. Close or release shared dependencies.
  6. Allow the application to terminate naturally.
  7. Forcefully terminate the application if the sequence can't complete.

The graceful shutdown sequence

Each step of the graceful shutdown sequence prepares for the next one until the application either terminates normally or forcefully at the expiration of a timeout.

1. Handling process termination signals

A graceful shutdown usually starts when the application receives one of these operating-system signals:

  • SIGTERM: commonly sent by a process supervisor or container orchestrator to terminate a process in a production environment.
  • SIGINT: commonly sent by a developer when pressing Ctrl+C during local development.

On non-Windows platforms, either signal terminates the Node.js process by default. Intercepting them gives the application a chance to run its shutdown sequence first, so it can finish processing active requests and clean up dependencies.

Implementing signal handlers

In Node.js, the process.on() method is used to intercept the SIGTERM and SIGINT signals and execute the shutdown handler responsible for performing the shutdown sequence.

type AppResources = {
  lifecycle: AppLifecycle;
  server?: Server | undefined;
  providers?: APIProviders | undefined;
};

function registerShutdown(resources: AppResources): void {
  process.on('SIGTERM', () => {
    void shutdown(resources);
  });

  process.on('SIGINT', () => {
    void shutdown(resources);
  });
}

Ideally, these signal handlers should be registered immediately after starting the HTTP server, before the listening callback marks the application as ready.

let providers: APIProviders | undefined;
let server: Server | undefined;

try {
  const configuration = await loadConfiguration();
  providers = await loadAPIProviders(configuration);
  const services = loadServices({ configuration, providers });
  const app = loadServer({ configuration, lifecycle, services });

  server = app.listen(
    configuration.server.port,
    configuration.server.host,
    (error) => {
      if (error) {
        void shutdown({ lifecycle, server, providers });
        return;
      }

      lifecycle.setState('READY');
    }
  );

  registerShutdown({ lifecycle, server, providers });
} catch {
  void shutdown({ lifecycle, server, providers });
}

2. Marking the application as shutting down

When the shutdown handler is executed, it should immediately change the application state to shutting down. This state is then used to prevent the shutdown sequence from running more than once and to reject new incoming requests.

Tracking application state

An easy way to keep track of the application state is to create a singleton with a state property that holds one of these three values:

  • STARTING: the application is loading its configuration, initializing required dependencies, and starting the HTTP server.
  • READY: the required dependencies have been initialized and the HTTP server is listening for incoming requests.
  • SHUTTING_DOWN: graceful shutdown has started and the application should no longer receive new requests.
export type AppState =
  | 'STARTING'
  | 'READY'
  | 'SHUTTING_DOWN';

class AppLifecycle {
  private state: AppState = 'STARTING';

  isReady(): boolean {
    return this.state === 'READY';
  }

  isShuttingDown(): boolean {
    return this.state === 'SHUTTING_DOWN';
  }

  setState(state: AppState): void {
    this.state = state;
  }
}

export const lifecycle = new AppLifecycle();

Ideally, this module should be imported at the top level and injected into the lower layers of the application like any other dependency.

Preventing concurrent shutdowns

The application may receive more than one termination signal before it completes shutdown.

To prevent the shutdown sequence from running more than once, the shutdown handler should:

  1. Check whether the application is already shutting down.
  2. Return immediately if so.
  3. Change the application state otherwise.
async function shutdown({ lifecycle }: AppResources): Promise<void> {
  if (lifecycle.isShuttingDown()) {
    return;
  }

  lifecycle.setState('SHUTTING_DOWN');
}

3. Rejecting new incoming requests

Once the application has been marked as shutting down, it should reject incoming requests by:

  1. Reporting to the process supervisor, container orchestrator, or load balancer that the instance is no longer ready to receive traffic.
  2. Returning a temporary unavailability response to any client whose request still reaches the application.

Reporting the instance as not ready

Process supervisors, container orchestrators, and load balancers commonly determine whether an API instance can receive traffic by periodically calling a dedicated readiness endpoint. Once that endpoint starts returning an HTTP 503 Service Unavailable, the surrounding infrastructure can mark the instance as unavailable and stop routing new requests to it.

A typical readiness endpoint:

  1. Checks the application state.
  2. Responds with an HTTP 200 OK if the application is ready to receive requests.
  3. Responds with an HTTP 503 Service Unavailable otherwise.
app.get('/ready', (_req, res) => {
  res.sendStatus(lifecycle.isReady() ? 200 : 503);
});

You can learn more about this by reading How to Implement an API Readiness Endpoint.

Responding with temporary unavailability

Readiness checks are not continuous. There is always a short interval between the moment the application becomes unavailable and the moment the surrounding infrastructure observes the next failed readiness check. During that interval, new requests can still reach the instance and must be rejected before they start normal request processing.

The most common way for an API to signal clients that it can't process new incoming requests is to implement request middleware that:

  1. Checks the application state.
  2. Responds with an HTTP 503 Service Unavailable if the application isn't ready.
  3. Forwards the request to the next component in the processing pipeline otherwise.
app.use((_req, res, next) => {
  if (!lifecycle.isReady()) {
    res.sendStatus(503);
    return;
  }
  next();
});

Ideally, this middleware should be declared after middleware that handles cross-cutting concerns, such as request tracing, request logging, or CORS, but before authentication, validation, business logic, or any route handler that begins processing the request.

Note that an HTTP 503 Service Unavailable response can only be returned while the request still reaches the application. Once the HTTP server stops accepting connections, subsequent requests may fail before the application has a chance to return an HTTP response.

4. Waiting for ongoing requests to finish

After the application starts rejecting new incoming requests, it should allow requests that are already being processed to finish before closing the dependencies they rely on. This period is commonly called draining.

Draining ongoing requests

In Node.js, the HTTP server returned by app.listen() exposes a close() method that:

  1. Stops the server from accepting new connections.
  2. Closes idle connections (on Node.js 19 and later).
  3. Allows active requests to finish.
  4. Calls its callback once the server has fully closed.

The server reference kept during startup can then be passed to the shutdown handler to drain ongoing requests.

// ...

function closeServer(server: Server): Promise<void> {
  return new Promise((resolve, reject) => server.close((error) => {
    if (error) {
      reject(error);
      return;
    }

    resolve();
  }));
}

async function shutdown({
  lifecycle,
  server
}: AppResources): Promise<void> {
  // ...

  if (server?.listening) {
    try {
      await closeServer(server);
    } catch (error) {
      console.error(error);
    }
  }
}

If draining fails, the shutdown handler should continue with the remaining cleanup steps so one failure does not prevent other resources from being released.

5. Closing shared dependencies

Once ongoing requests have finished, the application should close or release the shared dependencies it owns, such as database connections, Redis connections, message brokers, outbound HTTP clients, and so on.

Cleaning up application dependencies

Independent dependencies can be closed concurrently with Promise.allSettled() so that every cleanup operation is attempted even if one of them fails.

// ...

async function shutdown({
  lifecycle,
  server,
  providers
}: AppResources): Promise<void> {
  // ...

  if (providers !== undefined) {
    const results = await Promise.allSettled([
      providers.database.close(),
      // Other independent cleanup operations
    ]);

    for (const result of results) {
      if (result.status === 'rejected') {
        console.error(result.reason);
      }
    }
  }
}

If dependencies have cleanup-order requirements, close them in that order instead of concurrently.

6. Allowing the process to terminate naturally

Once ongoing requests have finished and application dependencies have been closed, the shutdown handler should set the process exit code and allow Node.js to terminate naturally.

Setting the process exit code

To determine the process.exitCode value, the shutdown handler can use a cleanupFailed variable that is set to true whenever a shutdown operation fails. Additionally, it can accept an optional exit code so shutdowns caused by application errors, such as a startup failure, can terminate with a non-zero status even when cleanup succeeds.

type ShutdownOptions = {
  exitCode?: number;
};

// ...

async function shutdown(
  { server, lifecycle, providers }: AppResources,
  { exitCode = 0 }: ShutdownOptions = {}
): Promise<void> {
  // ...

  let cleanupFailed = false;

  if (server?.listening) {
    try {
      await closeServer(server);
    } catch (error) {
      console.error(error);
      cleanupFailed = true;
    }
  }

  if (providers !== undefined) {
    // ...

    for (const result of results) {
      if (result.status === 'rejected') {
        console.error(result.reason);
        cleanupFailed = true;
      }
    }
  }

  process.exitCode = cleanupFailed ? 1 : exitCode;
}

Using process.exitCode instead of process.exit() records the shutdown result without terminating the process immediately, allowing remaining event-loop work, such as buffered logs or network writes, to complete before Node.js exits.

7. Bounding the entire sequence with a timeout

To prevent shutdown failures, such as hanging requests or open connections, from keeping the application alive indefinitely, the shutdown handler should apply a timeout to the entire sequence.

If the shutdown sequence completes before the deadline, the timeout should not prevent Node.js from terminating naturally. If the deadline expires first, the process should be forcefully terminated.

Ideally, the timeout should be shorter than the forced-termination deadline configured by the surrounding runtime or orchestrator, so the application gets a chance to handle the timeout itself before the process is killed externally.

Enforcing the shutdown timeout

The shutdown timeout should start immediately after the application is marked as shutting down and be unreferenced so it doesn't prevent Node.js from terminating naturally.

// ...

const SHUTDOWN_TIMEOUT_MS = 10_000;

async function shutdown(
  { server, lifecycle, providers }: AppResources,
  { exitCode = 0 }: ShutdownOptions = {}
): Promise<void> {
  // ...

  const forceShutdownTimer = setTimeout(() => {
    process.exit(1);
  }, SHUTDOWN_TIMEOUT_MS);

  forceShutdownTimer.unref();

  // ...

  process.exitCode = cleanupFailed ? 1 : exitCode;
}

Production considerations

A graceful shutdown implementation should also account for the environment in which the application runs and the type of operations it performs.

Integrating shutdown with the surrounding infrastructure

The surrounding runtime and traffic infrastructure must work with the application's shutdown sequence.

In practice, this means ensuring that:

  • Termination signals reach the application process.
  • Readiness checks reflect the application's shutdown state.
  • Instances that are shutting down are removed from traffic routing.

Handling long-lived operations explicitly

Some operations may remain active for most or all of the shutdown period.

For example:

  • Streaming responses
  • Server-Sent Events
  • WebSockets
  • Long polling
  • Long-running jobs

These workloads require an explicit shutdown policy. Depending on the operation, the application may allow the operation to finish, notify the client, stop it gracefully, or terminate it before the shutdown timeout expires.

Designing interrupted operations for recovery

While graceful shutdown reduces the chance of interrupting ongoing operations, it can't guarantee that every operation finishes. The shutdown timeout may expire, the process may crash, the host may fail, or the process may be terminated before cleanup completes. Operations that can't safely be interrupted should therefore provide their own recovery mechanisms.

For example:

  • Database transactions
  • Idempotent operations
  • Durable queues
  • Checkpoints or resumable workflows

Observing and testing shutdown behavior

Since most shutdown failures appear during deployments or under load, the shutdown sequence should be observable and tested under realistic conditions.

At minimum, record:

  • What initiated shutdown
  • When draining starts and finishes
  • Cleanup failures
  • Total shutdown duration
  • Whether the shutdown timeout expires

You should also test shutdown while:

  • A request is still being processed to verify that it finishes before its dependencies are closed.
  • A cleanup operation hangs to verify that the shutdown timeout forcefully terminates the process.

Not subscribed yet?

BackendBrewery is just getting started, and my first goal is to bring together 1,000 developers who want to build better backends one decision at a time.

Join today, it's free: