
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Dropped requests during deployments and failed database transactions are symptoms of missing lifecycle management, not infrastructure flaws. Implementing graceful shutdown and health checks in Kotlin ensures your application finishes active work before terminating and accurately reports its state to orchestrators like Kubernetes. This guide covers the practical implementation using Ktor, coroutines, and standard Kubernetes probes.
How do you implement graceful shutdown and health checks in Kotlin with Ktor?
Ktor provides built-in hooks for lifecycle management, but the default configuration is insufficient for production workloads. You must explicitly configure the shutdown timeout and register event handlers that coordinate with your coroutine scopes. Without this, the JVM terminates immediately upon receiving SIGTERM, severing active database connections and HTTP responses mid-flight. For teams managing blue-green and canary deploys on Kubernetes, this coordination is non-negotiable.
Configure the Ktor shutdown hook
The Application object exposes an environment.monitor subscription point. Use this to trigger cleanup logic when the application receives a stop signal. In Ktor 3.x (current stable for 2026), the embedded server also accepts a shutdownGracePeriod parameter that defines how long the engine waits for active calls to complete.
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
module()
environment.monitor.subscribe(ApplicationStopping) {
println("Received SIGTERM: initiating graceful shutdown")
// Signal custom coroutine scopes to cancel
AppScope.cancel(CancellationException("Shutting down"))
}
environment.monitor.subscribe(ApplicationStopped) {
println("All resources released. Safe to exit.")
}
}.start(wait = true)
} This handler runs synchronously on the shutdown thread. Keep it lightweight; delegate heavy cleanup to pre-registered callbacks or structured concurrency scopes rather than performing blocking I/O directly here.
Define a supervised coroutine scope
Never launch background tasks on GlobalScope. Create an application-level CoroutineScope tied to a SupervisorJob. When shutdown begins, cancel this scope and await completion of all children. This guarantees that message consumers, cache warmers, and scheduled jobs finish their current iteration before the process exits.
val AppScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// During shutdown:
AppScope.coroutineContext.job.children.forEach { child ->
try {
runBlocking {
withTimeout(10_000) { child.join() }
}
} catch (e: TimeoutCancellationException) {
println("Child job ${child.key} did not complete in time")
}
} What is the difference between liveness and readiness probes in Kotlin?
A common mistake is conflating liveness and readiness, leading to cascading restart loops. These probes serve fundamentally different purposes in orchestration platforms. Understanding the distinction prevents the most frequent cause of deployment instability in microservices.
| Criteria | Liveness Probe (/health/live) | Readiness Probe (/health/ready) |
|---|---|---|
| Purpose | Detects deadlocks, frozen threads, unrecoverable state | Confirms app can handle traffic safely |
| Failure Action | Kubelet kills and restarts the pod | Load balancer removes pod from service endpoints |
| Check Dependencies? | No — only internal JVM health | Yes — DB, cache, external APIs |
| During Startup | Should pass quickly after JVM starts | Fails until initialization completes |
| During Shutdown | Continues returning 200 until SIGTERM | Returns 503 immediately on SIGTERM |
| Typical Interval | 10–15 seconds | 5–10 seconds |
Your liveness endpoint should never check database connectivity. If the database is temporarily unavailable, failing the liveness probe causes Kubernetes to restart your pod repeatedly, creating a thundering herd effect against the already-stressed database. Reserve dependency checks exclusively for readiness. This separation aligns with the four golden signals of monitoring by ensuring saturation and errors don't falsely trigger availability alerts.
How do you wire up health endpoints in Ktor without external libraries?
You don't need heavyweight health-check libraries for most Kotlin services. A pair of route handlers with explicit dependency verification gives you full control over failure semantics and response payloads. External libraries often obscure what's actually being checked and make debugging probe failures harder.
Implement the liveness endpoint
Liveness should respond in under 50ms. Check only that the event loop is responsive and critical in-memory state hasn't corrupted.
routing {
get("/health/live") {
val memUsed = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()
val memMax = Runtime.getRuntime().maxMemory()
val memPercent = (memUsed.toDouble() / memMax) * 100
if (memPercent > 95) {
call.respond(HttpStatusCode.ServiceUnavailable, mapOf(
"status" to "DOWN",
"reason" to "Memory pressure at ${memPercent.toInt()}%"
))
return@get
}
call.respond(HttpStatusCode.OK, mapOf("status" to "UP"))
}
} Implement the readiness endpoint with dependency checks
Readiness must validate every external system required to serve traffic. Wrap each check in a timeout to prevent a hung dependency from blocking the entire probe response. Return detailed status per component so operators can diagnose failures from metrics alone.
get("/health/ready") {
val checks = mutableMapOf<String, String>()
var allHealthy = true
try {
withTimeout(2_000) { dbConnection.isValid(1) }
checks["database"] = "UP"
} catch (e: Exception) {
checks["database"] = "DOWN: ${e.message}"
allHealthy = false
}
try {
withTimeout(1_000) { redisClient.ping() }
checks["cache"] = "UP"
} catch (e: Exception) {
checks["cache"] = "DOWN: ${e.message}"
allHealthy = false
}
val statusCode = if (allHealthy) HttpStatusCode.OK else HttpStatusCode.ServiceUnavailable
call.respond(statusCode, mapOf("status" to if (allHealthy) "UP" else "DOWN", "checks" to checks))
} Track these probe responses as metrics. As covered in Prometheus metrics monitoring fundamentals, exposing health check latency and failure counts as gauges lets you build SLO-based alerts that fire before users notice degradation.
How do you configure Kubernetes probes for Kotlin applications correctly?
Even perfect application code fails if the Kubernetes probe configuration doesn't match the application's actual startup and shutdown timing. The most frequent misconfiguration is setting initialDelaySeconds too low for JVM warm-up or omitting terminationGracePeriodSeconds alignment.
Align termination grace period with application timeout
Kubernetes sends SIGTERM and waits terminationGracePeriodSeconds (default 30s) before sending SIGKILL. Your Ktor shutdownGracePeriod plus maximum request processing time must fit within this window. If your longest request takes 25 seconds and shutdown cleanup takes 5 seconds, set the grace period to at least 35 seconds.
spec:
terminationGracePeriodSeconds: 45
containers:
- name: kotlin-api
ports:
- containerPort: 8080
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 2
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 2
timeoutSeconds: 3
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"] Add a preStop sleep for load balancer propagation
The preStop sleep is essential. When Kubernetes marks a pod as terminating, it simultaneously removes the pod from service endpoints and sends SIGTERM. Due to asynchronous propagation, the load balancer may still route traffic for several seconds after endpoint removal. A 5-second preStop sleep absorbs this race condition, preventing the 502/504 errors that plague rolling deployments without it.
Why does my Kotlin application still drop requests during rolling updates?
If you've implemented everything above and still see intermittent 502s, the issue is usually one of three subtle timing gaps. First, verify your readiness probe returns 503 synchronously when shutdown begins; if there's any async delay, the load balancer continues routing during that window. Second, confirm your Ktor engine isn't accepting new connections during the drain phase—some Netty configurations keep the acceptor open unless explicitly stopped. Third, check that your reverse proxy (Nginx, Envoy, or cloud ALB) has its own idle connection timeout shorter than the Kubernetes grace period; otherwise, the proxy holds connections open to a terminating pod.
Debug this systematically by adding structured logging at each lifecycle stage. Log when SIGTERM arrives, when the acceptor closes, when the last active request completes, and when the process exits. Correlate these timestamps with access logs from your ingress controller. The gap between "acceptor closed" and "last request completed" is your actual drain duration; if it exceeds your configured timeout, increase the grace period or optimize request processing time. For deeper observability integration, see instrumenting an app with OpenTelemetry to trace individual requests through the shutdown window.
Deploying Graceful Shutdown and Health Checks in Kotlin Reliably
Getting graceful shutdown and health checks in Kotlin right requires treating application lifecycle as a first-class engineering concern, not an afterthought. Configure Ktor's shutdown hooks explicitly, separate liveness from readiness with discipline, align every Kubernetes timing parameter to your actual workload characteristics, and validate the entire sequence under load before trusting it in production. The cost of getting this wrong is silent data loss and eroded user trust; the cost of getting it right is a few hours of focused implementation and testing. If your team needs help auditing your current deployment pipeline or designing compliance-ready infrastructure, reach out to discuss your specific architecture.