Health Check API in Microservices: Tell the Platform When Your Service Can Work
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
- The service exposes endpoints. With Spring Boot Actuator these are
/actuator/health, and on Kubernetes also/actuator/health/livenessand/actuator/health/readiness. - Each endpoint runs its health indicators and combines the results. If any included check is
DOWN, the whole result isDOWN. - The HTTP status code carries the verdict. Spring Boot returns
200 OKforUPand503 Service UnavailableforDOWNorOUT_OF_SERVICE. Probes only look at the status code; the JSON body is for humans. - The platform acts on repeated failures, as the diagram shows.
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.
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());
}
}
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.
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);
}
}
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.
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");
}
}
}
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
| Liveness | Readiness | Startup | |
|---|---|---|---|
| Question | Is the process stuck? | Can it take traffic now? | Has it finished booting? |
| On failure | Container restarted | Removed from load balancer | Container restarted once the threshold is reached |
| Should check | Internal state only | Own critical dependencies, warm-up, shutdown | Same as liveness |
| Spring Boot path | /actuator/health/liveness | /actuator/health/readiness | Usually the liveness path |
| Typical mistake | Pinging the database | Checking every downstream service | Not 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:
200for healthy,503for 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:
200for up,503for 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.
Health Check API: Interview Questions
Common interview questions on Health Check API, from basic to scenario-based, with short answers.
Basic questions
Q. How do you enable the health endpoint and probes in Spring Boot?
Answer: Add spring-boot-starter-actuator; /actuator/health is exposed over HTTP by default. The liveness and readiness groups are enabled automatically when the app detects it is running on Kubernetes, and you can turn them on anywhere with one property.
management.endpoint.health.probes.enabled=true
Q. What does management.endpoint.health.show-details control?
Answer: It decides who sees the per-component details in the response. The default never returns only the overall status; when-authorized shows details to authenticated users with the configured roles; always shows them to everyone. Probes don't need details, so only internal callers should get them.
Q. Is a health check the same as monitoring?
Answer: No. A health check answers a yes/no question for automation, right now. Monitoring collects metrics, logs and traces over time so people can see trends, such as error rates and latency, and find causes. A service can pass every health check while being too slow to be useful, which only monitoring shows.
Intermediate questions
Q. How do you write a custom health indicator?
Answer: Create a bean that implements HealthIndicator and returns Health.up() or Health.down(), optionally with details. The bean name decides the component name.
@Component
class PaymentGatewayHealthIndicator implements HealthIndicator {
public Health health() {
return gatewayClient.ping() ? Health.up().build()
: Health.down().withDetail("reason", "ping failed").build();
}
}
It appears as paymentGateway in the health response.
Q. What is the difference between DOWN and OUT_OF_SERVICE?
Answer: DOWN means something is broken. OUT_OF_SERVICE means the instance is deliberately taken out of rotation, for example during maintenance, even though nothing is wrong. Spring Boot maps both to HTTP 503 by default.
Q. How do you add your own check to the readiness group only?
Answer: Configure the group to include it by name, next to the default state indicator.
management.endpoint.health.group.readiness.include=readinessState,db
Keep the liveness group as it is, with only livenessState.
Q. How do health checks relate to service discovery?
Answer: Registries such as Consul and Eureka use health information to decide which instances to return to callers. An instance that fails its checks is removed from discovery results, so clients stop calling it even without a central load balancer.
Q. Why should health endpoints often run on a separate management port?
Answer: With management.server.port, Actuator listens on a different port that is reachable only inside the cluster. The public port then can't leak health details, and probes still work even if the main connector's thread pool is exhausted by user traffic.
Q. How do you change readiness from inside the application?
Answer: Publish an availability change event; Spring Boot updates the readiness state and the probe follows.
AvailabilityChangeEvent.publish(context, ReadinessState.REFUSING_TRAFFIC);
This is useful when the app detects it can't serve requests, for example after losing a required license or configuration.
Tricky and scenario questions
Q. Scenario: After a deployment, new pods keep restarting before they ever serve a request. What do you check?
Answer: The liveness probe is probably killing them during startup. Check how long the app takes to start and add a startup probe whose failureThreshold × periodSeconds covers that time. Also check that the liveness path doesn't depend on something that isn't ready yet.
Q. Scenario: Your DBA reports thousands of SELECT 1 queries per minute from health checks. How do you fix it?
Answer: Many instances, probes and monitors each run the database check. Cache the check result for a few seconds, increase the probe period if it is very short, and remove the database check from liveness. With a connection pool, checking whether the pool can provide a valid connection is enough.
Q. Scenario: All health checks are green, but users get errors from one endpoint. How is that possible?
Answer: Health checks only test what they were written to test, usually connectivity. A bug in one endpoint, a bad configuration value or a full downstream queue isn't visible to them. Use synthetic checks that call real business endpoints, and alert on error rates from monitoring.
Q. How do gRPC services expose health checks?
Answer: gRPC defines a standard health checking protocol: a grpc.health.v1.Health service with Check and Watch methods returning SERVING or NOT_SERVING. Kubernetes supports gRPC probes natively (beta in 1.24, stable since 1.27), so the same liveness and readiness ideas apply without an HTTP endpoint.