TimeLimiter
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
A time limiter puts a ceiling on how long the caller waits for an operation. It is the simplest pattern in the toolbox — one duration — and the one most often misunderstood, because "timeout" suggests that the slow work stops, and a
time limiter does not promise that. This page covers the Resilience4j TimeLimiter end to end: its two settings, the
blocking Future form and the non-blocking CompletionStage form, why @TimeLimiter refuses methods that return a plain
value, how it nests with the HTTP client’s own timeouts, with Retry and with the gateway timeout, the TimeoutException
it raises, its events and metrics, and a change in 2.4.0. It was written against Resilience4j 2.4.0, Spring Boot 4.1.x,
Spring Framework 7.0.x and Java 21 and every example on it was compiled and run on those versions on 4 October 2026. The
TimeLimiter API, the resilience4j.timelimiter.* properties and the Actuator output are identical on Spring Boot 3.5.x:
only the starter (resilience4j-spring-boot3) and its Java package differ. The vocabulary (timeout, deadline,
bulkhead, fallback) is introduced in
Resilience Fundamentals and the registry and
configuration model shared by every module in
Registries, Configuration & Decorators.
In the running Bookshelf scenario, pricing-service is slow and flaky. A page that waits 30 seconds for a price is worse
than a page that shows the last known price after one second, so bookshelf-service bounds every pricing call.
What a time limiter does and does not do
A TimeLimiter has two settings and one job. The job: when the wrapped operation has not finished within
timeoutDuration, give the caller a TimeoutException and move on. What it does not do is guarantee that the
operation itself stops. How much stops depends on the form you use and on what the work is doing, and the sections below
show each case with a measurement.
TimeLimiterConfig builder |
Spring Boot property | Default | Meaning |
|---|---|---|---|
|
|
|
How long the caller waits. The builder rejects |
|
|
|
On a timeout of the |
none |
|
none |
Start from another named configuration under |
none |
|
|
Size of the ring buffer behind the |
none |
|
empty |
Tags added to the metrics of every time limiter in the registry. |
none |
|
|
Position of the aspect in the chain: a lower value is the outer aspect. The default sits inside |
none |
|
|
Registers the |
The defaults come from TimeLimiterConfig and CommonTimeLimiterConfigurationProperties at v2.4.0. Two consequences
matter in practice. First, the default timeout is one second: an @TimeLimiter method whose instance has no
configuration inherits it, and a perfectly healthy 1.2 second call fails. Second, there is no property for the scheduler,
the thread that runs the work, or the clock: they are code.
A shared configuration plus per-instance overrides, as the Bookshelf uses them:
resilience4j:
timelimiter:
configs:
default:
timeoutDuration: 2s
cancelRunningFuture: true
instances:
pricing:
baseConfig: default
timeoutDuration: 800ms
eventConsumerBufferSize: 50
reviews:
timeoutDuration: 300ms
cancelRunningFuture: false
Source: Resilience4j: Spring Boot 2 & 3 and the
CommonTimeLimiterConfigurationProperties
source at v2.4.0. Run on Spring Boot 4.1.1: pricing bound to 800 ms with cancelRunningFuture true (inherited from
default), reviews to 300 ms and false, and an instance named unlisted took the default configuration (2 s).
The TimeLimiterConfig builder exposes the same two settings in code, which is what you use outside Spring:
TimeLimiterConfig defaults = TimeLimiterConfig.ofDefaults();
defaults.getTimeoutDuration(); // PT1S
defaults.shouldCancelRunningFuture(); // true
TimeLimiterConfig custom = TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofMillis(800))
.cancelRunningFuture(true)
.build();
TimeLimiter pricing = TimeLimiter.of("pricing", custom);
Source: Resilience4j: TimeLimiter and the
TimeLimiterConfig
source at v2.4.0. Run on Resilience4j 2.4.0: PT1S and true.
Two forms: blocking Future, non-blocking CompletionStage
The module decorates exactly two kinds of operation, and the choice decides who waits and what gets cancelled.
The Future form: blocking
executeFutureSupplier (or decorateFutureSupplier, which returns a Callable) takes a supplier of a Future, calls
future.get(timeout, MILLISECONDS) and blocks the calling thread for at most the timeout. That is the whole mechanism
(TimeLimiterImpl at v2.4.0), so it needs no scheduler and works with any Future, but the caller’s thread is parked
for the wait: cheap on a virtual thread, a scarce resource on a small platform-thread pool.
TimeLimiter limiter = TimeLimiter.of("pricing", TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofMillis(200)).build());
limiter.getEventPublisher().onEvent(events::add);
Future<String> slow = pool.submit(() -> {
try { Thread.sleep(5_000); } catch (InterruptedException e) { interrupted.set(true); }
return "late";
});
assertThatThrownBy(() -> limiter.executeFutureSupplier(() -> slow))
.isInstanceOf(TimeoutException.class)
.hasMessage("TimeLimiter 'pricing' recorded a timeout exception.");
assertThat(slow.isCancelled()).isTrue(); // cancelRunningFuture is true by default
// the sleeping task received an interrupt, and one TIMEOUT event was published
Source: Resilience4j: TimeLimiter and the
TimeLimiterImpl
source at v2.4.0. Run on Resilience4j 2.4.0: TimeoutException, the future cancelled, the interrupt received, events
[TIMEOUT].
Three details of the blocking form, all from the source:
-
If the future fails, the
ExecutionExceptionis unwrapped and the cause is rethrown as is (anErrortoo), and anERRORevent carries it. Checked exceptions of the work therefore reach the caller unchanged. -
On a timeout the
TIMEOUTevent is published first, then the future is cancelled, then the exception is thrown. -
With
cancelRunningFuture(false)the future is left alone: it keeps running and its result is simply never read. The test below let a 400 ms task outlive a 100 ms limit and the future still completed with its value.
The CompletionStage form: non-blocking
executeCompletionStage(scheduler, supplier) (or decorateCompletionStage) takes a supplier of a CompletionStage,
converts it to a CompletableFuture, and schedules a task on the scheduler you pass that completes that future
exceptionally with a TimeoutException once the timeout has elapsed. The call returns a stage at once; nobody blocks.
When the stage completes first, the scheduled task is cancelled.
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
TimeLimiter limiter = TimeLimiter.of("stage", TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofMillis(100)).build());
CompletionStage<String> stage = limiter.executeCompletionStage(scheduler, () ->
CompletableFuture.supplyAsync(() -> slowPriceLookup(), pool)); // takes 400 ms
assertThatThrownBy(() -> stage.toCompletableFuture().get())
.isInstanceOf(ExecutionException.class)
.hasCauseInstanceOf(TimeoutException.class)
.hasRootCauseMessage("TimeLimiter 'stage' recorded a timeout exception.");
// ... and 500 ms later the supplyAsync task had still run to completion
Source: Resilience4j: TimeLimiter and the
TimeLimiterImpl
source at v2.4.0. Run on Resilience4j 2.4.0: the stage failed after about 112 ms with the TimeoutException, the TIMEOUT
event was published once, and the underlying task finished later anyway.
The last line is the point to remember. In this form cancelRunningFuture is not consulted (the source never reads it),
and cancelling a CompletableFuture would not interrupt its thread anyway: the work continues in the background and its
result is discarded. The get() of a failed stage throws ExecutionException with the TimeoutException as its cause;
join() throws CompletionException.
The scheduler is yours to size and to shut down. One thread is enough for timeouts, since the scheduled task only
completes a future, but shut it down with the application. The Spring aspect creates its own
(Executors.newScheduledThreadPool(availableProcessors), or a ContextAwareScheduledThreadPoolExecutor bean when one
exists) and closes it on shutdown.
The two forms side by side, as measured by the same limiter with a 150 ms timeout against a one-second task:
| Future form | CompletionStage form | |
|---|---|---|
Caller thread |
blocked for the whole wait (measured: about 162 ms) |
returns immediately (measured: 1 ms) |
Needs a scheduler |
no |
yes, supplied by the caller |
|
honoured: |
ignored: the work keeps running |
Failure of the work |
the cause is rethrown, unwrapped |
the stage fails with the cause (inside |
Spring |
not supported |
supported |
cancelRunningFuture does not stop non-interruptible work
cancel(true) only interrupts the thread. Work that is sleeping, waiting on a lock or blocked in an interruptible I/O
call notices and stops. Work that is spinning in a loop, parsing a large document, or blocked in a call that ignores
interrupts does not. The next test cancels a task that never checks its interrupt flag:
TimeLimiter limiter = TimeLimiter.of("spin", TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofMillis(100)).build()); // cancelRunningFuture is true
AtomicBoolean finished = new AtomicBoolean();
Future<String> spin = pool.submit(() -> {
long end = System.nanoTime() + Duration.ofMillis(600).toNanos();
while (System.nanoTime() < end) { /* no blocking call, no isInterrupted() check */ }
finished.set(true);
return "done";
});
assertThatThrownBy(() -> limiter.executeFutureSupplier(() -> spin)).isInstanceOf(TimeoutException.class);
assertThat(spin.isCancelled()).isTrue(); // the Future says it is cancelled ...
// ... but 800 ms later finished is true: the loop ran to completion on its thread
Source: TimeLimiterImpl
source at v2.4.0 and Future.cancel
in the JDK 21 API. Run on Resilience4j 2.4.0: isCancelled() was true right after the timeout, and the work had finished
600 ms later.
The practical rules follow. A time limiter protects the caller’s latency, not the callee’s resources: a thread, a
connection or a database cursor stays busy until the work itself ends. So give the work its own deadline as well (the HTTP
client’s read timeout, the JDBC query timeout, the SDK’s operation timeout), write long loops so that they check
Thread.currentThread().isInterrupted(), and put a Bulkhead (see
Bulkhead) around anything that can pile up, so abandoned work cannot
exhaust a pool.
@TimeLimiter and the return type
@TimeLimiter(name = …, configuration = …, fallbackMethod = …) marks a method or a class. The aspect
(TimeLimiterAspect in resilience4j-spring6, shared by both starters) looks at the declared return type of the method
to pick a decoration, and refuses the rest:
| Declared return type | What the aspect does | Failure shows up as |
|---|---|---|
|
|
the returned stage fails with |
|
|
the publisher errors with Reactor’s |
|
the matching RxJava extension |
|
|
nothing: |
thrown when the method is called |
any other type ( |
nothing: |
thrown when the method is called |
The reason is mechanical, not arbitrary. A time limiter has to stop waiting while the work is still going on, and a
method that returns a plain value has already waited by the time anyone sees it: the proxy would have to run the method on
another thread, which is a threading decision the library refuses to make for you. A CompletionStage or a reactive type
is a handle to work that is already (or lazily) asynchronous, so the aspect can attach the timeout to it. A plain
Future is not supported by the aspect even though the programmatic API supports it: the aspect only knows how to build
the non-blocking form.
@Service
public class PricingGateway {
// supported: CompletionStage
@TimeLimiter(name = "pricing")
public CompletableFuture<Money> price(String isbn) {
return CompletableFuture.supplyAsync(() -> fetch(isbn), executor);
}
// supported: Mono (needs resilience4j-reactor on the classpath)
@TimeLimiter(name = "pricing")
public Mono<Money> priceReactive(String isbn) {
return reactiveHttp.get().uri("/prices/{isbn}", isbn).retrieve().bodyToMono(Money.class);
}
// NOT supported: Future
@TimeLimiter(name = "pricing")
public Future<Money> priceFuture(String isbn) {
return executor.submit(() -> fetch(isbn));
}
// NOT supported: a plain value
@TimeLimiter(name = "pricing")
public Money priceBlocking(String isbn) {
return fetch(isbn);
}
}
Source: Resilience4j: Spring Boot 2 & 3 and the
TimeLimiterAspect
source at v2.4.0. Run on Spring Boot 4.1.1 with resilience4j-spring-boot4 2.4.0: the first two methods work and the
last two throw as shown below.
Calling either unsupported method fails immediately, before the method body runs, with an IllegalReturnTypeException
(a subclass of IllegalArgumentException in io.github.resilience4j.spring6.timelimiter.configure):
io.github.resilience4j.spring6.timelimiter.configure.IllegalReturnTypeException:
java.util.concurrent.Future demo.g4timelimiter.PricingGateway#priceFuture has unsupported by @TimeLimiter return type. CompletionStage expected.
io.github.resilience4j.spring6.timelimiter.configure.IllegalReturnTypeException:
com.example.bookshelf.Money demo.g4timelimiter.PricingGateway#priceBlocking has unsupported by @TimeLimiter return type. CompletionStage expected.
Source: IllegalReturnTypeException
source at v2.4.0. Each message was a single line in the run; they are wrapped here for width. The wording "has
unsupported by" is the library’s own. With WireMock verifying zero requests, the method body never ran.
Two further consequences. First, the exception is thrown when the method is invoked, not at startup: a wrongly typed
method passes the application context and fails on its first call, so cover every @TimeLimiter method with a test (see
Testing Resilience). Second, the annotation has to be on a Spring bean
reached through its proxy and requires the aspect: with resilience4j-spring-boot4 that means
spring-boot-starter-aspectj on the classpath, otherwise the annotation is silently ignored (see
Getting Started).
Mono and Flux
For reactive types the aspect applies TimeLimiterOperator, which calls Reactor’s own timeout(Duration) and reports to
the time limiter. Three differences from the CompletionStage form follow from the source and a run:
-
The exception comes from Reactor and carries Reactor’s message, not the Resilience4j one:
Did not observe any item or terminal signal within 800ms in 'flatMap' (and no fallback has been configured). It is still ajava.util.concurrent.TimeoutException, wrapped byblock()in aReactiveException. -
On a
Flux, the timeout applies between signals, not to the whole sequence. AFlux.interval(200 ms).take(4)taking 816 ms in total passed a 300 ms limiter untouched. -
On a
Flux, aSUCCESSevent is published for every item and again on completion: three items produced four events, and thesuccessfulcounter counts events.
Cancelling the subscription is what stops the upstream in a reactive pipeline, so a timed-out Mono does cancel its
HTTP request, which the CompletionStage form cannot do.
Timeout layering
A call to pricing-service crosses several timers, and each one belongs to a different layer. They must nest, with the
inner ones expiring first; when they do not, the wrong one fires and the evidence points to the wrong culprit.
| Layer | What it bounds | Where to set it |
|---|---|---|
HTTP connect timeout |
establishing the TCP (and TLS) connection |
|
HTTP read or response timeout |
waiting for the response bytes |
|
TimeLimiter |
the whole call as seen by the code that decorated it: connection, request, response, deserialization, queueing in an executor |
|
Retry |
nothing by itself: every attempt gets a full set of the timers above, so the total is |
|
Gateway or ingress |
the whole user request, including every retry |
Spring Cloud Gateway route and global settings, ingress-nginx |
The HTTP client’s own timeouts come first
A time limiter is a backstop for anything that can wait; an HTTP client can do better for HTTP, because it can give up
and release the connection. With Spring Boot the defaults for RestClient and WebClient are properties applied to the
auto-configured builders:
spring:
http:
clients:
connect-timeout: 200ms
read-timeout: 600ms
Source: Spring Boot: Calling REST Services. Run on Spring
Boot 4.1.1 with both RestClient and WebClient: a stub that answers after three seconds failed at about 611 ms (RestClient)
and 733 ms including start-up (WebClient), with a ReadTimeoutException as the root cause, and the 800 ms time limiter
never fired.
The property names matter on Boot 4. The keys are spring.http.clients.connect-timeout and spring.http.clients.read-timeout
(plural clients, since Boot 4.0). The older singular spring.http.client.connect-timeout and spring.http.client.read-timeout
still appear in the configuration metadata, marked deprecated since 4.0.0 with the plural keys as replacement, and had no
effect in the run: with only spring.http.client.read-timeout=300ms set, the call lasted until the 800 ms time limiter
fired. The same properties set a per-group default for HTTP service clients under spring.http.serviceclient.<group>.*.
Which exception you see depends on the HTTP library Boot picks from the classpath; in the Bookshelf (Reactor Netty is
present) it was an IOException caused by a Netty ReadTimeoutException.
When you build the client yourself, or need a different value for one client, set the timeouts on the client:
// RestClient on the JDK HTTP client
var factory = new JdkClientHttpRequestFactory(
HttpClient.newBuilder().connectTimeout(Duration.ofMillis(200)).build());
factory.setReadTimeout(Duration.ofMillis(300));
RestClient restClient = RestClient.builder().requestFactory(factory).baseUrl(baseUrl).build();
// the JDK HttpClient used directly: connect timeout on the client, request timeout per request
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(200)).build();
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/prices/x"))
.timeout(Duration.ofMillis(300)).GET().build();
// WebClient on Reactor Netty
var nettyClient = reactor.netty.http.client.HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 200)
.responseTimeout(Duration.ofMillis(300));
WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(nettyClient)).baseUrl(baseUrl).build();
Source: Spring Framework: REST Clients,
Spring Framework: WebClient configuration
and the JDK 21 API for HttpClient.Builder
and HttpRequest.Builder.
Run against a three-second WireMock stub: the JdkClientHttpRequestFactory failed with a ResourceAccessException caused by
HttpTimeoutException; the JDK request timeout threw HttpTimeoutException: request timed out; Reactor Netty threw a
WebClientRequestException caused by ReadTimeoutException; a JDK connect timeout of 300 ms against an unroutable
address threw HttpConnectTimeoutException after about 305 ms.
Note that the JDK HttpRequest.timeout and the Reactor Netty responseTimeout bound the wait for the response; they
are not an overall deadline for a slow body. A reactive .timeout(Duration) on the Mono, as in
webClient.get()…bodyToMono(String.class).timeout(Duration.ofMillis(300)), is a different thing: it is Reactor’s
time limiter on the subscription and fails with a plain TimeoutException.
Why the time limiter is still needed, and how to size it
If the HTTP client always expires first, the time limiter would never fire. It is still worth having for three reasons:
it covers the parts the client timeouts do not (queueing in an executor, DNS, a slow deserializer, a database call, an SDK
with no timeout), it bounds a composition of several calls, and it is the only timer that also produces
TimeLimiter events and metrics. The rules of thumb:
-
Set the limit of the inner timer below the outer one. With
connect-timeout200 ms andread-timeout600 ms the worst case of one HTTP attempt on a slow-to-connect, slow-to-answer server is 200 + 600 = 800 ms, so aTimeLimiterof 800 ms or a little more only fires for what the client cannot see. -
If the time limiter is shorter than the HTTP timeouts, the HTTP request keeps its connection until its own timeout: the caller moved on, but the connection and the server-side work did not stop. The measurement in the next section shows it: with a 5 s HTTP read timeout and an 800 ms time limiter, three requests still reached the server.
-
A
TimeLimiterdoes not replace the client timeouts. Without them, a stuck connection is released only when the server gives up, and with theCompletionStageform the thread that was running it stays blocked all that time.
A time budget across retries
Retry multiplies the timers: each attempt gets a fresh set. In the default aspect order (Retry outside, TimeLimiter
inside) the worst-case time of a call is:
total = attempts * perAttemptLimit + sum of the waits between attempts
= maxAttempts * timeoutDuration + (maxAttempts - 1) * waitDuration (fixed wait)
With the Bookshelf values (timeoutDuration 800 ms, maxAttempts 3, waitDuration 200 ms) the arithmetic is
3 x 800 + 2 x 200 = 2800 ms. With exponential backoff starting at 200 ms (waits of 200 ms and 400 ms) it is
3 x 800 + 600 = 3000 ms; jitter moves each wait. The test below stubs the server to answer after five seconds, makes
the HTTP read timeout longer than the limiter (so the time limiter is the timer that fires), and measures:
// resilience4j.retry.instances.pricing: maxAttempts 3, waitDuration 200ms
// resilience4j.timelimiter.instances.pricing: timeoutDuration 800ms
@Retry(name = "pricing")
@TimeLimiter(name = "pricing")
public CompletableFuture<Money> priceWithRetry(String isbn) {
return CompletableFuture.supplyAsync(() -> fetch(isbn), executor);
}
// the server never answers within 5 s
assertThatThrownBy(() -> gateway.priceWithRetry("slow").get())
.isInstanceOf(ExecutionException.class)
.hasCauseInstanceOf(TimeoutException.class); // the original exception, after the last attempt
Source: Resilience4j: Retry and the aspect order of
Annotations & Aspect Order. Run on Spring Boot 4.1.1:
the call failed after about 2881 ms (the arithmetic gives 2800 ms plus scheduling overhead) with the TimeoutException
of the last attempt, and three HTTP requests reached the server.
The edge in front of the application has to outlive that. A gateway or ingress timeout shorter than 2.8 s cuts the user request while the application is still retrying, and everything the application spent after that point is wasted. The budget works backwards from the outermost timer:
| Timer | Value | Why |
|---|---|---|
Gateway or ingress response timeout |
5 s, or the user-facing SLO |
The outermost deadline. ingress-nginx defaults to 60 s, which is far too generous to act as one. |
Retry budget |
2800 ms (3 attempts, 200 ms wait) |
Must fit under the edge with room for the fallback and for other calls in the same request. |
TimeLimiter per attempt |
800 ms |
Must be at least |
HTTP connect and read timeouts |
200 ms and 600 ms |
The innermost timers: the only ones that release the connection. |
The timeline is illustrative: it uses the configured values, not a measurement. Attempt 1 ends early because the HTTP client’s read timeout is the inner timer. Attempt 2 shows the case the time limiter exists for, work with no timer of its own, which is cut at 800 ms. The worst case, every attempt using its full 800 ms, is the 2800 ms of the table.
If you want a hard deadline for the whole operation regardless of the number of attempts, put the time limiter outside
the retry. The aspect order cannot do that (and timeLimiterAspectOrder would only move the whole aspect), but the
Decorators builder can, because each with… call wraps what came before it:
Retry retry = Retry.of("pricing", RetryConfig.custom()
.maxAttempts(3).waitDuration(Duration.ofMillis(100)).build());
TimeLimiter deadline = TimeLimiter.of("pricing-total", TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofSeconds(1)).build());
CompletionStage<String> call = Decorators.ofCompletionStage(attempt)
.withRetry(retry, scheduler) // inner: the attempts
.withTimeLimiter(deadline, scheduler) // outer: one deadline for all of them
.get();
Source: Resilience4j: Retry examples and the
Programmatic Usage page. Run on Resilience4j 2.4.0 with an
attempt that fails after 300 ms: the call failed with a TimeoutException after about 1027 ms, three attempts had started
by then, and the third kept running after the caller had been told: 600 ms later it had failed too. The deadline bounded the
caller, not the retry loop.
Spring Framework 7 has an analogous setting on its own @Retryable: its timeout attribute is documented as the maximum
elapsed time for the first invocation and all the retries, including the delays (0 means none). See the
Spring Framework: Resilience features page and
Retry for the comparison.
Finally, the gateway’s timers live in the gateway’s own configuration. Spring Cloud Gateway has a connect timeout (in
milliseconds) and a response timeout (a Duration) that can be set globally on its HTTP client or per route through route
metadata:
spring:
cloud:
gateway:
server:
webflux:
routes:
- id: bookshelf
uri: http://bookshelf-service.example.invalid
predicates:
- Path=/books/**
metadata:
response-timeout: 5000
connect-timeout: 500
Source: Spring Cloud Gateway: HTTP timeouts configuration
(the page documents response-timeout and connect-timeout as route metadata in milliseconds). Gateway 5.0.x (Spring
Cloud 2025.1, the version this section targets) binds routes only under spring.cloud.gateway.server.webflux.routes; the
older spring.cloud.gateway.routes prefix of the Boot 3 era is not read any more, so a route declared there is silently
ignored together with its timeouts. This example was not run, since
it needs a Gateway application. In Kubernetes the equivalent timers are the ingress-nginx proxy-connect-timeout and
proxy-read-timeout (annotation nginx.ingress.kubernetes.io/proxy-read-timeout, default 60 seconds), documented on the
ingress-nginx annotations page.
The Gateway side of Resilience4j is covered in the gateway page of this section.
TimeoutException
The time limiter’s failure is java.util.concurrent.TimeoutException, a checked exception, created by
TimeLimiter.createdTimeoutExceptionWithName with a fixed message:
TimeLimiter 'pricing' recorded a timeout exception.
Source: TimeLimiter at v2.4.0 (createdTimeoutExceptionWithName).
The name is the name of the limiter instance, which makes the message searchable in logs. Where the exception appears depends on the form:
| Form | What the caller sees |
|---|---|
Future (programmatic) |
|
CompletionStage, |
the returned stage fails: |
Mono, Flux |
the publisher errors with Reactor’s |
Downstream, a TimeoutException is an ordinary failure. The CircuitBreaker records it, so timeouts open the breaker and
this is how a slow dependency is turned into an open circuit (see
CircuitBreaker). Retry retries it, unless you list it in
ignoreExceptions or use retryExceptions that exclude it (see Retry),
and a fallback can catch it by type (see
Fallbacks & Composition). At an HTTP boundary a timeout
that the application could not hide maps to 504 Gateway Timeout (or 503 when you prefer to signal "retry later"), and
the code that does so is a plain @ExceptionHandler: the time limiter does not translate exceptions.
Events and metrics
A TimeLimiter publishes exactly three events, all from io.github.resilience4j.timelimiter.event:
| Event class | TimeLimiterEvent.Type |
Published when |
|---|---|---|
|
|
The operation finished in time. In 2.4.0 this includes a |
|
|
The timeout elapsed. Any |
|
|
The operation failed with something other than a |
Events are published only while a consumer is registered (the limiter checks hasConsumers()). Spring Boot registers one
per instance, a ring buffer of eventConsumerBufferSize entries, which feeds the Actuator endpoint; you can add your own.
TimeLimiter limiter = TimeLimiter.ofDefaults("ok");
limiter.getEventPublisher()
.onSuccess(e -> log.info("{}", e))
.onTimeout(e -> log.warn("{}", e))
.onError(e -> log.error("{} {}", e, e.getThrowable().toString()));
Source: Resilience4j: TimeLimiter and the
resilience4j-timelimiter module at v2.4.0.
Run on Resilience4j 2.4.0 (success only; the success event printed … TimeLimiter 'ok' recorded a successful call.).
Two Actuator endpoints are registered when they are exposed: timelimiters (the names of all instances) and
timelimiterevents (the buffered events, optionally filtered by instance name and event type):
management:
endpoints:
web:
exposure:
include: health,timelimiters,timelimiterevents,metrics
curl -s http://localhost:8080/actuator/timelimiters
curl -s http://localhost:8080/actuator/timelimiterevents/pricing
curl -s http://localhost:8080/actuator/timelimiterevents/pricing/timeout
{"timeLimiters":["pricing","reviews"]}
{"timeLimiterEvents":[
{"creationTime":"2026-10-04T18:16:41.939164+02:00[Europe/Madrid]","timeLimiterName":"pricing","type":"SUCCESS"},
{"creationTime":"2026-10-04T18:16:42.749811+02:00[Europe/Madrid]","timeLimiterName":"pricing","type":"TIMEOUT"}]}
Source: Spring Boot: Actuator endpoints and the
TimeLimiterEventsEndpoint
source at v2.4.0. Output of the run, with one fast and one slow call; the third request returns only the TIMEOUT entry
and the output is shown compacted. Expose Actuator endpoints only on a protected management port or behind authentication:
the event buffer reveals call patterns.
The metrics come from Micrometer (resilience4j-micrometer, included by the starter): a single counter,
resilience4j.timelimiter.calls, tagged name (the instance) and kind with the values successful, failed and
timeout. The run produced resilience4j.timelimiter.calls{name=pricing,kind=timeout} equal to 1 after one timed-out
call, and through /actuator/metrics/resilience4j.timelimiter.calls?tag=kind:timeout the COUNT measurement was 1.0.
The timeout rate per instance, rate(resilience4j_timelimiter_calls_total{kind="timeout"}[5m]) in Prometheus, is the
first number to look at when a dependency slows down; the dashboards and alerts built on it are in
Observability. There is no health indicator for the time limiter.
Pitfalls
| Pitfall | What to do |
|---|---|
Believing a timeout stops the work |
It stops the wait. The |
|
A busy loop, a CPU-bound task and many blocking calls ignore interrupts: the work finishes later and keeps its thread. Check the interrupt flag in loops. |
|
|
Forgetting the default of one second |
An instance with no |
HTTP read timeout longer than the time limiter |
The caller leaves at the limit but the connection stays busy to the HTTP timeout. Make the client timeouts the innermost. |
Time limiter shorter than connect plus read timeouts |
The HTTP client never reports its own, more informative, exception. Size the limiter at least at connect + read. |
Using the singular |
They are deprecated and had no effect in the run. Use |
Ignoring the retry multiplication |
Total = attempts x limit + waits. Make the gateway timeout exceed it, or lower |
A deadline outside |
The retry loop continues in the background after the caller times out (measured: the third attempt failed 600 ms after the |
Expecting one timeout message everywhere |
|
|
Reactor’s |
Success events per item on a |
|
Unsized or unclosed scheduler |
The |
Blocking the caller with the |
Each waiting call parks a thread. Use virtual threads or the |
What changed recently
-
2.4.0, CompletionStage results. In 2.3.0 the success branch of
decorateCompletionStagetestedresult != null; in 2.4.0 it teststhrowable == null(pull request #2374, "TimeLimiter, take success path when completing without error"). A stage that completes normally with anullvalue (aCompletableFuture<Void>, aMono<Void>bridged to a stage) used to skip theSUCCESSevent and the cancellation of the scheduled timeout task; now it takes the success path. Verified by running the same code against the 2.3.0 and 2.4.0 jars:SUCCESSevents for anullresult were 0 with 2.3.0 and 1 with 2.4.0. If you count successes from aVoidoperation, the counter now moves, and thesuccessfulrate of theresilience4j.timelimiter.callsmetric rises accordingly. -
2.4.0 adds
resilience4j-spring-boot4for Spring Boot 4; theTimeLimiterAPI and theresilience4j.timelimiter.*properties are the same as withresilience4j-spring-boot3. The release notes are in the GitHub release;RELEASENOTES.adocat the tag stops at 2.3.0. -
Spring Boot 4.0 renamed the HTTP client defaults from
spring.http.client.tospring.http.clients.. -
The readme.io TimeLimiter page predates all of this: it documents only the programmatic API, and its blocking-form example,
executeFutureSupplier) → CompletableFuture.supplyAsync(() → helloWorldService::sayHelloWorld, does not compile as written. It does not mention the Spring Boot properties, the one-second default, theIllegalReturnTypeExceptionrestriction or the events. Follow the source for defaults.