Bucket4j vs Resilience4j: Choosing a Java Rate Limiter

Published on
July 30, 2026

Search for a Java rate limiting library and two names come back: Bucket4j and Resilience4j. They are compared constantly, usually as though they were competing implementations of the same thing. They are not. One is a dedicated rate limiter built for distributed enforcement; the other is a rate limiter module inside a fault-tolerance library, deliberately scoped to a single JVM. Knowing which problem you have settles the choice faster than any feature table.

Why Teams Compare These Two

The comparison usually starts from one of two places.

Either Resilience4j is already in the project — added for circuit breakers around a flaky downstream — and someone notices it also ships a RateLimiter, which raises the obvious question of whether a second library is needed. Or the team goes looking for rate limiting specifically, finds Bucket4j at the top of every list, and wants to know whether the thing already on the classpath would have done the job.

Both are Apache 2.0 licensed, both are mature, and both are well built. The difference that matters is not quality. It is scope: what happens when your application runs on more than one node.

Bucket4j

Bucket4j is a dedicated token bucket implementation, currently at 8.19.0. A bucket holds a capacity of tokens refilled at a defined rate; each call consumes one or more, and a call that finds the bucket empty is refused.

Bucket bucket = Bucket.builder()
    .addLimit(limit -> limit.capacity(20).refillGreedy(10, Duration.ofMinutes(1)))
    .build();

if (bucket.tryConsume(1)) {
    doSomething();
} else {
    throw new SomeRateLimitingException();
}

Three things distinguish it.

First, multiple bandwidths on a single bucket. A bucket can carry more than one limit at once, so a short-term burst allowance and a long-term quota are enforced together by one object:

Bucket bucket = Bucket.builder()
    .addLimit(limit -> limit.capacity(100).refillGreedy(100, Duration.ofMinutes(1)))
    .addLimit(limit -> limit.capacity(1000).refillGreedy(1000, Duration.ofDays(1)))
    .build();

A caller gets 100 requests per minute and 1,000 per day, and both are checked on every consume. This is the single feature most often missed when teams move off Bucket4j, and it is genuinely awkward to reproduce with two independent limiters.

Second, integer arithmetic throughout. Bucket4j performs no floating-point calculation anywhere in its accounting, which removes a class of rounding drift that matters when a limit is contractual rather than advisory.

Third, a wide choice of distributed backends, covered below.

One thing to know before adopting it: Bucket4j is a library, not a framework. The popular bucket4j-spring-boot-starter, which configures limits through YAML with no code, is a separate community project rather than part of Bucket4j itself. That is not a criticism — it is well maintained and widely used — but it is a second dependency on a different release cycle, and worth knowing when you are evaluating what you are taking on.

Resilience4j's RateLimiter

Resilience4j is a fault-tolerance library. Its modules — CircuitBreaker, Retry, Bulkhead, TimeLimiter, RateLimiter — exist to protect a caller from a failing dependency, and they are designed to compose with each other.

The rate limiter divides time from the epoch into cycles of limitRefreshPeriod, and grants limitForPeriod permits in each cycle. A caller waits up to timeoutDuration for a permit before failing:

RateLimiterConfig config = RateLimiterConfig.custom()
    .limitForPeriod(10)
    .limitRefreshPeriod(Duration.ofSeconds(1))
    .timeoutDuration(Duration.ofMillis(25))
    .build();

RateLimiter limiter = RateLimiterRegistry.of(config).rateLimiter("flightSearch");

In Spring Boot it is configuration plus an annotation, with a fallback method for the rejected case:

resilience4j:
  ratelimiter:
    instances:
      flightSearch:
        limitForPeriod: 10
        limitRefreshPeriod: 1s
        timeoutDuration: 25ms
@RateLimiter(name = "flightSearch", fallbackMethod = "searchFallback")
public List<Flight> search(SearchRequest request) {
    return flightSearchService.search(request);
}

Note that the cycle model is a fixed window, not a token bucket: permits reset on cycle boundaries rather than refilling continuously. The default implementation, AtomicRateLimiter, is lock-free and recalculates permits on the calling thread; SemaphoreBasedRateLimiter is available as an alternative.

Where Resilience4j earns its place is composition. Rate limiting an outbound call is rarely the only protection that call needs — it usually wants a timeout, a retry with backoff, and a circuit breaker as well, applied in a defined order around the same method. Resilience4j does that in one coherent configuration. Bucket4j does not attempt it.

The Distributed Question

This is where the two part company, and it is the whole decision for most teams.

Resilience4j's rate limiter holds its state in memory, in one JVM. That is a deliberate scope decision rather than a gap — the library's own guidance is that server-side rate limiting needs coordination between instances and is a job for an API gateway or a purpose-built limiter. Run three replicas configured for 10 requests per second and the fleet admits 30, and the number changes every time the deployment scales.

For limiting your own outbound calls to a third-party API from a single service instance, that is fine. For enforcing a limit on inbound traffic across a cluster, it is not what the module is for.

Bucket4j handles this with a ProxyManager: the bucket's state moves out of the JVM into a shared store, and every instance reads and updates it atomically. The list of supported stores is long:

CategoryBackends
RedisRedisson, Lettuce, Jedis, Vert.x
ValkeyGlide
Data gridsHazelcast, Apache Ignite, Infinispan, Oracle Coherence, any JCache (JSR-107) provider
DatabasesPostgreSQL, MySQL, MariaDB, Oracle, SQL Server, DB2, MongoDB
OtherCouchbase, Caffeine (local only)

That breadth is Bucket4j's strongest argument. If your shared state lives in Hazelcast or PostgreSQL, Bucket4j gives you distributed rate limiting on top of infrastructure you already run, and nothing in this article is a reason to change that.

If You Run Bucket4j on Redis, You Already Run a Redis Client

Here is the part that gets skipped in most comparisons. Bucket4j does not talk to Redis directly. It talks through a Java Redis client that you choose and configure, and Redisson is one of the four supported options.

BackendAsyncRedis Cluster
RedissonYesYes
LettuceYesYes
Vert.xYesYes
JedisNoYes

Wiring Bucket4j to Redisson looks like this:

Config config = new Config();
// ... configure Redisson

Redisson redissonClient = (Redisson) Redisson.create(config);

RedissonBasedProxyManager<String> proxyManager = Bucket4jRedisson
    .casBasedBuilder(redissonClient.getCommandExecutor())
    .expirationAfterWrite(ExpirationAfterWriteStrategy
        .basedOnTimeForRefillingBucketUpToMax(Duration.ofSeconds(10)))
    .keyMapper(Mapper.STRING)
    .build();

BucketConfiguration configuration = BucketConfiguration.builder()
    .addLimit(limit -> limit.capacity(100).refillGreedy(100, Duration.ofMinutes(1)))
    .build();

Bucket bucket = proxyManager.builder().build("user:42", configuration);

if (bucket.tryConsume(1)) {
    // handle the request
}

Two details in that snippet are worth pausing on. The expirationAfterWrite strategy is not optional in practice — without it, a per-user bucket key has no TTL and the keyspace grows for as long as the service runs. And the cast to Redisson is required because getCommandExecutor() is not part of the RedissonClient interface; it belongs to the implementation class. This is what Bucket4j's own reference documentation prescribes, so it is supported rather than a hack — but it does couple your setup to a Redisson internal, which is worth knowing before you upgrade either library.

Choosing between the four is the same decision as choosing a Redis client generally — a topic we have covered in Jedis vs Lettuce. The short version: Jedis is the only one without async support, which matters here because a blocking call to Redis on every rate-limited request ties up an application thread for the round trip.

One asymmetry is worth flagging if you are on Valkey rather than Redis: Bucket4j's Valkey support goes through GLIDE, not through Redisson. A Valkey shop standardised on Redisson cannot use it as a Bucket4j backend today and would be adding a second client to the application.

When You Do Not Need Bucket4j At All

Follow the dependency chain of a typical Bucket4j-on-Redis setup and it reads: your application, Bucket4j, a Bucket4j Redis adapter, a Redis client, Redis. The rate limiter is a layer on top of a client that is already there.

If that client is Redisson, the rate limiter is already there too. RRateLimiter ships in the open-source edition and keeps its state in Redis or Valkey, so one limit holds across every JVM sharing the key:

RRateLimiter limiter = redisson.getRateLimiter("api:user:42");
limiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.MINUTES);

if (limiter.tryAcquire()) {
    // handle the request
} else {
    // over the limit — reject with 429
}

RateType.OVERALL shares one allowance across the fleet; PER_CLIENT gives each Redisson instance its own. To limit individual users, give each one its own named limiter and keep the type as OVERALL — the key makes the limit per-user, the type keeps it cluster-wide. A keepAliveTime overload lets idle limiters expire so per-user keys do not accumulate.

Redisson also offers RGcra, a limiter built on GCRA and the native Redis GCRA command, which returns the exact wait before a retry would succeed — the value you want for a Retry-After header, where counting algorithms can only estimate. It requires Redis 8.8 or later and is not available on Valkey.

Two honest limitations against Bucket4j. RRateLimiter configures one rate per limiter, so the burst-plus-daily-quota pattern needs two limiters and two checks rather than one bucket carrying both. And it does not guarantee fairness — threads are not queued in arrival order — which is documented, and rarely matters for HTTP request limiting but might for job scheduling.

What you gain is one fewer dependency, one fewer layer between the request and the decision, and the same client already handling your locks, caches and queues. For a full walkthrough including a reusable Spring Boot annotation, see distributed rate limiting in Java with Valkey or Redis and Spring Boot.

Comparison at a Glance

Bucket4jResilience4j RateLimiterRedisson
AlgorithmToken bucketFixed-window cyclesAverage rate, plus GCRA via RGcra
DistributedYes, via ProxyManagerNo — single JVMYes, natively
BackendsRedis, Valkey, data grids, RDBMS, MongoDBn/aRedis, Valkey
Multiple limits per bucketYesNoNo — one rate per limiter
Exact retry-afterNoNoYes, with RGcra
Composes with retry / circuit breakerNoYesNo
Spring config-only setupVia third-party starterBuilt inWrite an AOP aspect
Also provides locks, caches, queuesNoNoYes
Extra dependency over your Redis clientYesn/aNo

How to Choose

Work from your situation, not from the feature list:

  • Limiting your own outbound calls from one service, with Resilience4j already present. Use Resilience4j. Adding a library to do what the one on your classpath already does, alongside the retry and circuit breaker that call also needs, is not a good trade.
  • Enforcing a limit across a cluster, with shared state in Hazelcast, Ignite, PostgreSQL or MongoDB. Use Bucket4j. It is the only one of the three that works there.
  • Enforcing a limit across a cluster, on Redis, with no Redis client chosen yet. Either works. Take Bucket4j if you need several limits on one bucket; take Redisson if you would rather not add a layer.
  • Already running Redisson for locks, caches or queues. Use RRateLimiter. The rate limiter is already on your classpath.
  • A contractual quota where callers need an accurate Retry-After. Use RGcra, on Redis 8.8 or later.
  • Rate limiting inbound public traffic at scale. Consider whether it belongs in the application at all. An API gateway or edge service handles it before the request reaches your JVM; application-level limiting is a complement to that, not a replacement.

One point outranks all of these. A limiter that is correct under concurrency and wrong about its algorithm will serve you better than the reverse. All three libraries here get the atomicity right. The failure mode to avoid is the hand-rolled counter that looked simpler than adding a dependency.

Frequently Asked Questions

What is the difference between Bucket4j and Resilience4j?

Bucket4j is a dedicated token bucket rate limiter that supports distributed enforcement across a cluster through a ProxyManager over Redis, data grids or a database. Resilience4j is a fault-tolerance library whose RateLimiter module works within a single JVM and is designed to compose with its circuit breaker, retry and bulkhead. Use Resilience4j for protecting outbound calls from one instance, Bucket4j when the limit must hold across several.

Does Resilience4j support distributed rate limiting?

No. Its rate limiter keeps state in memory in a single JVM, and the project's guidance is that cluster-wide enforcement is a job for an API gateway or a purpose-built distributed limiter. Running several replicas means each enforces its own copy of the limit, so the fleet admits the configured rate multiplied by the instance count.

Can Bucket4j use Redis?

Yes, through four supported clients: Redisson, Lettuce, Vert.x and Jedis. All four support Redis Cluster; Jedis is the only one without async support, which means a blocking round trip on every rate-limited request. Valkey is supported separately through GLIDE.

Is Bucket4j free?

Yes. Bucket4j is licensed under the Apache Software License 2.0, with no paid tier for the library itself. Resilience4j is also Apache 2.0.

Do I need Bucket4j if I already use Redisson?

Usually not. Redisson's RRateLimiter ships in the open-source edition and provides distributed rate limiting directly, so Bucket4j on a Redisson backend adds a layer on top of a client already in your application. The case for keeping Bucket4j is if you rely on multiple limits enforced by a single bucket, such as a per-minute burst and a per-day quota together, which RRateLimiter does not express in one object.

Redisson provides distributed rate limiters, locks, caches and queues through one Java API across Valkey and Redis. See the feature comparison between Redisson and Redisson PRO, or start a free trial.