Resilience4j versus Bucket4j
|
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. |
Both libraries have something called a rate limiter, both are Apache-2.0 Java libraries, and both work in a Spring Boot service, so the question "which one?" comes up as soon as a team needs to limit anything. The short answer is that they were built for different jobs and work best together. This page explains the difference, measures it, and shows the two side by side in the Bookshelf service.
It was written against Resilience4j 2.4.0, Bucket4j 8.21.0, Spring Boot 4.1.x (4.1.1), Spring Framework 7.0.x and Java 21,
with the community bucket4j-spring-boot-starter at 0.20.0-RC1 (0.14.0 for Spring Boot 4.0) and Spring Cloud Gateway 5.0.3,
and verified on 5 October 2026: Maven Central still lists 8.21.0 and 2.4.0 as the latest releases, and the starter’s Boot 4.1
line is still only the 0.20.0-RC1 pre-release. Every example on this page was run in a scratch project: the burst comparison
with a controllable clock, and the combined example against Redis 7 in Testcontainers.
Two libraries, two jobs
Resilience4j is a fault-tolerance toolkit for the calls your service makes. Its six core patterns (CircuitBreaker, Retry,
RateLimiter, Bulkhead, TimeLimiter, Cache) all decorate a call to a dependency. Its RateLimiter is one of them: it counts
permits in this JVM so that this instance does not call a dependency more often than it should. The section’s
RateLimiter page covers it in full, and its last section lists what it does not
do: no per-client keys, no shared state, nothing persistent.
Bucket4j is a rate-limiting and quota library. It has one concern, how many operations a key may perform per unit of time, and it solves it with a token bucket that can live in the JVM or in a shared store (Redis, a JDBC database, a data grid). That makes it usable in two directions:
-
inbound, to give each client of your API its own quota (100 requests per hour on the free plan, 5 000 on the pro plan), enforced across every replica
-
outbound, to share one quota of a third-party API between all replicas, which a per-instance limiter cannot do
The two overlap in exactly one place: an in-process limit on outbound calls. Everywhere else they complement each other, and Bucket4j even depends on what Resilience4j provides, because a Bucket4j bucket in Redis is itself an outbound call that can be slow or fail.
In the figure, orange is Bucket4j and blue is Resilience4j. The orange boxes are all about who may do how much; the blue ones are all about what happens when a dependency misbehaves, and that includes the Redis that holds the buckets.
The algorithms side by side
Resilience4j: fixed cycles
The Resilience4j RateLimiter (the default AtomicRateLimiter) splits time into cycles of limitRefreshPeriod, counted from
the creation of the limiter. Each cycle starts with limitForPeriod permits; unused permits are lost at the end of the cycle,
and a caller that finds none waits for the next cycle, but no longer than timeoutDuration. The defaults (50 permits every
500 ns, 5 s timeout) limit nothing, so both numbers must be set. The model is a fixed window: there is no gradual refill,
and the counter never holds more than one cycle’s worth of permits.
Bucket4j: a token bucket
A Bucket4j bandwidth has a capacity C and a refill, for example refillGreedy(10, Duration.ofSeconds(1)), which adds
tokens continuously at r = 10 per second. With nothing consumed since t0:
The capacity sets the largest burst and the refill sets the long-run rate, independently. A bucket can hold several
bandwidths (100 per hour and 10 per second); a request must satisfy all of them. refillIntervally adds the whole refill at
once at the end of each period, which behaves much like a fixed window, but not quite (see
token bucket and bandwidths).
The burst at a cycle boundary
The two models differ most at the edge of a cycle. In any interval of length T, a cycle limiter can admit permits from every cycle that the interval touches, and an interval as short as one nanosecond can touch two cycles. A token bucket can admit at most its capacity plus what it refilled during the interval:
where L is limitForPeriod and P is limitRefreshPeriod. For "10 per second" (L = C = 10, P = 1 s, r = 10/s) the cycle
limiter lets 20 requests through in the last millisecond of one cycle and the first of the next; the bucket lets 10 through,
plus 0.01 token refilled in that millisecond. Over a whole second both bounds are 20, so the long-run rate is the same: the
difference is how concentrated the burst can be, and with a token bucket you choose it through the capacity.
The test below fires ten requests at four instants against both libraries, configured as "10 per second" on the same manual clock, and a third limiter, a bucket with the same rate and a capacity of 2:
static final class ManualClock implements TimeMeter { // Bucket4j's clock abstraction
final AtomicLong nanos = new AtomicLong();
@Override public long currentTimeNanos() { return nanos.get(); }
@Override public boolean isWallClockBased() { return false; }
}
ManualClock r4jClock = new ManualClock();
RateLimiter cycle = RateLimiter.of("isbn", RateLimiterConfig.custom()
.limitForPeriod(10)
.limitRefreshPeriod(Duration.ofSeconds(1))
.timeoutDuration(Duration.ZERO) // reject instead of waiting
.build(), r4jClock::currentTimeNanos); // Resilience4j accepts a nanosecond supplier
ManualClock b4jClock = new ManualClock();
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(10, Duration.ofSeconds(1)))
.withCustomTimePrecision(b4jClock)
.build();
ManualClock smallClock = new ManualClock();
Bucket smallBurst = Bucket.builder()
.addLimit(limit -> limit.capacity(2).refillGreedy(10, Duration.ofSeconds(1)))
.withCustomTimePrecision(smallClock)
.build();
// at 990, 1000, 1500 and 2000 ms: set each clock, then try ten times
// cycle -> {990=10, 1000=10, 1500=0, 2000=10}
// bucket -> {990=10, 1000=0, 1500=5, 2000=5}
// capacity 2 -> {990=2, 1000=0, 1500=2, 2000=2}
Source: Resilience4j: RateLimiter, Bucket4j reference: Bandwidth and Bucket4j reference: a custom time measurement strategy.
The results are the ones in the comments, and the timeline shows them:
Between 0.99 s and 1.00 s the cycle limiter admitted 20 requests in 10 milliseconds; the bucket admitted 10. The bucket
with capacity 2 shows the other lever: the long-run rate is the same 10 per second, but no burst is ever larger than 2. With
Resilience4j the only way to get a small burst is a short cycle (limitForPeriod: 1, limitRefreshPeriod: 100ms), which
changes the granularity of everything else.
Feature comparison
Each row links the official documentation of both libraries. "Not built in" means the library does not provide it, not that it cannot be built on top.
| Feature | Resilience4j 2.4.0 | Bucket4j 8.21.0 |
|---|---|---|
Algorithm and state |
||
Algorithm |
Fixed cycles: |
Token bucket: capacity plus greedy, intervally or aligned refill; the burst is the capacity. Refill types |
Several limits per bucket |
No: one limit per limiter; combine two limiters by decorating twice. RateLimiter |
Yes: several bandwidths per bucket, each must allow the request (100 per hour and 10 per second). Bucket configuration |
Per-key buckets |
One limiter per name; one per client means one registry entry per client, kept forever. RateLimiter |
One bucket per key of any type, created on first use, expiring in the store. ProxyManager |
Distributed state and backends |
Not built in: the state is in the JVM. RateLimiter |
Redis (Lettuce, Jedis, Redisson, Vert.x, GLIDE), JDBC (PostgreSQL, MySQL, MariaDB, Oracle, SQL Server, DB2), JCache, Hazelcast, Ignite, Infinispan, Coherence, Geode, MongoDB, Couchbase, Memcached, Cassandra, Caffeine. Distributed features |
Consuming |
||
Blocking and async waiting |
Blocking: the caller is parked up to |
Blocking: |
Weighted consumption |
|
|
Returning tokens |
Not built in; |
|
Changing limits at runtime |
|
|
Other patterns (Resilience4j only) |
||
Circuit breaker |
|
Not built in. Basic features |
Retry |
|
Not built in; the compare-and-swap retries of a |
Bulkhead |
|
Not built in. Basic features |
Time limiter |
|
Not built in; |
Cache |
|
Not built in. Basic features |
Spring integration |
||
Annotations |
|
None in Bucket4j. The community starter adds |
Spring Boot auto-configuration |
|
None in Bucket4j; the community starter binds |
Actuator endpoints |
|
None in Bucket4j; the starter exposes a |
Health indicators |
|
None; write a |
Micrometer metrics |
Gauges |
None built in; a |
Ecosystem |
||
Reactor and Kotlin |
|
No Reactor or Kotlin module; wrap the |
Spring Cloud Gateway integration |
Through Spring Cloud CircuitBreaker: the gateway |
Web MVC |
Licence |
Apache-2.0. Repository |
Apache-2.0. Repository |
Java baseline |
Java 17 since 2.0.0. Getting started |
Java 17 for |
Latest version (5 October 2026) |
2.4.0. Release |
8.21.0 (2 October 2026). Release notes |
Two rows deserve a comment. Health: a Resilience4j limiter that is doing its job reports RATE_LIMITED, which is a signal,
not a failure; a Bucket4j store that is down is a real failure of a dependency, which is why
Bucket4j observability writes a HealthIndicator for it. Several limits:
two Resilience4j limiters stacked on one call work, but a call admitted by the first and rejected by the second has spent a
permit of the first; a Bucket4j bucket checks all its bandwidths atomically and consumes from none of them when one refuses.
Decision guide
to one dependency| R4J["Resilience4j RateLimiter
per instance"] Q -->|one quota of a third party,
shared by every replica| SHARED["Bucket4j distributed bucket
ProxyManager, key quota:isbn"] Q -->|requests of your clients,
per API key, user or IP| WHERE{Where is the decision made?} WHERE -->|in the service: plans,
costs per endpoint| INBOUND["Bucket4j in the service
starter or your own filter"] WHERE -->|at the edge, before
the request reaches a service| EDGE["Gateway Bucket4j filter
or an API manager"] Q -->|how many calls run
at the same time| CONC["Bulkhead or
Spring Framework 7 ConcurrencyLimit"] Q -->|failures and slowness
of a dependency| FT["Resilience4j CircuitBreaker,
Retry, TimeLimiter"] classDef b4j fill:#fff1e6,stroke:#b34700,color:#222 classDef r4j fill:#e8f0f8,stroke:#1c4b73,color:#222 class SHARED,INBOUND,EDGE b4j class R4J,CONC,FT r4j
| Need | Use | Why |
|---|---|---|
Stay under a dependency’s limit from one instance (the ISBN provider allows 5 calls per second per client) |
In memory, no network call, composes with Retry and CircuitBreaker in one decorator chain. The limit multiplies with the replica count, so give each instance its share. |
|
One quota of a third-party API shared by every replica (1 000 ISBN lookups per day) |
Only shared state holds a service-wide limit; the per-instance limiter admits the quota once per replica. |
|
Inbound per-client quotas (free and pro plans) |
the starter for YAML-driven limits, or your own filter for full control |
Per-key buckets, several bandwidths, weighted costs and a shared store are what client quotas need. The starter is a community project; the manual filter has no third-party dependency. |
Limits at the edge, before traffic reaches a service |
the gateway (Bucket4j or the Redis |
Stops abusive traffic once, for every service behind the gateway. Gateway-level quotas, plans and API managers are the subject of a separate issue (#184). |
Per-user token quotas for an LLM endpoint (each call costs the tokens it used, across every replica) |
a Bucket4j distributed bucket per user, consuming a weighted cost per call |
The provider’s own limit is per account, not per user; the per-instance limiter and |
Limit concurrency, not rate |
A rate limiter allows 10 calls per second even if each takes a minute; a bulkhead caps how many run at once. |
The guide has one deliberately absent row: "inbound limits with Resilience4j". It works for a whole endpoint on one
instance (protect /books/search from a burst), and the RateLimiter page shows how. For anything per client it is the wrong
tool, because each client would need its own limiter instance in an unbounded registry, and the limit would still multiply
with the replica count.
Using both together
In the Bookshelf the two libraries meet in three places.
Bucket4j inbound, Resilience4j outbound
The inbound API is protected by Bucket4j: a bucket per API key in Redis, so the free plan’s 100 requests per hour hold across every replica. The outbound calls (ISBN provider, pricing-service) are decorated with Resilience4j, as in the rest of this section. Nothing in either library knows about the other at this level; they are simply in different filters.
A CircuitBreaker and TimeLimiter around the ProxyManager
A Bucket4j bucket in Redis turns every inbound request into an outbound call to Redis. When Redis hangs, a request waits;
when it is down, a request fails. Bucket4j’s own requestTimeout bounds one call, but it does not remember that the last
hundred calls failed. Resilience4j does: a TimeLimiter bounds the wait, and a CircuitBreaker stops calling a store that
is failing, so that after a few slow requests the rest get an answer at once. What the answer is (let the request through
without a limit, or refuse it) is a policy, fail open or fail closed, and it belongs in your code, as described in
fallbacks and composition.
@Configuration(proxyBeanMethods = false)
class RateLimitStoreConfig {
/** The free plan of the Bookshelf API: 100 requests per hour and at most 10 per second. */
@Bean
BucketConfiguration freePlan() {
return BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, Duration.ofHours(1)).id("hour"))
.addLimit(limit -> limit.capacity(10).refillGreedy(10, Duration.ofSeconds(1)).id("second"))
.build();
}
@Bean(destroyMethod = "shutdown")
RedisClient bucket4jRedisClient(DataRedisProperties redis) { // Boot does not create a RedisClient bean
return RedisClient.create(RedisURI.builder().withHost(redis.getHost()).withPort(redis.getPort()).build());
}
@Bean
AsyncProxyManager<String> buckets(RedisClient bucket4jRedisClient) {
return Bucket4jLettuce.casBasedBuilder(bucket4jRedisClient)
.expirationAfterWrite(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
.requestTimeout(Duration.ofSeconds(1)) // last line of defence; the TimeLimiter fires first
.build()
.withMapper((String key) -> key.getBytes(StandardCharsets.UTF_8))
.asAsync();
}
@Bean(destroyMethod = "shutdown")
ScheduledExecutorService rateLimitTimeouts() {
return Executors.newScheduledThreadPool(2);
}
}
Source: Bucket4j reference: Lettuce, Asynchronous API and Expiration policy.
The asynchronous proxy is what makes the TimeLimiter useful: it returns a CompletableFuture, which the TimeLimiter can
complete with a TimeoutException and cancel. Around it, the CircuitBreaker records timeouts and Redis errors as failures,
while a refused consumption (a 429) is a normal result and never opens the breaker.
@Component
public class GuardedRateLimiter {
public enum FailureMode { OPEN, CLOSED }
private final AsyncProxyManager<String> buckets;
private final BucketConfiguration freePlan;
private final CircuitBreaker breaker;
private final TimeLimiter timeLimiter;
private final ScheduledExecutorService scheduler;
private final FailureMode failureMode;
GuardedRateLimiter(AsyncProxyManager<String> buckets, BucketConfiguration freePlan,
CircuitBreakerRegistry breakers, TimeLimiterRegistry timeLimiters,
ScheduledExecutorService rateLimitTimeouts,
@Value("${bookshelf.rate-limit.on-store-failure:open}") FailureMode failureMode) {
this.buckets = buckets;
this.freePlan = freePlan;
this.breaker = breakers.circuitBreaker("rate-limit-store");
this.timeLimiter = timeLimiters.timeLimiter("rate-limit-store");
this.scheduler = rateLimitTimeouts;
this.failureMode = failureMode;
}
public Decision check(String apiKey, long tokens) {
Supplier<CompletableFuture<ConsumptionProbe>> consume = () -> buckets.builder()
.build("api:" + apiKey, () -> CompletableFuture.completedFuture(freePlan))
.tryConsumeAndReturnRemaining(tokens);
try {
ConsumptionProbe probe = breaker
.executeCompletionStage(() -> timeLimiter.executeCompletionStage(scheduler, consume))
.toCompletableFuture()
.join();
return probe.isConsumed()
? Decision.allowed(probe.getRemainingTokens())
: Decision.rejected(probe.getNanosToWaitForRefill());
} catch (RuntimeException storeFailure) { // TimeoutException, CallNotPermittedException, Redis errors
log.warn("Rate-limit store unavailable ({}), failing {}", rootCause(storeFailure), failureMode);
return failureMode == FailureMode.OPEN ? Decision.unchecked() : Decision.unavailable();
}
}
}
Source: Resilience4j: CircuitBreaker, Resilience4j: TimeLimiter and Bucket4j reference: Asynchronous API.
Decision.rejected turns the nanoseconds into whole seconds, rounded up, for Retry-After. A servlet filter calls
check for every /api/** request and maps the four outcomes to responses:
Decision decision = limiter.check(apiKey, 1);
switch (decision.outcome()) {
case ALLOWED -> {
response.setHeader("X-Rate-Limit-Remaining", Long.toString(decision.remaining()));
chain.doFilter(request, response);
}
case UNCHECKED -> chain.doFilter(request, response); // fail open
case REJECTED -> {
response.setHeader("Retry-After", Long.toString(decision.retryAfterSeconds()));
problem(response, HttpStatus.TOO_MANY_REQUESTS, "Rate limit of your plan exceeded");
}
case UNAVAILABLE -> { // fail closed
response.setHeader("Retry-After", Long.toString(decision.retryAfterSeconds()));
problem(response, HttpStatus.SERVICE_UNAVAILABLE, "Rate limiting is temporarily unavailable");
}
}
Source: Bucket4j reference: rate limiting a REST API
and Spring Framework: Error Responses.
problem(…) writes an RFC 9457 ProblemDetail as application/problem+json; the complete filter, with key resolution and
the order relative to Spring Security, is on
manual Spring Boot integration.
bookshelf:
rate-limit:
on-store-failure: open # or closed
resilience4j:
timelimiter:
instances:
rate-limit-store:
timeout-duration: 200ms
cancel-running-future: true
circuitbreaker:
instances:
rate-limit-store:
sliding-window-type: COUNT_BASED
sliding-window-size: 4
minimum-number-of-calls: 4
failure-rate-threshold: 50
wait-duration-in-open-state: 2s
permitted-number-of-calls-in-half-open-state: 1
Source: Resilience4j: Spring Boot and Spring Boot configuration.
The window is tiny on purpose, so that the behaviour is visible in a test; in production use a time-based window of tens of seconds. Pausing the Redis container (a hung server, which a refused connection does not reproduce) and sending six requests gave:
| Request | Fail closed | Fail open |
|---|---|---|
First ones |
503 after 214 to 217 ms (the 200 ms |
200 after 204 to 218 ms, without |
After the breaker opened |
503 after 2 to 4 ms |
200 after 3 to 5 ms |
After Redis came back and 2 s passed |
200 with |
Not measured in this run; the breaker is configured identically |
How many slow requests it takes depends on what is already in the window: in the fail-closed run the four-call window already held two successful calls, so two timeouts reached the 50 % threshold; in the fail-open run it held one, and three timeouts were needed. Either way the slow phase is short and bounded, which is the point. A rejected consumption (429) counts as a success for the breaker, so a client hammering its quota never opens it.
Which policy to choose is the same decision as for any fallback: fail open for limits that protect cost or fairness and where a few minutes without limits is cheaper than an outage, fail closed for limits that protect security (login attempts, OTP) or money (a paid third-party quota). The trade-offs, and a third option that falls back to a local bucket per instance, are on manual Spring Boot integration; how to alert on a fail-open period is on Bucket4j observability.
Retry honouring Bucket4j’s Retry-After
The third meeting point is a client of a Bucket4j-limited API, for example a partner of the Bookshelf or one Bookshelf
service calling another. Bucket4j’s 429 carries Retry-After, and a Resilience4j Retry with an IntervalBiFunction can
read it, so the retry waits exactly as long as the server asked instead of guessing:
public class RetryAfterIntervalFunction implements IntervalBiFunction<Object> {
static final long MAX_WAIT_MILLIS = 10_000;
private final IntervalFunction backoff = IntervalFunction.ofExponentialRandomBackoff(
Duration.ofMillis(100), 2.0, 0.5, Duration.ofSeconds(2));
@Override
public Long apply(Integer attempt, Either<Throwable, Object> outcome) {
long computed = backoff.apply(attempt);
if (outcome.isLeft() && outcome.getLeft() instanceof HttpStatusCodeException e) {
HttpHeaders headers = e.getResponseHeaders();
String value = headers == null ? null : headers.getFirst(HttpHeaders.RETRY_AFTER);
long asked = value == null ? 0 : Long.parseLong(value.trim()) * 1000; // delta-seconds
if (asked > MAX_WAIT_MILLIS) {
return -1L; // too long: stop retrying
}
return Math.max(computed, asked);
}
return computed;
}
}
resilience4j:
retry:
instances:
bookshelf-api:
max-attempts: 3
retry-exceptions:
- org.springframework.web.client.HttpClientErrorException$TooManyRequests
interval-bi-function: compare.RetryAfterIntervalFunction
This version reads only the delta-seconds form, which is what every Bucket4j example in this section sends; the version on
Retry also parses the HTTP-date form, which a third-party API may use. With the bucket of
a key exhausted by ten requests, a client call through this retry got 429 with Retry-After: 1, waited, and succeeded
after 1 044 ms in total, with one retry. Two conditions keep this honest: the retry must stop when the server asks for longer
than the caller can wait (the -1), and only TooManyRequests is retried, never a 4xx that will not change.
The Bookshelf with both libraries
Putting the pieces together, the Bookshelf runs:
-
at the edge: the gateway’s Bucket4j filter with a coarse per-client limit, see Bucket4j in Spring Cloud Gateway
-
inbound in each replica: Spring Security, then the Bucket4j limiter above, with the free plan (100 per hour and 10 per second), the pro plan (5 000 per hour and 50 per second) and internal clients unlimited, buckets in Redis, search costing 5 tokens
-
the Redis calls of that limiter: a Resilience4j
TimeLimiterof 200 ms and aCircuitBreaker, failing open for the API plans -
outbound to the ISBN provider: one shared Bucket4j bucket
quota:isbnfor the daily quota (failing closed, because the quota costs money), then the Resilience4j chain of the section:RetryhonouringRetry-After,CircuitBreaker,TimeLimiter, with a per-instanceRateLimiterfor the provider’s per-second limit -
outbound to pricing-service: Resilience4j only, as in fallbacks and composition
The free plan, seen from a client. Ten requests in one burst pass with the remaining count going from 9 to 0; the eleventh
is refused for about 100 ms, the time the 10-per-second bandwidth needs for one token, which Retry-After rounds up to a
whole second:
curl -si -H 'X-API-Key: key-free-demo' http://localhost:8080/api/books # the 11th request within 100 ms
HTTP/1.1 429
Retry-After: 1
Content-Type: application/problem+json
Content-Length: 86
{"detail":"Rate limit of your plan exceeded","status":429,"title":"Too Many Requests"}
And the same request while Redis is paused, in fail-closed mode:
HTTP/1.1 503
Retry-After: 5
Content-Type: application/problem+json
Content-Length: 96
{"detail":"Rate limiting is temporarily unavailable","status":503,"title":"Service Unavailable"}
Source: Bucket4j reference: rate limiting a REST API
and RFC 9457: Problem Details for HTTP APIs. The responses were captured from the
scratch application on Spring Boot 4.1.1 with Redis 7. To send the burst from a shell, put the eleven URLs in one curl
invocation: separate curl processes take long enough each that the 10-per-second bandwidth refills between them.
The same idea in other stacks
Other stacks on this site have the same split between an in-process limiter, a shared-store limiter and the fault-tolerance patterns.
| Stack and library | How it compares | Where it is covered |
|---|---|---|
ASP.NET Core rate limiting middleware ( |
Fixed-window, sliding-window, token-bucket and concurrency limiters, partitioned by a key such as the user name, with a
configurable rejection status. Like Bucket4j it has per-key token buckets; like Resilience4j it keeps the state in the process.
Outbound fault tolerance is Polly and |
Performance and caching: rate limiting and HTTP client and resilience |
NestJS |
A counter per tracker (by default the client IP) that expires after |
Rate limiting and encryption and Resilience, idempotency and locks |
Quarkus, SmallRye Fault Tolerance |
|
|
Redis Lua token bucket |
The algorithm Bucket4j implements, written by hand: one Lua script reads the level, computes the refill and writes it back atomically. Shared across instances; you own clock handling, expiry and every edge case that Bucket4j’s backends already handle. |
|
Spring Cloud Gateway |
A token bucket in a Redis Lua script per key ( |
The broader mapping of the fault-tolerance patterns across stacks is in resilience fundamentals, and gateway-level limiting in a wider architecture in API gateway and BFF.
Pitfalls
| Pitfall | What happens | Fix |
|---|---|---|
Per-client API limits with the Resilience4j |
One limiter per client in an unbounded registry, and the limit multiplies with replicas |
Bucket4j buckets per key in a shared store |
A Resilience4j |
Three replicas call the provider three times as often as the quota allows |
A Bucket4j distributed bucket for the quota, a Resilience4j limiter only for the per-instance share |
"10 per second" with Resilience4j where bursts matter |
20 requests can pass within a millisecond at a cycle boundary |
A token bucket with a small capacity, or a short |
Bucket4j where a circuit breaker or retry is needed |
Bucket4j limits how often, not what happens when the call fails |
Resilience4j for the outbound call; the two compose |
A Bucket4j store call without a time bound |
A hung Redis makes every request wait for the client’s timeout |
|
No explicit policy for a store outage |
The exception becomes a 500, which is neither open nor closed |
A |
Counting 429 responses as breaker failures |
A client exhausting its quota opens the breaker for everybody |
Only store errors and timeouts are failures; a refused consumption is a result |
A client retrying a 429 on its own schedule |
Retries arrive before the bucket has refilled and earn more 429s |
A Resilience4j |
Stacking two Resilience4j limiters for "per hour and per second" |
A call refused by the second has already spent a permit of the first |
One Bucket4j bucket with two bandwidths, checked atomically |
Relying on |
The Spring aspect in 2.4.0 ignores |
|
What changed
-
Resilience4j 2.4.0 (the current release) added
resilience4j-spring-boot4; theRateLimitermodel is unchanged since 2.x. 2.3.0 added thepermitsattribute to@RateLimiter, which the Spring 6 aspect does not read (see RateLimiter). Spring Cloud 2025.1.3 still manages Resilience4j at 2.3.0, so importresilience4j-bombeforespring-cloud-dependencies. -
Bucket4j 8.21.0 (2 October 2026) added
bucket4j_jdk17-bom, new backends (Cassandra, Memcached, Ignite 3, Infinispan 15, Coherence 21) and the compare-and-swap retry settings; the builder API of 8.5.0 (addLimit(limit → …)) replacedBandwidth.simpleandRefill, and the artifacts arebucket4j_jdk17-*since the 8.11 line. -
Spring Cloud Gateway gained Bucket4j limiters on both servers (the WebFlux one announced with Spring Cloud 2025.0), next to the Redis
RequestRateLimiter; none of them is Resilience4j. -
Spring Framework 7 added
@ConcurrencyLimitand@Retryablein the core framework, but no rate limiter, so neither library is replaced: see Spring Framework native resilience. -
The community starter has no GA for Spring Boot 4.1 yet (0.20.0-RC1); 0.14.0 targets Spring Boot 4.0. See the starter page.
-
The places where the Bucket4j reference and the 8.21.0 code disagree are collected on migration and what’s changed.
References
-
Resilience4j: CircuitBreaker, Retry, Bulkhead, TimeLimiter and Cache
-
Bucket4j reference: Bandwidth, Refill types and Bucket configuration
-
Bucket4j reference: Bucket, Returning tokens, Blocking API and Configuration replacement
-
Bucket4j reference: Distributed features, ProxyManager, Asynchronous API, Request timeout, Max retries for CAS operations, Implicit configuration replacement, Expiration policy and Distributed checklist
-
Bucket4j reference: Adding the dependency and the 8.21.0 release notes
-
Bucket4j repository, including the Spring Boot starter section
-
bucket4j-spring-boot-starter (community project), its properties and monitoring
-
Spring Cloud Gateway: Web MVC rate limiter, WebFlux RequestRateLimiter and CircuitBreaker filter
-
RFC 6585, section 4: 429 Too Many Requests, RFC 9110: Retry-After and RFC 9457: Problem Details for HTTP APIs
-
Token bucket (background)