
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Dropped requests during rolling deployments remain a primary source of user-facing errors in modern web applications, even when using high-performance runtimes. Implementing graceful shutdown and health checks in Bun correctly bridges the gap between raw throughput and production reliability, ensuring your service stops accepting new work only after finishing existing tasks. This guide covers the exact signal handling, drain logic, and probe endpoints required for safe orchestration in Kubernetes or systemd environments.
How do you implement graceful shutdown and health checks in Bun?
You implement this pattern by combining Bun's native server.stop() method with process signal listeners and a shared state flag for health endpoints. Unlike Node.js, where you might manually track socket connections, Bun handles the low-level connection draining internally when you call stop with the appropriate options. The critical piece most tutorials miss is coordinating this stop signal with your health check endpoint so load balancers stop sending traffic before the server begins rejecting connections.
In my experience managing high-traffic services, the difference between a naive shutdown and a proper one is measurable in error rates during CI/CD pipelines. Without coordination, Kubernetes will continue routing traffic to a pod that has already initiated its shutdown sequence, resulting in 502 Bad Gateway errors. Proper implementation requires treating the health endpoint and the shutdown handler as coupled components rather than isolated features. For teams also managing database connections, pairing this with PostgreSQL administration essentials ensures your DB pool closes cleanly alongside the HTTP server.
Core shutdown implementation
The following pattern works for Bun 1.1+ and handles both SIGTERM (Kubernetes/docker) and SIGINT (local development). It uses an AbortController to signal the health endpoint immediately upon shutdown initiation.
const server = Bun.serve({
port: 3000,
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/health") {
if (isShuttingDown) {
return new Response("Shutting Down", { status: 503 });
}
return new Response("OK", { status: 200 });
}
return handleRequest(req);
},
});
let isShuttingDown = false;
async function gracefulShutdown(signal: string) {
console.log(`Received ${signal}. Starting graceful shutdown...`);
isShuttingDown = true;
// Stop accepting new connections immediately
// Wait up to 30s for in-flight requests
await server.stop(true);
console.log("Server stopped. Cleaning up resources...");
await cleanupDatabasePool();
await flushMetrics();
process.exit(0);
}
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT")); The server.stop(true) call is key: passing true tells Bun to wait for in-flight requests to complete rather than terminating them abruptly. However, Bun does not enforce a maximum drain timeout natively in all versions. You should wrap this in a race condition with a forced exit timer to prevent hung processes from blocking deployments indefinitely.
Why are separate liveness and readiness probes necessary?
A common mistake in Bun applications is using a single /health endpoint for both liveness and readiness. These serve fundamentally different purposes in orchestration systems. Liveness determines if the process needs to be restarted (deadlock, infinite loop). Readiness determines if the instance should receive traffic (warming up, shutting down, dependency failure).
When implementing graceful shutdown and health checks in Bun, conflating these leads to cascading failures. If your database connection pool is exhausted, a combined health check fails, causing Kubernetes to restart the pod. But the pod isn't dead—it's just busy. Restarting it drops active requests and worsens the problem. Separating these concerns allows the orchestrator to make intelligent routing decisions independent of process lifecycle management.
| Probe Type | Purpose | Failure Action | Bun Implementation |
|---|---|---|---|
| Liveness | Is the process alive? | Restart container | Simple 200 OK, no external deps |
| Readiness | Can it serve traffic? | Remove from LB rotation | Check DB, cache, + shutdown flag |
| Startup | Is initialization complete? | Delay other probes | Check warmup state / migrations |
Differentiated endpoint pattern
// Liveness: Always responds unless event loop is blocked
app.get("/health/live", () => new Response("OK"));
// Readiness: Fails during shutdown or dependency outage
app.get("/health/ready", async () => {
if (isShuttingDown) {
return new Response("Shutting Down", { status: 503 });
}
try {
await db.ping(); // Verify actual connectivity
return new Response("Ready", { status: 200 });
} catch (err) {
return new Response("Dependency Failed", { status: 503 });
}
}); This separation aligns with the four golden signals of monitoring because readiness directly correlates to saturation and availability. When your readiness probe starts failing intermittently, it's often an early warning of resource exhaustion before latency spikes become user-visible.
How do you configure Kubernetes probes for Bun applications?
Kubernetes probe configuration must match your application's actual behavior. A frequent issue I see in production audits is probe timing that conflicts with application startup or shutdown characteristics. For Bun, which starts significantly faster than traditional Node.js apps, default probe timings are often too conservative, delaying traffic routing unnecessarily.
Your deployment manifest should reflect the differentiated endpoints discussed above. Note the terminationGracePeriodSeconds value: this must exceed your application's maximum expected drain time. If your longest API call takes 25 seconds and you set grace period to 30s, but your drain timeout is also 30s, you risk SIGKILL before clean shutdown completes. Always set grace period > app drain timeout + buffer.
livenessProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 2
periodSeconds: 5
failureThreshold: 2
successThreshold: 1
startupProbe:
httpGet:
path: /health/live
port: 3000
failureThreshold: 30
periodSeconds: 1
lifecycle:
preStop:
exec:
command: ["/bin/sleep", "5"] The preStop hook deserves special attention. There is a race condition in Kubernetes where the pod is marked Terminating and removed from endpoints simultaneously with SIGTERM delivery. Due to kube-proxy update latency, some proxies may still route traffic for a few seconds after SIGTERM. The 5-second sleep in preStop absorbs this propagation delay, preventing the exact 502 errors that graceful shutdown and health checks in Bun are meant to eliminate. This is especially relevant for teams following blue-green and canary deploy strategies where traffic shifting precision matters.
What are common pitfalls when draining connections in Bun?
Even with correct signal handling, several subtle issues can undermine graceful shutdown. Understanding these prevents debugging sessions at 2 AM during incident response.
- WebSocket connections block drain indefinitely: Bun's
server.stop(true)waits for WebSocket connections to close naturally. If clients don't implement reconnection logic or maintain persistent idle connections, your drain will hang until the forced timeout kills the process. Implement explicit WebSocket close signaling in your shutdown handler. - Background jobs ignore shutdown signals: Your HTTP server may drain cleanly while a background queue worker continues processing. Use the same AbortController or signal mechanism to notify all concurrent workers. Treat your Bun process as a collection of coordinated services, not just an HTTP server.
- Health check caching masks real state: Some teams add caching to health endpoints to reduce database load. During shutdown, cached "healthy" responses cause load balancers to keep sending traffic. Never cache readiness probes, or invalidate cache immediately on SIGTERM receipt.
- Missing timeout on drain: Relying solely on
server.stop(true)without a fallback timer means a single stuck request can block deployment indefinitely. Always race against a maximum acceptable drain duration.
Adding a forced drain timeout
async function gracefulShutdown(signal: string) {
isShuttingDown = true;
console.log(`${signal}: stopping new connections`);
const drainTimeout = setTimeout(() => {
console.error("Drain timeout exceeded. Forcing exit.");
process.exit(1);
}, 25000); // Must be < K8s terminationGracePeriodSeconds
await server.stop(true);
clearTimeout(drainTimeout);
await cleanupResources();
process.exit(0);
} This pattern guarantees forward progress. The 25-second timeout here assumes a 30-second Kubernetes grace period, leaving 5 seconds for cleanup and process exit overhead. Adjust these values based on your actual p99 request latency and infrastructure constraints.
How do you verify shutdown behavior before production?
Testing graceful shutdown requires simulating the exact conditions of a rolling deployment. Unit tests won't catch race conditions between probe updates and connection draining. You need integration tests that send concurrent requests while triggering SIGTERM.
- Start your Bun server with the shutdown handler implemented.
- Generate sustained load using a tool like
ohaork6with requests that take 2-5 seconds each. - Send SIGTERM via
kill -TERM $(pgrep bun)while load continues. - Observe three things: the health/readiness endpoint returns 503 within milliseconds, in-flight requests complete successfully, and no new requests are accepted after SIGTERM.
- Verify exit code 0 and clean resource cleanup in logs.
For teams practicing structured logging best practices, instrument your shutdown handler with structured events. Log the number of in-flight requests at SIGTERM receipt, the drain duration, and any forced terminations. These metrics become invaluable during post-incident analysis and capacity planning. Without observability into the shutdown process itself, you're operating blind during the most critical transition in your deployment lifecycle.
Production Checklist for Bun Shutdown Reliability
Getting graceful shutdown and health checks in Bun right is not optional for production workloads. The patterns covered here—differentiated probes, coordinated shutdown flags, drain timeouts, and preStop hooks—form the baseline for zero-downtime deployments. Start with the core implementation, validate it under load in staging, and instrument everything. If your team needs help auditing your current Bun deployment strategy or designing compliant infrastructure for regulated environments, reach out to discuss your specific requirements.