hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Error Handling and Resilience with @hectoday/http

The problem with errors

  • Why error handling matters
  • Project setup

Error fundamentals

  • JavaScript error types
  • Try-catch and error propagation
  • Async errors

Structured error handling

  • Custom error classes
  • A global error handler
  • Operational vs programmer errors

Resilience patterns

  • Retries
  • Timeouts
  • Circuit breakers
  • Fallbacks and degradation

Server lifecycle

  • Graceful shutdown
  • Uncaught exceptions and unhandled rejections
  • Health checks under failure

Putting it all together

  • Error handling checklist
  • Capstone: resilient e-commerce API

Graceful shutdown

Why graceful shutdown matters

Everything we have built so far handles errors while the server is running. But what happens when the server itself needs to stop?

When you deploy a new version of your app (as covered in the Deploying with Docker course), Docker sends a signal to the running container saying “please shut down.” The container has a few seconds to wrap things up before Docker kills it forcefully.

Without graceful shutdown, in-flight requests are dropped mid-response. A user is in the middle of placing an order. The payment has been charged but the order record has not been written to the database yet. The server dies. The user is charged but has no order. That is a real problem.

Graceful shutdown means: stop accepting new work, finish what you started, clean up resources, then exit.

Signals

Your operating system communicates with processes through signals. Three of them matter here.

SIGTERM means “please shut down.” This is the polite request. Docker, Kubernetes, and process managers send this when they want to stop a process. Your code can catch this signal and do cleanup before exiting.

SIGINT means “interrupt.” This is what happens when you press Ctrl+C in the terminal. It has the same meaning: please shut down. You will see this constantly during development.

SIGKILL means “stop immediately.” This one cannot be caught or handled. The process is killed instantly, no cleanup, no goodbye. Docker sends this after a grace period (default 10 seconds) if the process has not exited after receiving SIGTERM. This is the “I asked nicely and you ignored me” signal.

srvx handles signals for you

Here is the good news: srvx, the server library we set up in the project setup lesson, handles graceful shutdown automatically. When you call serve(), srvx listens for SIGTERM and SIGINT, stops accepting new connections, waits for in-flight requests to complete, and exits. You do not need to write any signal handling code for that part.

The default grace period is 5 seconds. If in-flight requests have not finished by then, srvx forcefully closes all connections and exits. You can press Ctrl+C a second time during the grace period to force an immediate close.

// src/server.ts
import { serve } from "srvx";
import { app } from "./app.js";

const server = serve({ fetch: app.fetch, port: 3000 });

That is it. srvx registers the signal handlers, manages the countdown, and shuts down cleanly. If you start this server and press Ctrl+C, you will see a message like:

Stopping server gracefully (5s)... Press Ctrl+C again to force close.

It counts down each second. If all connections close before the timeout, it prints a success message and exits.

Custom cleanup

Stopping the HTTP server is only half the job. We also need to close the database connection, flush any pending logs, and clean up any other resources. srvx handles the HTTP part, but it does not know about your database.

To add custom cleanup, we listen for the same signals and run our cleanup after calling server.close():

Code along
// src/server.ts
import { serve } from "srvx";
import { app } from "./app.js";
import db from "./db.js";

const server = serve({
  fetch: app.fetch,
  port: 3000,
  gracefulShutdown: false, // We handle it ourselves
});

function cleanup() {
  try {
    db.close();
    console.log("Database connection closed.");
  } catch {}
  // Close any other resources: Redis, message queues, file handles
}

const SHUTDOWN_TIMEOUT = 10_000;
let shutdownTimer: ReturnType<typeof setTimeout> | null = null;

async function shutdown(signal: string) {
  if (shutdownTimer) return; // Already shutting down

  console.log(`\n${signal} received. Starting graceful shutdown...`);

  // Force exit if shutdown takes too long
  shutdownTimer = setTimeout(() => {
    console.error("Graceful shutdown timed out. Forcing exit.");
    cleanup();
    process.exit(1);
  }, SHUTDOWN_TIMEOUT);

  // Step 1: Stop accepting new connections, wait for in-flight requests
  await server.close();
  console.log("HTTP server closed.");

  // Step 2: Close database and other resources
  cleanup();

  // Step 3: Exit
  console.log("Shutdown complete.");
  process.exit(0);
}

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

Let’s walk through what happens when SIGTERM arrives.

The shutdown function is called. First, it checks whether shutdown is already in progress (the shutdownTimer guard prevents running cleanup twice if both SIGTERM and SIGINT arrive). Then it starts the SHUTDOWN_TIMEOUT safety net of 10 seconds. If the graceful shutdown takes too long (maybe a WebSocket connection is not closing, or a request is stuck), the timeout fires and forces an exit. This matches Docker’s default grace period. If your shutdown takes longer than 10 seconds, Docker sends SIGKILL anyway.

Next, it calls server.close(), which returns a promise. This stops the server from accepting new connections, but existing connections (the ones with in-flight requests) continue until they complete naturally. The user who is in the middle of placing an order gets their response. The promise resolves when all connections have finished.

Then we run cleanup(), which closes the database. The try-catch is there because by the time we are shutting down, the database might already be in a bad state. We do not want the cleanup itself to prevent exit.

You can also customize the srvx grace period instead of disabling it entirely. If your needs are simple and you just need the timeout adjusted:

const server = serve({
  fetch: app.fetch,
  port: 3000,
  gracefulShutdown: {
    gracefulTimeout: 10_000, // Wait 10 seconds instead of the default 5
  },
});

This keeps srvx’s built-in signal handling but gives in-flight requests more time to finish. The trade-off is that you do not get custom cleanup (like closing the database) because srvx does not have a hook for that. For most applications, you will want the custom shutdown approach.

[!NOTE] The Deploying with Docker course explained PID 1 and why the Dockerfile uses exec form (CMD ["node", "dist/server.js"]). This ensures Node.js receives SIGTERM directly. If a shell process is PID 1 instead, the signal is not forwarded and graceful shutdown does not work.

How other runtimes handle this

Our project runs on Node.js, but the concepts are the same everywhere. If you are using Deno or Bun without srvx, here is how graceful shutdown works in each runtime.

Deno

Deno’s built-in Deno.serve() returns a server with a shutdown() method that waits for in-flight requests to complete:

const server = Deno.serve({ port: 3000 }, app.fetch);

async function shutdown() {
  console.log("Shutting down...");
  await server.shutdown(); // Waits for in-flight requests
  db.close();
  Deno.exit(0);
}

Deno.addSignalListener("SIGTERM", shutdown);
Deno.addSignalListener("SIGINT", shutdown);

Deno uses Deno.addSignalListener instead of process.on. The server.shutdown() method works like server.close() in Node: it stops accepting new connections and waits for existing requests to finish. The rest is the same: close your database, then exit.

Bun

Bun’s Bun.serve() returns a server with a stop() method:

const server = Bun.serve({ port: 3000, fetch: app.fetch });

function shutdown() {
  console.log("Shutting down...");
  server.stop(); // Stop accepting new connections
  db.close();
  process.exit(0);
}

process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

Bun uses process.on for signals, same as Node. The server.stop() method stops the server. You can pass true to forcefully close active connections: server.stop(true).

The pattern is the same everywhere

Regardless of the runtime, the sequence is always:

  1. Listen for SIGTERM and SIGINT
  2. Stop accepting new connections
  3. Wait for in-flight requests to complete
  4. Close your own resources (database, caches, queues)
  5. Exit the process

The API names differ (close(), shutdown(), stop()), but the concept is identical. srvx abstracts over all three runtimes, which is why our project uses it. But understanding how each runtime works underneath helps when you are debugging shutdown issues or working on a project that does not use srvx.

Testing graceful shutdown

You can test this manually. Start the server, send a request, and send SIGTERM before the request completes:

# Start the server
node dist/server.js &
SERVER_PID=$!

# Send a long-running request
curl http://localhost:3000/slow-endpoint &

# Send SIGTERM
kill -SIGTERM $SERVER_PID

# The server should:
# 1. Log "SIGTERM received"
# 2. Stop accepting new connections
# 3. Wait for the slow request to complete
# 4. Close the database
# 5. Exit

If you see the shutdown messages in order and the slow request gets a response, graceful shutdown is working.

Exercises

Exercise 1: Add the graceful shutdown handler with custom cleanup. Start the server. Press Ctrl+C. Verify the shutdown log messages appear in order.

Exercise 2: Start a long-running request (add a route with a 5-second setTimeout). Send SIGTERM during the request. Verify the request completes before the server exits.

Exercise 3: Set the shutdown timeout to 2 seconds. Start a 5-second request. Send SIGTERM. Verify the forced exit fires after 2 seconds.

Graceful shutdown handles the planned exit. But what about unplanned exits? What happens when an error escapes everything, even the global error handler? Next, we will cover the last safety net: uncaught exceptions and unhandled rejections.

Why does graceful shutdown stop accepting new connections before closing existing ones?

← Fallbacks and degradation Uncaught exceptions and unhandled rejections →

© 2026 hectoday. All rights reserved.