[MM’s] Boot Notes — Resilience patterns made simple with Spring Boot 4.
Fortify Your Microservices with Retryable and ConcurrencyLimit

Hey Spring community! With the new release of Spring Boot 4 (built on the powerful new Spring Framework 7), a set of fantastic, built-in resilience tools are now at our fingertips. In a world of distributed systems, network hiccups and service overloads aren’t just possible — they’re inevitable.
Today, we’ll take a hands-on look at how the new @Retryable and @ConcurrencyLimit annotations can transform a fragile component into a robust, self-healing one.
The Scenario: An Unreliable API Call
Imagine we have a service that needs to call an external RESTful API. This API can sometimes be slow or return transient errors like a 503 Service Unavailable.
Our initial code might look something like this. Simple, clean, but prone to breaking in edge cases.
@Service
public class DataService {
private final RestfulApiClient client;
public DataService(RestfulApiClient client) {
this.client = client;
}
public RestfulApiResponse processRequest(String key) {
return this.client.getResponse(key);
}
}
What happens if the processRequest() method throws a GatewayTimeoutException? The operation fails, an error is logged and our user gets a frustrating experience.
Step 1: Automatically Retrying Failures with @Retryable 🔁
Transient errors, like temporary network blips, are perfect candidates for a retry. Instead of failing immediately, let’s just try again! With Spring Boot 4, this is incredibly simple.
First, we need to enable the new resilience capabilities in our configuration:
@Configuration
@EnableResilientMethods
public class ResilienceConfig {}
Now, we can annotate our method with @Retryable.
@Service
public class AnnotationBasedRetryService {
//...
@Retryable(includes = GatewayTimeoutException.class, maxAttempts = 4, multiplier = 2)
public RestfulApiResponse processRequest(String key) {
return this.client.getResponse(key);
}
}
With a single annotation, we’ve added powerful functionality:
- includes = GatewayTimeoutException.class: We only want to retry on this specific, transient exception.
- maxAttempts = 4: It will try the operation up to 4 more times after the initial failure.
- multiplier = 2: We're using an exponential back-off strategy. The delay between retries will double each time (e.g., 1s, 2s, 4s, 8s), which is a best practice to avoid hammering a struggling service.
Our service can now automatically recover from temporary API failures without any complex try-catch loops or manual state management.
Step 2: Preventing Overload with @ConcurrencyLimit 🚦
Our service is now resilient to failures, but what if our own application is the source of the problem? If we get a sudden spike in traffic, our processRequest method might be called by hundreds of requests at once, overwhelming the external API and causing it to fail or throttle us.
This is especially important when using Virtual Threads, where the number of concurrent tasks can be massive. Let’s safeguard our downstream dependency using @ConcurrencyLimit.
@Service
public class AnnotationBasedRetryService {
//...
@ConcurrencyLimit(15) // Only 15 concurrent executions allowed
@Retryable(includes = GatewayTimeoutException.class, maxAttempts = 4, multiplier = 2)
public RestfulApiResponse processRequest(String key) {
return this.client.getResponse(key);
}
}
By adding @ConcurrencyLimit(15), we've created a safeguard. Spring will now ensure that no more than 15 concurrent invocations of processRequest are running at any given time. The 16th request will simply wait until one of the previous 15 has completed. This acts as a protective throttle, preventing us from overwhelming the external API and improving the overall stability of the system.
Alternative approach: Programmatic Control with RetryTemplate
Annotations are perfect for most use cases, but sometimes you need more fine-grained control within a method. For these scenarios, Spring provides the RetryTemplate.
You can create a RetryPolicy with the exact same back-off and exception rules and then wrap any piece of code in a retryable block.
@Service
public class ProgrammaticBasedRetryService {
private final RestfulApiClient client;
private final RetryTemplate template;
public ProgrammaticBasedRetryService(RestfulApiClient client) {
this.client = client;
var retryPolicy = RetryPolicy.builder()
.includes(GatewayTimeoutException.class)
.maxAttempts(4)
.delay(Duration.ofMillis(200))
.build();
this.template = new RetryTemplate(retryPolicy);
}
public RestfulApiResponse processRequest(String key) {
try {
return template.execute(() -> this.client.getResponse(key));
} catch (RetryException e) {
throw new GatewayTimeoutException(e.getCause());
}
}
}
This gives you the power of programmatic retries exactly where you need it.
How Does This Compare to Resilience4j?
Many of you are likely familiar with Resilience4j, a mature library for fault tolerance. So, when should you use the new built-in Spring features and when should you reach for Resilience4j?
Think of it as a matter of scope and need:
Core Spring Resilience
✅ Provides essentials: Retry and Concurrency Throttling
✅ Built-in, no dependencies, just @EnableResilientMethods
✅ Great for 80% of common use cases
Resilience4j
🔧 Offers a full toolkit: Circuit Breaker, Rate Limiter, Bulkhead, Retry, Time Limiter, Cache
➕ Requires external dependency (library + Spring Boot starter)
🎯 Best when you need fine-grained, advanced resilience control
Bottom Line:
- Use the new Core Spring Resilience features: Your primary needs are retry and concurrency limiting, and you prefer to have these essentials provided directly by the framework without adding new dependencies.
- Stick with Resilience4j: You require more advanced patterns like Circuit Breaker or Rate Limiter. If you need a complete, highly configurable fault-tolerance toolkit for complex scenarios, Resilience4j remains the premier choice.
The new features aren’t a replacement for Resilience4j, but rather a powerful, integrated starting point for building more reliable applications.
Conclusion
The new resilience features baked directly into Spring Framework 7 and made effortlessly available in Spring Boot 4 are a game-changer for building robust applications. By combining @Retryable and @ConcurrencyLimit, we can easily:
- Improve reliability by automatically handling transient failures.
- Increase stability by protecting downstream services from being overwhelmed.
- Write cleaner code by replacing boilerplate logic with declarative annotations.
💡 Best Practices
- Target exceptions: Always specify includes/excludes in @Retryable to avoid retrying on non-transient errors (e.g., 401 Unauthorized, NullPointerException).
- Use backoff: Apply a multiplier for exponential backoff to prevent hammering a struggling service.
- Limit attempts: Keep maxAttempts reasonable; too many retries can hurt user experience.
- Set guardrails: Choose @ConcurrencyLimit based on downstream capacity, not just your app’s.
- Combine smartly: Retries handle hiccups; concurrency limits prevent overload.
- Measure & tune: Monitor retries, wait times, and errors — adjust settings with real-world data.
Discover what’s new in Spring Boot 4 and make your services more resilient.
You can find all the code on GitHub.
Originally posted on marconak-matej.medium.com.