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

timeoutDuration(Duration)

timeoutDuration

1s (one second)

How long the caller waits. The builder rejects null with NullPointerException: TimeoutDuration must not be null. The Boot binder additionally rejects a negative duration. The Future form converts it with toMillis().

cancelRunningFuture(boolean)

cancelRunningFuture

true

On a timeout of the Future form, call future.cancel(true). Not consulted by the CompletionStage form.

none

baseConfig

none

Start from another named configuration under resilience4j.timelimiter.configs. An instance with no baseConfig starts from configs.default, when it exists.

none

eventConsumerBufferSize

100

Size of the ring buffer behind the timelimiterevents Actuator endpoint. Must be at least 1.

none

resilience4j.timelimiter.tags.<key>

empty

Tags added to the metrics of every time limiter in the registry.

none

resilience4j.timelimiter.timeLimiterAspectOrder

Ordered.LOWEST_PRECEDENCE - 2

Position of the aspect in the chain: a lower value is the outer aspect. The default sits inside Retry, CircuitBreaker and RateLimiter and outside Bulkhead.

none

resilience4j.timelimiter.metrics.enabled

true

Registers the resilience4j.timelimiter.calls meters when Micrometer is present.

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:

TimeLimiter configuration in application.yml
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:

The defaults, in code
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.

flowchart TD A([TimeLimiter decoration]) --> B{what does the supplier return?} B -->|Future| C[decorateFutureSupplier: the caller thread blocks in Future.get with the timeout] B -->|CompletionStage| D[decorateCompletionStage: a ScheduledExecutorService completes the future exceptionally after the timeout] C --> E{done in time?} E -->|yes| F([result, SUCCESS event]) E -->|no| G[publish TIMEOUT, then cancel true when cancelRunningFuture] G --> H([throw TimeoutException]) D --> I([returns a stage at once, the caller is not blocked]) I --> J{completes in time?} J -->|yes| K([result, SUCCESS event]) J -->|no| L([stage fails with TimeoutException, TIMEOUT event, the work is not 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.

The blocking form
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 ExecutionException is unwrapped and the cause is rethrown as is (an Error too), and an ERROR event carries it. Checked exceptions of the work therefore reach the caller unchanged.

  • On a timeout the TIMEOUT event 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.

The non-blocking form
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

cancelRunningFuture

honoured: future.cancel(true) after the timeout

ignored: the work keeps running

Failure of the work

the cause is rethrown, unwrapped

the stage fails with the cause (inside CompletionException for join())

Spring @TimeLimiter

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:

Cancellation is an interrupt, not a kill
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

CompletionStage, CompletableFuture

executeCompletionStage with the aspect’s own ScheduledExecutorService

the returned stage fails with TimeoutException

Mono, Flux (with resilience4j-reactor)

transformDeferred(TimeLimiterOperator.of(timeLimiter))

the publisher errors with Reactor’s TimeoutException

Flowable, Single, Maybe, Completable, Observable (RxJava 2 or 3 modules)

the matching RxJava extension

see Reactive, Kotlin & RxJava

Future

nothing: IllegalReturnTypeException

thrown when the method is called

any other type (Money, String, void)

nothing: IllegalReturnTypeException

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.

Which return types the aspect accepts
@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):

The exact message, as printed by the run
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 a java.util.concurrent.TimeoutException, wrapped by block() in a ReactiveException.

  • On a Flux, the timeout applies between signals, not to the whole sequence. A Flux.interval(200 ms).take(4) taking 816 ms in total passed a 300 ms limiter untouched.

  • On a Flux, a SUCCESS event is published for every item and again on completion: three items produced four events, and the successful counter 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

spring.http.clients.connect-timeout, or the client’s own builder

HTTP read or response timeout

waiting for the response bytes

spring.http.clients.read-timeout, or the client’s own builder

TimeLimiter

the whole call as seen by the code that decorated it: connection, request, response, deserialization, queueing in an executor

resilience4j.timelimiter.*

Retry

nothing by itself: every attempt gets a full set of the timers above, so the total is attempts times the per-attempt limit plus the waits

resilience4j.retry.*

Gateway or ingress

the whole user request, including every retry

Spring Cloud Gateway route and global settings, ingress-nginx proxy-read-timeout (60 s by default)

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:

HTTP client defaults for RestClient and WebClient (application.yml)
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:

Per-client timeouts: RestClient, JDK HttpClient and Reactor Netty
// 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-timeout 200 ms and read-timeout 600 ms the worst case of one HTTP attempt on a slow-to-connect, slow-to-answer server is 200 + 600 = 800 ms, so a TimeLimiter of 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 TimeLimiter does not replace the client timeouts. Without them, a stuck connection is released only when the server gives up, and with the CompletionStage form 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:

Retry outside, TimeLimiter inside: the whole budget is spent
// 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 connect-timeout + read-timeout (200 + 600), so that the HTTP client reports first.

HTTP connect and read timeouts

200 ms and 600 ms

The innermost timers: the only ones that release the connection.

sequenceDiagram participant G as Gateway or ingress, 5 s participant R as Retry, 3 attempts, 200 ms apart participant T as TimeLimiter, 800 ms participant H as HTTP client, connect 200 ms, read 600 ms participant P as pricing-service G->>R: GET /books/978-1 (deadline 5000 ms) R->>T: attempt 1 at 0 ms T->>H: call H->>P: request Note over H,P: no answer, read timeout 600 ms H-->>T: ReadTimeoutException at about 600 ms T-->>R: failure, within the 800 ms limit Note over R: wait 200 ms R->>T: attempt 2 at about 800 ms T->>H: call H->>P: request Note over H,P: the stall is not an HTTP wait (a slow deserializer or an executor queue), so no client timer fires T-->>R: TimeoutException at 800 ms after the start of the attempt Note over R: wait 200 ms R->>T: attempt 3 at about 1800 ms Note over T: worst case 800 ms T-->>R: TimeoutException at about 2600 ms R-->>G: throws the last exception, no later than 2800 ms in the worst case Note over G: 5000 ms deadline not reached

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:

A single deadline outside Retry
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:

A per-route response timeout in Spring Cloud Gateway
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)

TimeoutException thrown directly by executeFutureSupplier, with the stack trace of the underlying Future.get timeout

CompletionStage, @TimeLimiter

the returned stage fails: get() throws ExecutionException with the TimeoutException as cause, join() throws CompletionException

Mono, Flux

the publisher errors with Reactor’s TimeoutException (Reactor’s message); block() wraps it in a ReactiveException

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

TimeLimiterOnSuccessEvent

SUCCESS

The operation finished in time. In 2.4.0 this includes a CompletionStage that completes with a null result.

TimeLimiterOnTimeoutEvent

TIMEOUT

The timeout elapsed. Any TimeoutException reaching the limiter counts, including one thrown by the work itself.

TimeLimiterOnErrorEvent

ERROR

The operation failed with something other than a TimeoutException; getThrowable() returns the cause.

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.

Subscribing to events
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):

Exposing and reading the endpoints
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 CompletionStage form never cancels the work, and cancel(true) in the Future form only interrupts. Give the work its own deadline and bound the pile-up with a bulkhead.

cancelRunningFuture against non-interruptible code

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.

@TimeLimiter on a method that returns a plain value or a Future

IllegalReturnTypeException on the first call. Return a CompletionStage or a Mono, and test every annotated method.

Forgetting the default of one second

An instance with no timeoutDuration and no configs.default is limited to 1 s. Define configs.default explicitly.

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 spring.http.client.* keys on Boot 4

They are deprecated and had no effect in the run. Use spring.http.clients.connect-timeout and spring.http.clients.read-timeout.

Ignoring the retry multiplication

Total = attempts x limit + waits. Make the gateway timeout exceed it, or lower maxAttempts, or put a TimeLimiter outside the retry with Decorators.

A deadline outside Retry that is assumed to stop the retries

The retry loop continues in the background after the caller times out (measured: the third attempt failed 600 ms after the TimeoutException).

Expecting one timeout message everywhere

Mono and Flux fail with Reactor’s own TimeoutException message, not the TimeLimiter '…​' recorded one. Match on the type.

Flux timeout assumed to cover the whole stream

Reactor’s timeout applies between signals. Use a separate deadline for the whole sequence.

Success events per item on a Flux

SUCCESS is published per item and at completion, which inflates the successful counter. Prefer counting on a Mono.

Unsized or unclosed scheduler

The CompletionStage form needs one you create and shut down. One thread is enough for the timers.

Blocking the caller with the Future form on a small pool

Each waiting call parks a thread. Use virtual threads or the CompletionStage form.

What changed recently

  • 2.4.0, CompletionStage results. In 2.3.0 the success branch of decorateCompletionStage tested result != null; in 2.4.0 it tests throwable == null (pull request #2374, "TimeLimiter, take success path when completing without error"). A stage that completes normally with a null value (a CompletableFuture<Void>, a Mono<Void> bridged to a stage) used to skip the SUCCESS event 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: SUCCESS events for a null result were 0 with 2.3.0 and 1 with 2.4.0. If you count successes from a Void operation, the counter now moves, and the successful rate of the resilience4j.timelimiter.calls metric rises accordingly.

  • 2.4.0 adds resilience4j-spring-boot4 for Spring Boot 4; the TimeLimiter API and the resilience4j.timelimiter.* properties are the same as with resilience4j-spring-boot3. The release notes are in the GitHub release; RELEASENOTES.adoc at the tag stops at 2.3.0.

  • Spring Boot 4.0 renamed the HTTP client defaults from spring.http.client. to spring.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, the IllegalReturnTypeException restriction or the events. Follow the source for defaults.