Ch. 8 · Spring Boot

Spring Startup Checks and Readiness

How Spring Boot liveness and readiness probes work on Kubernetes, what belongs in each health group, and how to warm up and shut down gracefully.

~6 min readadvancedupdated Oct 6, 2026

“How do you configure health checks for a Spring Boot service on Kubernetes?” Interviewers ask this to find out whether you understand what each probe causes. A failed liveness probe kills the container. A failed readiness probe removes it from the load balancer. Mixing them up produces outages that are worse than the original problem: a database blip triggers restarts of every pod, the restarts hit the database with connection storms, and the service never recovers on its own.

Before you start

You should know what a Kubernetes Deployment, Service and pod are, how Spring Boot Actuator’s /actuator/health endpoint works, and the basics of the application lifecycle (context refresh, CommandLineRunner, shutdown). Examples target Spring Boot 3.x.

The short answer

Spring Boot tracks two availability states. LivenessState is CORRECT or BROKEN and means “the process is functioning; restarting would not help or would”. ReadinessState is ACCEPTING_TRAFFIC or REFUSING_TRAFFIC. Boot exposes them at /actuator/health/liveness and /actuator/health/readiness (enabled automatically on Kubernetes, or with management.endpoint.health.probes.enabled=true). Readiness switches to accepting traffic only after the application is fully started and runners have finished, and back to refusing during shutdown. Liveness should depend only on the process itself; readiness may include dependencies the instance cannot serve without. Use a startupProbe for slow starts and enable graceful shutdown.

How it works

Boot publishes AvailabilityChangeEvents during the lifecycle:

context refreshed            -> LivenessState.CORRECT
ApplicationStartedEvent      (CommandLineRunner / ApplicationRunner beans run next)
ApplicationReadyEvent        -> ReadinessState.ACCEPTING_TRAFFIC
shutdown begins              -> ReadinessState.REFUSING_TRAFFIC
Text

The livenessState and readinessState health indicators report these states, and two health groups expose them:

management:
  endpoint:
    health:
      probes:
        enabled: true
      group:
        readiness:
          include: readinessState,db        # optionally add dependencies required to serve
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true
server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 20s
yaml

And the matching Kubernetes probes:

startupProbe:
  httpGet: { path: /actuator/health/liveness, port: 8080 }
  periodSeconds: 5
  failureThreshold: 30          # allows up to 150 s to start
livenessProbe:
  httpGet: { path: /actuator/health/liveness, port: 8080 }
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet: { path: /actuator/health/readiness, port: 8080 }
  periodSeconds: 5
  failureThreshold: 2
yaml

The startup probe suspends the other probes until it succeeds once, so a slow first boot is not mistaken for a hung process.

Step-by-step walkthrough

Step 1: Keep liveness narrow

Liveness should fail only when restarting the container is the right fix, for example a deadlock or an unrecoverable internal state. The default group contains only livenessState. Do not add db, redis or downstream checks: if the database is down, restarting your pods does not bring it back.

Step 2: Decide what readiness includes

Readiness controls traffic. If an instance truly cannot serve any request without its database, including db in the readiness group is reasonable. But if every instance shares the same database, they all go unready together, and the Service has zero endpoints, which turns a partial outage into a total one. Many teams keep readiness to readinessState plus local conditions such as a warmed cache, and let requests fail fast with clear errors during dependency outages.

Step 3: Warm up before accepting traffic

Work done in an ApplicationRunner completes before ApplicationReadyEvent, so readiness stays down until it is finished:

@Component
class PriceCacheWarmer implements ApplicationRunner {
    private final PriceCache cache;
    PriceCacheWarmer(PriceCache cache) { this.cache = cache; }

    @Override
    public void run(ApplicationArguments args) {
        cache.loadAll();   // readiness is still REFUSING_TRAFFIC while this runs
    }
}
java

For conditions that change at runtime, publish the state yourself:

@Component
class ReplicationLagMonitor {
    private final ApplicationEventPublisher events;
    ReplicationLagMonitor(ApplicationEventPublisher events) { this.events = events; }

    void onLagChanged(Duration lag) {
        ReadinessState state = lag.compareTo(Duration.ofSeconds(30)) > 0
                ? ReadinessState.REFUSING_TRAFFIC : ReadinessState.ACCEPTING_TRAFFIC;
        AvailabilityChangeEvent.publish(events, this, state);
    }
}
java

Step 4: Shut down gracefully

On SIGTERM, Boot marks readiness as refusing traffic, and with server.shutdown=graceful the web server stops accepting new connections and waits up to timeout-per-shutdown-phase for in-flight requests. Kubernetes removes the pod from endpoints asynchronously, so a short preStop sleep (5 to 10 seconds) avoids sending new requests to a pod that is already closing. The pod’s terminationGracePeriodSeconds must exceed the preStop sleep plus the shutdown timeout.

Worked scenario

A payments service had a single probe for both liveness and readiness: /actuator/health, with all default indicators, including db and a custom indicator calling a fraud API. During a 90-second database failover, every pod’s health turned DOWN. Liveness failed three times, and Kubernetes restarted all 12 pods at once. Each new pod opened its full Hikari pool against the recovering database, startup took 70 seconds because Flyway checks and cache warming also hit the database, and the probes killed several pods again before they finished. The outage lasted 25 minutes for a 90-second failover.

The fix separated the signals. Liveness moved to /actuator/health/liveness, which contains only livenessState. Readiness used /actuator/health/readiness with readinessState only; database errors now fail individual requests with a 503 problem response instead of removing every pod. A startup probe with a 150-second budget protected slow boots. Graceful shutdown with a 20-second phase timeout and a 5-second preStop sleep stopped dropped requests during deploys. In the next failover, error rates rose for 90 seconds and recovered without a single restart.

Common mistake

  • Using /actuator/health for liveness. It aggregates every indicator, including external dependencies.
  • Putting shared dependencies in readiness without thinking. All pods go unready together and the Service has no endpoints.
  • Long initialDelaySeconds instead of a startup probe. It delays failure detection after every restart and is a guess.
  • Doing heavy warm-up in @PostConstruct of an unrelated bean, which slows context refresh and hides the work from the lifecycle states.
  • No graceful shutdown. Deploys drop in-flight requests and clients see connection resets.
  • Calling slow endpoints in health indicators. Probes time out (default 1 second) and fail even though the service works.

Verify the behavior

Test the state transitions directly:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
                properties = "management.endpoint.health.probes.enabled=true")
@AutoConfigureMockMvc
class ProbeTest {

    @Autowired MockMvc mvc;
    @Autowired ApplicationContext ctx;

    @Test
    void readinessFollowsAvailabilityEvents() throws Exception {
        mvc.perform(get("/actuator/health/readiness"))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.status").value("UP"));

        AvailabilityChangeEvent.publish(ctx, ReadinessState.REFUSING_TRAFFIC);

        mvc.perform(get("/actuator/health/readiness"))
           .andExpect(status().isServiceUnavailable())
           .andExpect(jsonPath("$.status").value("OUT_OF_SERVICE"));

        mvc.perform(get("/actuator/health/liveness")).andExpect(status().isOk());
    }
}
java

On a cluster, watch kubectl get endpoints <service> -w during a rolling deploy and during a simulated dependency failure: pods should leave endpoints on shutdown and stay in place, not restart, when the database is unreachable.

Follow-up questions

What HTTP status does a failing probe return? OUT_OF_SERVICE and DOWN map to 503 by default, which Kubernetes treats as a failure.

Why might liveness legitimately fail? A custom indicator could publish LivenessState.BROKEN when, for example, an essential background thread has died and the process cannot recover.

How does lazy initialization affect readiness? With spring.main.lazy-initialization=true, beans are created on first request, so the pod can be ready before it is truly warm, and the first requests are slow.

Should probes go through authentication? No. Probe endpoints should be permitted anonymously and reveal only status; see Actuator exposure.

Interview exercise

A service takes 2 minutes to start because it loads a 2 GB reference dataset into memory. It depends on PostgreSQL and on an optional recommendations API. Design its probes and explain each choice.

Answer and reasoning

Load the dataset in an ApplicationRunner so readiness stays down until it completes. Add a startup probe on /actuator/health/liveness with a budget comfortably above 2 minutes (for example periodSeconds: 10, failureThreshold: 18), so liveness does not kill the pod mid-load. Liveness contains only livenessState. Readiness contains readinessState; PostgreSQL could be included only if serving without it is impossible and you accept that all pods go unready together, otherwise leave it out and return 503 responses per request. The optional recommendations API never appears in any probe; the service degrades gracefully without it. Enable graceful shutdown and a preStop sleep, and because a restart costs 2 minutes, make liveness conservative. The reasoning: match each probe to the action it triggers.

Continue learning

More in Spring Boot

read ✓Spring Boot · hard

Spring Actuator Exposure and Operational Access

Which Spring Boot Actuator endpoints to expose, how exposure differs from authorization, and how to lock down env, heapdump and health details.

~6 min readread →
read ✓Spring Boot · easy

@Controller vs @RestController in Spring Boot

@RestController is @Controller plus @ResponseBody: return values become the response body, not view names. See both styles and the usual bugs.

~6 min readread →
esc