00:00

Health Check API in Microservices: Tell the Platform When Your Service Can Work

Last updated:

A health check API is an HTTP endpoint, such as /actuator/health, that reports whether a service instance is working. Load balancers, service registries and Kubernetes call it every few seconds. They stop sending traffic to an instance that reports a problem, or restart it.

In a system with dozens of services and hundreds of instances, nobody can watch every process by hand. The health check is how each instance tells the platform "send me requests" or "leave me alone for now".

1. Why a Running Process Is Not a Healthy Service

Picture a food delivery app with a restaurant-menu service running on four instances. One instance has lost its database connection pool. The JVM is still running, the port is still open, and the load balancer happily sends it every fourth request. A quarter of your customers see "Something went wrong" while the other instances are fine.

Checking that the process exists, or that the port accepts connections, isn't enough. The platform needs to ask the service itself: "Can you do your job right now?" Only the service knows the answer, because only it knows which connections, caches and settings it needs. A health check API is how it answers.

2. Key Terms

  • Probe: a regular call from the platform to a health endpoint. Kubernetes, AWS load balancers and Consul all send probes.
  • Liveness: "Is this process stuck beyond repair?" If the answer stays "no", the platform restarts the instance.
  • Readiness: "Can this instance handle requests right now?" If not, the platform stops routing traffic to it but leaves it running.
  • Startup: "Has the instance finished starting?" Used for slow-starting apps, so the other checks don't kill them while they boot.
  • Health indicator: one check inside the service, such as "the database answers" or "there is free disk space".
  • Failure threshold: how many failed probes in a row are needed before the platform acts. In Kubernetes it defaults to 3.

3. How a Health Check API Works

How liveness, readiness and startup probes workThe startup probe runs first. When it passes, the liveness and readiness probes start. If the startup or liveness probe fails three times in a row, the container is restarted and starts again with the startup probe. If the readiness probe fails three times in a row, the instance is removed from the load balancer but keeps running; one passing readiness probe adds it back. Restart the container Startup probe finished booting? Liveness probe is the process stuck? Readiness probe can it take traffic now? Removed from load balancer Back in load balancer starts again fails 3× passes passes fails 3× in a row fails 3× in a row passes once both run every 10 s by default, each with a 1 s timeout a not-ready instance keeps running; only liveness restarts it
Liveness and readiness probes wait until startup succeeds; a failed liveness probe restarts the container, while a failed readiness probe only removes it from traffic.
  1. The service exposes endpoints. With Spring Boot Actuator these are /actuator/health, and on Kubernetes also /actuator/health/liveness and /actuator/health/readiness.
  2. Each endpoint runs its health indicators and combines the results. If any included check is DOWN, the whole result is DOWN.
  3. The HTTP status code carries the verdict. Spring Boot returns 200 OK for UP and 503 Service Unavailable for DOWN or OUT_OF_SERVICE. Probes only look at the status code; the JSON body is for humans.
  4. The platform acts on repeated failures, as the diagram shows.
What happens internally: in Spring Boot, every bean that implements HealthIndicator becomes a component of the health endpoint, named after the bean without the "HealthIndicator" suffix. A StatusAggregator combines them, ordering statuses by severity (DOWN, OUT_OF_SERVICE, UP, UNKNOWN) and reporting the most severe one. On Kubernetes, the liveness and readiness groups by default contain only Spring's own livenessState and readinessState components, not your database check. That default is deliberate, as Example 2 shows.

4. Code Examples

These programs use only the JDK (Java 21), so you can see what Actuator and the platform do without a framework.

4.1 Example 1: A Minimal Health Endpoint

The JDK's built-in HttpServer serves /health, combining two checks. Then the "database" goes away.

HealthEndpointDemo.java
import com.sun.net.httpserver.HttpServer;
import java.io.File;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BooleanSupplier;

public class HealthEndpointDemo {

    static final AtomicBoolean databaseReachable = new AtomicBoolean(true); // pretend connection pool state

    public static void main(String[] args) throws Exception {
        Map<String, BooleanSupplier> checks = new LinkedHashMap<>();
        checks.put("database", databaseReachable::get);
        checks.put("diskSpace", () -> new File(".").getUsableSpace() > 10L * 1024 * 1024); // more than 10 MB free

        HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); // port 0 = any free port
        server.createContext("/health", exchange -> {
            StringBuilder components = new StringBuilder();
            boolean allUp = true;
            for (var check : checks.entrySet()) {
                boolean up = check.getValue().getAsBoolean();
                allUp &= up;
                if (!components.isEmpty()) components.append(',');
                components.append("\"%s\":\"%s\"".formatted(check.getKey(), up ? "UP" : "DOWN"));
            }
            String body = "{\"status\":\"%s\",\"components\":{%s}}".formatted(allUp ? "UP" : "DOWN", components);
            byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().set("Content-Type", "application/json");
            exchange.sendResponseHeaders(allUp ? 200 : 503, bytes.length); // the status code is what probes read
            exchange.getResponseBody().write(bytes);
            exchange.close();
        });
        server.start();

        HttpClient client = HttpClient.newHttpClient();
        URI uri = URI.create("http://localhost:" + server.getAddress().getPort() + "/health");
        try {
            probe(client, uri, "Normal operation");
            databaseReachable.set(false); // simulate the database going away
            probe(client, uri, "Database unreachable");
        } finally {
            server.stop(0);
        }
    }

    static void probe(HttpClient client, URI uri, String label) throws Exception {
        HttpResponse<String> response = client.send(HttpRequest.newBuilder(uri).GET().build(),
                HttpResponse.BodyHandlers.ofString());
        System.out.println(label + ":");
        System.out.println("  HTTP " + response.statusCode() + "  " + response.body());
    }
}
Output
Normal operation:
  HTTP 200  {"status":"UP","components":{"database":"UP","diskSpace":"UP"}}
Database unreachable:
  HTTP 503  {"status":"DOWN","components":{"database":"DOWN","diskSpace":"UP"}}

The body says which component failed, but a load balancer only reads 503. Returning 200 with "status":"DOWN" inside the body is a real bug seen in production: every probe passes while the instance is broken.

4.2 Example 2: Why the Database Doesn't Belong in the Liveness Check

Three instances share one database that goes down for seven probe periods. In the first run, the liveness check includes a database ping; in the second, only readiness does.

ProbeRestartDemo.java
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class ProbeRestartDemo {

    static final int FAILURE_THRESHOLD = 3; // Kubernetes default for both probes; one tick = one probe period

    static class Instance {
        final String name;
        int failedLiveness, failedReadiness, restarts;
        boolean inLoadBalancer = true;
        Instance(String name) { this.name = name; }
    }

    // livenessChecksDatabase = the mistake: the liveness check also pings the shared database
    static void simulate(String title, boolean livenessChecksDatabase) {
        System.out.println(title);
        List<Instance> instances = new ArrayList<>(List.of(new Instance("pod-1"), new Instance("pod-2"), new Instance("pod-3")));
        for (int tick = 1; tick <= 9; tick++) {
            boolean databaseUp = tick == 1 || tick == 9; // the database is down during ticks 2 to 8
            Map<String, List<String>> events = new LinkedHashMap<>(); // what happened -> which pods
            for (Instance pod : instances) {
                boolean live = !livenessChecksDatabase || databaseUp; // the process itself is always healthy here
                boolean ready = databaseUp;                            // it can't serve orders without the database
                pod.failedLiveness = live ? 0 : pod.failedLiveness + 1;
                if (pod.failedLiveness == FAILURE_THRESHOLD) {        // the orchestrator kills and restarts it
                    pod.restarts++;
                    pod.failedLiveness = 0;
                    events.computeIfAbsent("RESTARTED", k -> new ArrayList<>()).add(pod.name);
                }
                pod.failedReadiness = ready ? 0 : pod.failedReadiness + 1;
                boolean shouldServe = pod.inLoadBalancer ? pod.failedReadiness < FAILURE_THRESHOLD : ready; // one success brings it back
                if (pod.inLoadBalancer != shouldServe) {
                    pod.inLoadBalancer = shouldServe;
                    events.computeIfAbsent(ready ? "back in load balancer" : "removed from load balancer", k -> new ArrayList<>()).add(pod.name);
                }
            }
            StringBuilder line = new StringBuilder();
            events.forEach((what, pods) -> line.append(what).append(": ").append(String.join(" ", pods)).append("  "));
            System.out.printf("  tick %d  db=%-4s  %s%n", tick, databaseUp ? "UP" : "DOWN", line.isEmpty() ? "-" : line.toString().strip());
        }
        int total = instances.stream().mapToInt(p -> p.restarts).sum();
        System.out.println("  restarts: " + total);
    }

    public static void main(String[] args) {
        simulate("Liveness checks the database (wrong):", true);
        simulate("Liveness checks only the process, readiness checks the database:", false);
    }
}
Output
Liveness checks the database (wrong):
  tick 1  db=UP    -
  tick 2  db=DOWN  -
  tick 3  db=DOWN  -
  tick 4  db=DOWN  RESTARTED: pod-1 pod-2 pod-3  removed from load balancer: pod-1 pod-2 pod-3
  tick 5  db=DOWN  -
  tick 6  db=DOWN  -
  tick 7  db=DOWN  RESTARTED: pod-1 pod-2 pod-3
  tick 8  db=DOWN  -
  tick 9  db=UP    back in load balancer: pod-1 pod-2 pod-3
  restarts: 6
Liveness checks only the process, readiness checks the database:
  tick 1  db=UP    -
  tick 2  db=DOWN  -
  tick 3  db=DOWN  -
  tick 4  db=DOWN  removed from load balancer: pod-1 pod-2 pod-3
  tick 5  db=DOWN  -
  tick 6  db=DOWN  -
  tick 7  db=DOWN  -
  tick 8  db=DOWN  -
  tick 9  db=UP    back in load balancer: pod-1 pod-2 pod-3
  restarts: 0

Restarting doesn't fix a database outage. It just throws away warm caches and connection pools, and every restart opens a burst of new connections the moment the database comes back. The rule: liveness checks only things a restart can fix, such as a deadlocked thread pool. Dependencies belong in readiness, or nowhere.

4.3 Example 3: Timeouts and Caching

A health check that waits for a hanging dependency becomes a hanging health check. Kubernetes gives each probe 1 second by default, and a probe that times out counts as a failure.

HealthCheckBudgetDemo.java
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;

public class HealthCheckBudgetDemo {

    record Check(String name, long delayMillis) {}
    record Report(String overall, Map<String, String> components) {}

    static final AtomicInteger realChecks = new AtomicInteger(); // how often we really hit the dependencies

    // Runs all checks in parallel; each one gets at most timeoutMillis
    static Report runChecks(List<Check> checks, long timeoutMillis, ExecutorService pool) {
        realChecks.incrementAndGet();
        Map<String, CompletableFuture<String>> futures = new LinkedHashMap<>();
        for (Check c : checks) {
            futures.put(c.name(), CompletableFuture.supplyAsync(() -> {
                sleep(c.delayMillis()); // stands in for a query or a ping
                return "UP";
            }, pool).orTimeout(timeoutMillis, TimeUnit.MILLISECONDS)
              .exceptionally(e -> e instanceof TimeoutException
                      ? "DOWN (timed out after " + timeoutMillis + " ms)" : "DOWN"));
        }
        Map<String, String> result = new LinkedHashMap<>();
        futures.forEach((name, f) -> result.put(name, f.join()));
        boolean allUp = result.values().stream().allMatch("UP"::equals);
        return new Report(allUp ? "UP" : "DOWN", result);
    }

    // Caches the last report so frequent probes don't hammer the database
    static class CachedHealth {
        private final long ttlMillis;
        private Report last;
        private long lastAt = Long.MIN_VALUE;
        CachedHealth(long ttlMillis) { this.ttlMillis = ttlMillis; }

        Report get(long nowMillis, java.util.function.Supplier<Report> compute) {
            if (last == null || nowMillis - lastAt >= ttlMillis) {
                last = compute.get();
                lastAt = nowMillis;
            }
            return last;
        }
    }

    static void sleep(long millis) {
        try { Thread.sleep(millis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }

    public static void main(String[] args) {
        List<Check> checks = List.of(new Check("database", 40), new Check("searchIndex", 3_000)); // search is hanging
        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            long start = System.nanoTime();
            Report report = runChecks(checks, 300, pool);
            long tookMillis = (System.nanoTime() - start) / 1_000_000;
            System.out.println("Overall: " + report.overall());
            report.components().forEach((k, v) -> System.out.println("  " + k + ": " + v));
            System.out.println("Answered within a 1 s probe timeout: " + (tookMillis < 1_000));

            CachedHealth cache = new CachedHealth(5_000);
            realChecks.set(0);
            long[] probeTimes = {0, 1_000, 2_000, 4_999, 5_000, 9_000}; // simulated clock, in milliseconds
            for (long t : probeTimes) {
                cache.get(t, () -> runChecks(List.of(new Check("database", 5)), 300, pool));
            }
            System.out.println(probeTimes.length + " probes, database really checked " + realChecks.get() + " times");
        }
    }
}
Output
Overall: DOWN
  database: UP
  searchIndex: DOWN (timed out after 300 ms)
Answered within a 1 s probe timeout: true
6 probes, database really checked 2 times

Each check runs in parallel with its own time limit, so one slow dependency produces a clear DOWN instead of a probe timeout. The cache matters too: with several probes, a load balancer and a monitoring tool all calling the endpoint, an uncached health check can send more queries to the database than real users do. One detail to know: orTimeout only completes the future; the slow task keeps running in the background, which is why each check needs its own driver-level timeout as well.

5. Liveness vs Readiness vs Startup

LivenessReadinessStartup
QuestionIs the process stuck?Can it take traffic now?Has it finished booting?
On failureContainer restartedRemoved from load balancerContainer restarted once the threshold is reached
Should checkInternal state onlyOwn critical dependencies, warm-up, shutdownSame as liveness
Spring Boot path/actuator/health/liveness/actuator/health/readinessUsually the liveness path
Typical mistakePinging the databaseChecking every downstream serviceNot using it for slow starts

6. Common Mistakes and Pitfalls

1. Deep checks in liveness. Shown in Example 2: one shared outage restarts every instance, over and over.

2. Checking other services in readiness. If the order service is "not ready" whenever the payment service is slow, a payment problem removes all order instances too, and the outage spreads. Use a circuit breaker for downstream calls, and mark the service not ready only for dependencies it truly can't work without, such as its own database.

3. No startup probe for slow apps. A large Spring Boot app may need 60 seconds to start. Without a startup probe, the liveness probe can kill it before it finishes, and it never comes up.

4. Leaking details. A public health endpoint that lists database hosts and versions helps attackers. Spring Boot hides details by default (show-details is never); keep it that way for public callers.

5. Ignoring shutdown. During a rolling update, an instance that is shutting down should become not-ready first, so the load balancer stops sending it new requests before it exits. Spring Boot switches readiness to REFUSING_TRAFFIC as soon as shutdown begins, and graceful shutdown, on by default since Spring Boot 3.4, lets requests already in progress finish.

7. Best Practices

  • Keep liveness cheap and local. No network calls.
  • Keep readiness honest. Include the service's own database or broker, not other teams' services.
  • Give every check a timeout, shorter than the probe timeout, and run checks in parallel.
  • Cache expensive checks for a few seconds.
  • Return the right status code: 200 for healthy, 503 for unhealthy.
  • Expose details only internally, for example on a separate management port.
  • Alert on health trends in your monitoring tool. A probe restarts an instance; it doesn't tell a human why.

8. Summary

  • A health check API lets the platform ask each instance whether it can do its job.
  • Liveness decides restarts, readiness decides traffic, startup protects slow boots.
  • Probes read the HTTP status code: 200 for up, 503 for down.
  • Never put shared dependencies in liveness checks, or one outage restarts everything.
  • Time-limit and cache checks so they can't hang or overload a dependency.
  • Spring Boot Actuator gives you all of this, with safe defaults.

Start by adding Actuator, enabling the liveness and readiness groups, and pointing your probes at them. A well-designed health check API costs a few lines of configuration and decides how gracefully your microservices fail.