Revision note (2026-09-15). The earlier version of this article had a health indicator whose check was return true; // placeholder, recommended the HealthAggregator interface that Spring Boot replaced with StatusAggregator in 2.2, suggested @Cacheable on health() although Actuator has its own cache setting, and showed a custom WARN status without the configuration that makes it count. This version is rebuilt around a small project with eight integration tests and a recorded curl session on Spring Boot 3.5.16.
What a health indicator must never do
GET /actuator/health is called by load balancers, Kubernetes probes, and uptime monitors on a schedule. The one thing a custom indicator must not do is block. A probe that calls a dependency and waits turns "the database is slow" into "the health endpoint is slow", and a probe that waits long enough turns it into "the instance was killed". So the first design rule is a hard timeout inside the indicator, independent of whatever timeout the dependency's client may or may not honor.
Tested versions: Spring Boot 3.5.16 (spring-boot-actuator 3.5.16, spring-web 6.2.19), Java 17 (Amazon Corretto 17.0.14), Gradle 8.8.
A bounded indicator
The dependency is abstracted behind one method so the lab can make it healthy, failing, or hanging on demand:
public interface DependencyProbe {
boolean isReachable() throws Exception;
}
The indicator runs the probe on its own small executor and waits with a deadline:
@Component
public class BoundedDependencyHealthIndicator implements HealthIndicator {
private final DependencyProbe probe;
private final long timeoutMillis;
private final ExecutorService executor = Executors.newFixedThreadPool(2, namedDaemonThreads("health-probe-"));
public BoundedDependencyHealthIndicator(DependencyProbe probe, DemoHealthProperties properties) {
this.probe = probe;
this.timeoutMillis = properties.dependencyTimeout().toMillis(); // 500 ms in the lab
}
@Override
public Health health() {
long started = System.nanoTime();
Future<Boolean> future = executor.submit(probe::isReachable);
try {
boolean reachable = future.get(timeoutMillis, TimeUnit.MILLISECONDS);
return (reachable ? Health.up() : Health.down())
.withDetail("latencyMs", elapsedMillis(started))
.build();
} catch (TimeoutException ex) {
future.cancel(true); // interrupt the probe thread
return Health.down()
.withDetail("reason", "timeout")
.withDetail("timeoutMs", timeoutMillis)
.build();
} catch (ExecutionException ex) {
Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
return Health.down()
.withDetail("error", cause.getClass().getName() + ": " + cause.getMessage())
.withDetail("latencyMs", elapsedMillis(started))
.build();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
future.cancel(true);
return Health.down().withDetail("error", "health check interrupted").build();
}
}
@PreDestroy
void shutdown() {
executor.shutdownNow();
}
}
The bean is named boundedDependencyHealthIndicator, and Actuator strips the suffix, so it appears as component boundedDependency. Nothing else is needed to register it.
What the endpoint returned
The lab exposes POST /demo/dependency/{HEALTHY|FAILING|HANGING} to switch the simulated dependency. Output below is pasted from a run against gradle bootRun on port 8090; Actuator's own diskSpace, ping, and ssl components are trimmed for length.
Healthy:
$ curl -s -w "nHTTP %{http_code}n" localhost:8090/actuator/health
{"status":"UP","components":{"boundedDependency":{"status":"UP","details":{"latencyMs":0}}, ...}}
HTTP 200
Failing (the probe throws IOException):
{"status":"DOWN","components":{"boundedDependency":{"status":"DOWN","details":{"error":"java.io.IOException: Connection refused (simulated)","latencyMs":0}}, ...}}
HTTP 503
Hanging (the probe sleeps 10 seconds; the indicator's timeout is 500 ms):
$ curl -s -w "nHTTP %{http_code} time_total=%{time_total}sn" localhost:8090/actuator/health
{"status":"DOWN","components":{"boundedDependency":{"status":"DOWN","details":{"reason":"timeout","timeoutMs":500}}, ...}}
HTTP 503 time_total=0.512952s
A second call measured 0.511977 s. The integration test hangingProbeTimesOutInsteadOfBlockingTheEndpoint asserts the request completes in under 2 seconds against a 10-second hang.
Does the cancelled probe leak a thread? A thread dump after the two hanging calls showed both health-probe-* threads parked on the executor's queue, idle. For this probe future.cancel(true) works because Thread.sleep honors interruption. A real driver's socket read may not; that is why the pool is fixed at two threads. In the worst case two probes are stuck, later checks still return timeout on time, and the thread count does not grow. The stuck-probe case is reasoned from the executor design, not demonstrated here.
Custom statuses: what the aggregator does with WARN
A second indicator in the lab returns new Status("WARN") when a simulated latency figure exceeds a threshold. With the default configuration the response looks like this:
{"status":"UP","components":{ ... "latencyAware":{"description":"Latency above threshold","status":"WARN","details":{"latencyMs":900,"warnThresholdMs":200}} ... }}
HTTP 200
The component says WARN; the overall status says UP. That is not "WARN is treated as healthy". It is that WARN does not take part in the aggregate at all. In Spring Boot 3.5.16, SimpleStatusAggregator.getAggregateStatus is:
return statuses.stream().filter(this::contains).min(this.comparator).orElse(Status.UNKNOWN);
contains checks membership in the configured order, which defaults to DOWN, OUT_OF_SERVICE, UP, UNKNOWN. Statuses outside that list are filtered out before the minimum is taken. Test warnStatusIsIgnoredByDefaultAggregator pins this behavior.
To make WARN count, put it in the order:
management.endpoint.health.status.order=DOWN,OUT_OF_SERVICE,WARN,UP,UNKNOWN
Now the aggregate becomes WARN when no component is DOWN (test warnBecomesOverallStatusWhenListedInOrder), and DOWN still wins because it is earlier in the list.
The interface the earlier article named, HealthAggregator, was removed in Spring Boot 2.2 together with the old Health-based aggregation; the current extension point is StatusAggregator, and for most needs the status.order property is enough.
The http-mapping trap
While adding WARN I also mapped it to an HTTP code:
management.endpoint.health.status.http-mapping.WARN=200
With only that line, a DOWN aggregate returned HTTP 200. The test was first written expecting 503 and failed with expected: 503 SERVICE_UNAVAILABLE but was: 200 OK. The cause is in SimpleHttpCodeStatusMapper:
this.mappings = CollectionUtils.isEmpty(mappings) ? DEFAULT_MAPPINGS : getUniformMappings(mappings);
A non-empty custom mapping replaces the default map (DOWN and OUT_OF_SERVICE to 503) instead of merging with it. Any status without a mapping falls back to 200. If you add one mapping, restate the built-in ones:
management.endpoint.health.status.order=DOWN,OUT_OF_SERVICE,WARN,UP,UNKNOWN
management.endpoint.health.status.http-mapping.WARN=200
management.endpoint.health.status.http-mapping.DOWN=503
management.endpoint.health.status.http-mapping.OUT_OF_SERVICE=503
Tests downStillWinsOverWarnButHttpMappingDefaultsAreGone (the trap) and downWinsOverWarnWithHttp503 (the fix) cover both configurations.
Caching: use the setting that exists
The earlier version suggested @Cacheable on health(). Actuator already caches endpoint responses through management.endpoint.health.cache.time-to-live, which defaults to zero. Set it when a probe is expensive; do not add a second caching layer on the indicator method, which would also require the caching infrastructure to be enabled and would proxy the bean.
Exposure and details
The lab's configuration:
management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=always
management.endpoint.health.show-components=always
always is right for a lab and wrong for a public endpoint: details include error messages and, for diskSpace, a filesystem path. Use when_authorized with Spring Security in front, or keep details off and rely on the overall status.
What this does not cover
- Real dependencies. The probe is simulated; there is no JDBC, Redis, or HTTP client here. Spring Boot ships indicators for many of those already, and they should be your first choice.
- Health groups, liveness and readiness probes, and Kubernetes probe configuration.
ReactiveHealthIndicatorfor WebFlux applications.- Securing
/actuator/health; the lab's/demo/**switch endpoint exists only for the demo. - The
WARN-only edge case where the aggregator falls back toUNKNOWN.
Reproduce it
gradle test # 8 tests across three Spring contexts
gradle bootRun --args='--server.port=8090'
curl -s -X POST localhost:8090/demo/dependency/HANGING
curl -s -w "nHTTP %{http_code} %{time_total}sn" localhost:8090/actuator/health
