Graceful Shutdown and Health Checks in Kotlin

Khimananda Oli 8 min read Programming and Languages
Graceful Shutdown and Health Checks in Kotlin

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.

SIGTERMOS SignalStop AcceptingClose ListenersDrain ActiveAwait CoroutinesExitCode 0Health: FailingTimeout: 30s Max
Graceful shutdown and health checks in Kotlin lifecycle: SIGTERM triggers listener closure, active request draining, and safe exit within timeout bounds.

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.

CriteriaLiveness Probe (/health/live)Readiness Probe (/health/ready)
PurposeDetects deadlocks, frozen threads, unrecoverable stateConfirms app can handle traffic safely
Failure ActionKubelet kills and restarts the podLoad balancer removes pod from service endpoints
Check Dependencies?No — only internal JVM healthYes — DB, cache, external APIs
During StartupShould pass quickly after JVM startsFails until initialization completes
During ShutdownContinues returning 200 until SIGTERMReturns 503 immediately on SIGTERM
Typical Interval10–15 seconds5–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.

LIVENESS PROBE✓ Thread pool responsive✓ Memory below OOM threshold✗ NO database / cache checksREADINESS PROBE✓ PostgreSQL connection valid✓ Redis PING successful✓ Kafka consumer group joinedSHUTDOWN BEHAVIORLiveness: Still 200 OKPrevents premature kill during drainReadiness: Immediate 503Removes from LB before drain starts
Liveness vs readiness probe responsibilities: liveness checks only JVM internals, readiness validates all dependencies, and shutdown behavior differs critically between them.

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.

TIMELINEt=0Pod markedTerminatingpreStop: 5s sleept=5sSIGTERM sentLB drainingKtor drain: active requests complete (≤30s)t=35sClean exitCode 0t=45sSIGKILL(if still alive)terminationGracePeriodSeconds = 45KEY CONSTRAINTpreStop + maxRequestTime + cleanupTime ≤ terminationGracePeriodSeconds5s + 30s + 5s = 40s → Set grace period ≥ 45s (buffer included)Violation = SIGKILL mid-request = data corruption
Kubernetes termination timeline: preStop sleep absorbs LB propagation delay, drain window completes active work, and SIGKILL deadline must exceed total shutdown duration.

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.

Frequently Asked Questions

Set server.shutdown=graceful and spring.lifecycle.timeout-per-shutdown-phase in application.yml. This tells the embedded Tomcat or Netty server to stop accepting new requests while allowing active ones to complete within the defined timeout period before forcing termination.

Thirty seconds.

Kubernetes sends SIGTERM upon pod termination, triggering Spring's ContextClosedEvent. Ensure your preStop hook sleeps for five seconds to allow load balancers to deregister the endpoint before the application actually stops processing incoming traffic.

Use /actuator/health/liveness and /actuator/health/readiness endpoints. Configure management.endpoint.health.probes.enabled=true in properties to expose these specific probe states separately from the general health status for Kubernetes orchestration integration.

Yes.

Use SupervisorJob tied to the application lifecycle scope. Catch CancellationException gracefully within your coroutine blocks to perform cleanup operations like closing database connections or flushing buffers before the job fully terminates and resources release.

Readiness probes often fail because dependencies initialize slower than the probe interval. Implement a custom ReadinessStateHealthIndicator that returns DOWN until critical components like database pools or cache connections are verified ready, preventing premature traffic routing.

Spring forces immediate termination by cancelling remaining tasks and closing resources abruptly. Active requests receive connection resets or 503 errors. Monitor shutdown duration metrics to tune the timeout-per-shutdown-phase value appropriately for your typical request processing latency.

Send SIGTERM via kill command or docker stop while running load tests with tools like k6 or wrk. Verify zero failed requests in client metrics and confirm logs show orderly bean destruction and coroutine cancellation without exceptions or resource leaks.

Micronaut uses micronaut.server.shutdown-timeout property and registers ShutdownHook automatically. Unlike Spring, it integrates natively with Project Reactor and Kotlin coroutines without additional adapter libraries, providing faster startup and more predictable shutdown sequencing for cloud-native microservices.

Register a ConsumerRebalanceListener that pauses partition assignments on revocation. Commit offsets synchronously within the shutdown hook before closing the consumer instance to ensure processed records are acknowledged and not reprocessed by another group member.

Unprotected health endpoints leak internal dependency status, versions, and infrastructure details. Always place actuator endpoints behind authentication or network policies. Expose only liveness and readiness publicly; restrict detailed health information to internal monitoring networks or service mesh sidecars.

Register ApplicationListener beans to execute logging before context destruction completes. Use structured logging with MDC context preserved during shutdown phase. Avoid async appenders that may drop final log entries when executor services terminate prematurely.

Yes.

Use distributed tracing propagation headers to track in-flight requests across service boundaries. Implement circuit breakers with fallback responses during peer shutdown windows. Configure staggered rollout strategies in CI/CD pipelines to maintain cluster-wide availability during rolling updates.