Bucket4j: The Bucket API
|
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. |
Bucket is the interface everything else in Bucket4j is built around. A bucket is a configuration (the bandwidths from
the previous page) plus a mutable state (the token count
and the time of the last refill). This page walks through the whole API surface of a local bucket: how to build one,
every way to consume or add tokens, the blocking and asynchronous views, replacing the configuration of a live bucket,
listeners, the clock and the synchronization strategy. Distributed buckets, reached through a ProxyManager, expose the
same consumption methods; see distributed rate limiting.
It was written against Bucket4j 8.21.0, Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21 and verified on
4 October 2026. The examples that exercise a bucket were run against 8.21.0, with a controllable clock where the result depends on time.
One thing to know first: there is no Bucket.consume() method, which some older descriptions imply. Non-blocking
consumption is tryConsume; waiting is asBlocking().consume(…), and asynchronous waiting is asScheduler().
Building a local bucket
Bucket.builder() returns a LocalBucketBuilder. It collects limits and options, and build() creates the bucket.
| Method | Meaning |
|---|---|
|
Adds a prebuilt bandwidth |
|
Adds a bandwidth configured by a lambda: |
|
Wall-clock time in milliseconds (the default, |
|
|
|
Your own time source, for tests or special clocks |
|
|
|
Receives consumption events |
|
Creates the bucket |
A configuration can also be built on its own, which matters for configuration replacement:
BucketConfiguration.builder().addLimit(…).build(). A BucketConfiguration is immutable.
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(20).refillGreedy(20, Duration.ofMinutes(1)))
.build();
Source: Bucket4j reference: Local bucket builder and
BucketConfiguration. The reference sometimes shows
Bandwidth.builder(limit → …); that overload does not exist in 8.21.0, and addLimit(limit → …) on the bucket or
configuration builder is the form that compiles.
Consuming tokens
tryConsume
boolean tryConsume(long numTokens) takes the tokens if they are all available and returns true; otherwise it takes
nothing and returns false. It never blocks. The token count must be positive, and a request for more tokens than the
bucket’s capacity can never succeed: it simply returns false every time.
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(20).refillGreedy(20, Duration.ofMinutes(1)))
.build();
if (bucket.tryConsume(1)) {
// handle the request
} else {
// answer 429
}
Source: Bucket4j reference: Limiting the rate of access to a REST API.
The reference example uses javax.servlet.Filter; on Spring Boot 4 the interface is jakarta.servlet.Filter.
Weighted consumption
An operation may cost more than one token. The Bookshelf search endpoint is five times more expensive than a lookup, so it consumes five tokens from the same bucket. A free-plan bucket of 20 tokens then serves 20 lookups or four searches, or any mix that adds up to 20.
int searches = 0;
while (bucket.tryConsume(5)) { // each search costs 5 tokens
searches++;
}
// searches == 4 on a full bucket of 20; bucket.tryConsume(1) is false afterwards
Source: Bucket4j reference: Bucket.
Weights larger than the capacity are a configuration bug: tryConsume(21) on a bucket of 20 returns false and the probe
variant reports Long.MAX_VALUE nanoseconds to wait, which an HTTP layer would translate into a nonsensical
Retry-After. Check that the largest weight is not above the smallest capacity of the plans.
tryConsumeAndReturnRemaining and ConsumptionProbe
tryConsumeAndReturnRemaining(long numTokens) consumes like tryConsume but returns a ConsumptionProbe with the
information an HTTP response needs.
ConsumptionProbe method |
Meaning |
|---|---|
|
Whether the tokens were taken |
|
Tokens left after the call (on a rejection, the tokens that were and still are available) |
|
When rejected, the nanoseconds until enough tokens will have been refilled for this request; zero when consumed. A request
that can never succeed reports |
|
Nanoseconds until the bucket is full again |
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(20).refillGreedy(20, Duration.ofMinutes(1)))
.build();
bucket.tryConsume(5);
ConsumptionProbe ok = bucket.tryConsumeAndReturnRemaining(1);
// ok.isConsumed() == true, ok.getRemainingTokens() == 14, ok.getNanosToWaitForRefill() == 0
// ok.getNanosToWaitForReset() == 18_000_000_000L (6 tokens missing, 3 seconds each)
bucket.tryConsumeAsMuchAsPossible(); // drain the bucket
ConsumptionProbe no = bucket.tryConsumeAndReturnRemaining(5);
// no.isConsumed() == false, no.getRemainingTokens() == 0
// no.getNanosToWaitForRefill() == 15_000_000_000L: five tokens at three seconds each
// no.getNanosToWaitForReset() == 60_000_000_000L
estimateAbilityToConsume
estimateAbilityToConsume(long numTokens) answers "could I consume this many tokens now?" without consuming. It returns an
EstimationProbe with canBeConsumed(), getRemainingTokens() and getNanosToWaitForRefill(). Use it to decide before
doing expensive work, for example to refuse a search before parsing its body, and to give a client a warning header when
the bucket runs low. Estimation does not notify the listener and does not change the bucket.
EstimationProbe estimate = bucket.estimateAbilityToConsume(20);
// with 14 tokens left: canBeConsumed() == false, getNanosToWaitForRefill() == 18_000_000_000L
bucket.getAvailableTokens(); // still 14
Source: Bucket source at 8.21.0.
tryConsumeAsMuchAsPossible
tryConsumeAsMuchAsPossible() takes every available token and returns how many it took, which may be zero.
tryConsumeAsMuchAsPossible(long limit) takes at most limit. It suits batch jobs that process "as many items as the quota
allows now", such as sending as many queued notifications as the current budget covers.
long taken = bucket.tryConsumeAsMuchAsPossible(10); // 10 when 14 were available
long rest = bucket.tryConsumeAsMuchAsPossible(); // 4
long none = bucket.tryConsumeAsMuchAsPossible(); // 0
Source: Bucket source at 8.21.0.
Adding tokens back and inspecting the state
| Method | Behaviour |
|---|---|
|
Adds tokens up to the capacity: |
|
Adds tokens without the capacity cap, so the bucket can hold more than its capacity |
|
Refills the bucket to its maximum capacity |
|
The current token count. It may be negative after |
|
Takes tokens even when there are not enough, which can drive the count negative. It returns the rate-limit violation in nanoseconds (zero when there was none). It exists for high-priority operations that must run but should still count against later requests. |
Returning tokens is the compensating-transaction pattern: consume first, do the work, and give the tokens back if the work failed for a reason that should not cost the client quota.
if (bucket.tryConsume(5)) {
try {
searchBooks(query);
} catch (UpstreamUnavailableException e) {
bucket.addTokens(5); // the client should not pay for our failure
throw e;
}
}
bucket.forceAddTokens(5); // a goodwill credit above the capacity
bucket.reset(); // back to a full bucket
Source: Bucket4j reference: Returning tokens back to the bucket.
In a test with a bucket of capacity 10 and 10 tokens, addTokens(5) leaves 10, forceAddTokens(5) raises the count to 15,
and after consumeIgnoringRateLimits(4) on an empty bucket getAvailableTokens() is -4.
The verbose API
bucket.asVerbose() returns a view with the same methods whose results are wrapped in a VerboseResult: the value plus a
snapshot of the configuration and diagnostics taken at that moment. It is meant for headers and logs that need more than the
probe offers, such as the configured limit and the time to full refill.
VerboseResult<ConsumptionProbe> result = bucket.asVerbose().tryConsumeAndReturnRemaining(1);
long limit = Arrays.stream(result.getConfiguration().getBandwidths())
.mapToLong(Bandwidth::getCapacity)
.max().getAsLong();
long remaining = result.getDiagnostics().getAvailableTokens();
long secondsToFull = TimeUnit.NANOSECONDS.toSeconds(result.getDiagnostics().calculateFullRefillingTime());
ConsumptionProbe probe = result.getValue();
Source: Bucket4j reference: Verbose/Debug API.
The same asVerbose() exists on the blocking and scheduling views (asBlocking().asVerbose(), asScheduler().asVerbose()).
Primitive results are boxed (VerboseResult<Boolean>, VerboseResult<Long>) and void becomes VerboseResult<Void>.
The blocking API
bucket.asBlocking() waits for tokens instead of failing. It is a BlockingBucket with these methods:
| Method | Behaviour |
|---|---|
|
Waits as long as needed; throws |
|
Waits as long as needed and ignores interruption (restoring the interrupt flag afterwards) |
|
Waits at most the given time. If the wait would be longer, returns |
|
The same without |
Each has overloads that accept a BlockingStrategy (for the interruptible methods) or an UninterruptibleBlockingStrategy
(for the others) to replace the default PARKING strategy, which uses LockSupport.parkNanos. A strategy has a single
method, park(long nanosToPark) or parkUninterruptibly(long nanosToPark), so it can be a lambda that sleeps differently,
for example in a test.
Bucket bucket = Bucket.builder()
.withNanosecondPrecision()
.addLimit(limit -> limit.capacity(1).refillIntervally(1, Duration.ofMillis(200)))
.build();
bucket.tryConsume(1);
bucket.asBlocking().consume(1); // parks for about 200 ms
boolean ok = bucket.asBlocking().tryConsume(1, Duration.ofMillis(10)); // false at once: the wait would exceed 10 ms
boolean ok2 = bucket.asBlocking().tryConsumeUninterruptibly(1, Duration.ofSeconds(1)); // true after the refill
BlockingStrategy custom = nanos -> Thread.sleep(nanos / 1_000_000, (int) (nanos % 1_000_000));
bucket.asBlocking().tryConsume(1, Duration.ofSeconds(1), custom);
Source: Bucket4j reference: Blocking API example and the
BlockingBucket source.
Three behaviours matter in practice:
-
The tokens are taken before the wait. The blocking view reserves the tokens, then parks. During the wait the bucket already reports the debt, and a second thread that asks for a token queues behind the first. A test that watches
getAvailableTokens()while another thread is parked inconsume(1)sees-1. This keeps callers in order. -
consumecan wait for more than the capacity. Because it reserves,consume(21)on a bucket of capacity 20 does not fail: it waits until the debt is paid.tryConsume(21)on the same bucket returnsfalse. -
It blocks the calling thread. On a servlet request thread that means one pool thread per waiting client, which is how a rate limiter turns into an outage. Use the blocking API in batch jobs, scheduled tasks and client-side throttling (the ISBN quota), or on virtual threads, where parking is cheap; the Resilience4j side of that discussion is in Virtual threads and @ConcurrencyLimit. A server should answer 429 rather than wait.
The scheduling API
bucket.asScheduler() returns a SchedulingBucket: instead of parking a thread, it completes a CompletableFuture after
the wait, using a ScheduledExecutorService you supply.
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
CompletableFuture<Boolean> permitted =
bucket.asScheduler().tryConsume(1, Duration.ofSeconds(1), scheduler);
// not done yet: the future completes with true after the refill
// asScheduler().tryConsume(1, Duration.ofMillis(5), scheduler) completes immediately with false
CompletableFuture<Void> eventually = bucket.asScheduler().consume(1, scheduler);
Source: Bucket4j reference: basic features.
The tokens are reserved immediately, as with the blocking view; the listener gets onDelayed(nanos) instead of
onParked. This is the right shape for reactive code and for delaying outbound messages, as long as the executor is not the
thread that serves requests.
Replacing the configuration of a live bucket
replaceConfiguration(BucketConfiguration, TokensInheritanceStrategy) swaps the limits of an existing bucket, for example
when a client upgrades from the free plan to the pro plan, or when you tune limits at run time. The difficulty is the state:
what happens to the tokens the bucket holds? The strategy decides.
TokensInheritanceStrategy |
Formula | Example: capacity 100, 40 tokens available |
|---|---|---|
|
|
new capacity 200 gives 80; new capacity 20 gives 8 |
|
|
200 gives 40; 20 gives 20 |
|
|
200 gives 140; 20 gives 20 |
|
The old state is erased, the bucket starts as if newly built (full unless |
200 gives 200; 20 gives 20 |
There are four strategies. A bucket that used 60 of 100 tokens was run through every row above to produce those numbers
(the reference shows the same examples). PROPORTIONALLY preserves the fraction of the quota already used, which suits plan
changes. AS_IS keeps the absolute tokens and only clips. ADDITIVE rewards an upgrade: the extra capacity is available
at once. RESET is a clean start, equivalent to removing the bucket and creating it again.
BucketConfiguration pro = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(200).refillGreedy(200, Duration.ofMinutes(1)))
.build();
bucket.replaceConfiguration(pro, TokensInheritanceStrategy.PROPORTIONALLY);
Source: Bucket4j reference: On-the-fly configuration replacement and
TokensInheritanceStrategy explanation. The strategy
formulas and examples are also in the
TokensInheritanceStrategy Javadoc.
Bandwidth ids decide which old bandwidth is which
With one bandwidth, the old and the new one are simply paired. With several, Bucket4j needs to know which new bandwidth
replaces which old one. Pairing by position would give results that depend on the order of the lines, so the rule is:
a bandwidth is matched by its id.
-
A bandwidth has a
nullid by default. Anullid matches anothernullid only if exactly one bandwidth in the bucket has none. -
Ids must be unique within a bucket (duplicates are rejected with an
IllegalArgumentException). -
With multiple bandwidths, give them ids if you plan to replace configurations.
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(10, Duration.ofSeconds(1)).id("technical-limit"))
.addLimit(limit -> limit.capacity(10_000).refillGreedy(10_000, Duration.ofHours(1)).id("business-limit"))
.build();
BucketConfiguration newConfiguration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, Duration.ofSeconds(10)).id("technical-limit"))
.addLimit(limit -> limit.capacity(5_000).refillGreedy(5_000, Duration.ofHours(1)).id("business-limit"))
.build();
bucket.replaceConfiguration(newConfiguration, TokensInheritanceStrategy.PROPORTIONALLY);
A trap found by running this: the match is by id on both sides. Replacing a bandwidth without an id by one that has an id
(or the reverse) is treated as replacing one bandwidth by a different one, so no tokens are inherited. A bucket with capacity(20)
and 15 tokens left, replaced with PROPORTIONALLY by capacity(40)….id("plan"), ended with 40 tokens. The same replacement
without the id on the new bandwidth ended with 30 (15 x 40/20). Use the same id, or none, on both sides.
Listening to bucket events
A BucketListener observes consumption. It has these methods: onConsumed(long tokens), onRejected(long tokens),
onDelayed(long nanos) (the scheduling API), onParked(long nanos) and beforeParking(long nanos) (the blocking API; the
second has a default empty implementation) and onInterrupted(InterruptedException). BucketListener.NOPE ignores everything.
SimpleBucketListener is a ready-made implementation that counts: getConsumed(), getRejected() (both are token
totals, not call counts), getDelayedNanos(), getParkedNanos() and getInterrupted().
SimpleBucketListener listener = new SimpleBucketListener();
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(2).refillGreedy(2, Duration.ofMinutes(1)))
.withListener(listener)
.build();
bucket.tryConsume(1);
bucket.tryConsume(1);
bucket.tryConsume(1); // rejected
// listener.getConsumed() == 2, listener.getRejected() == 1
Source: Bucket4j reference: Listening for bucket events. Wiring a listener to Micrometer is covered on Bucket4j observability.
Corner cases, from the reference and from running them:
-
One listener per application or one per bucket. A single listener gives aggregate statistics; one per bucket gives per-bucket statistics.
-
The listener always runs on the client side. With distributed buckets, each JVM keeps its own statistics for the same bucket, because the listener cannot be serialized to Redis or a database. Aggregate in the monitoring system.
-
toListenable(listener)replaces the listener, it does not add to it.bucket.toListenable(other)returns a view of the same bucket state whose events go tootheronly; the listener given to the builder is not called for operations on that view. It suits a per-request listener on a shared bucket. -
Only consumption notifies.
tryConsumeandtryConsumeAndReturnRemainingnotify.estimateAbilityToConsume,getAvailableTokensandaddTokensdo not. -
The blocking API notifies in a particular order:
onConsumedfirst (the tokens are reserved), thenbeforeParking, thenonParkedonce the wait is over; a timeout without a wait callsonRejected, and an interrupted wait callsonInterruptedand rethrows. The scheduling API callsonConsumedand thenonDelayed. -
Listeners should be fast and must not throw. They run inline on the calling thread.
Time: TimeMeter
A TimeMeter is the bucket’s clock. It has two methods:
-
long currentTimeNanos(): the current time in nanoseconds, with arbitrary origin unless wall-clock based. -
boolean isWallClockBased():trueif the meter behaves likeSystem.currentTimeMillis(), meaning that machines in a cluster agree on it. Aligned refills require it.
Two implementations ship: TimeMeter.SYSTEM_MILLISECONDS (wall clock, the local default) and TimeMeter.SYSTEM_NANOTIME (not
wall clock). The reference shows a custom example that implements only currentTimeNanos(); that does not compile against
8.21.0, because isWallClockBased() has no default. A controllable clock is the standard way to test limits without sleeping:
class ManualClock implements TimeMeter {
private final AtomicLong nanos = new AtomicLong();
@Override
public long currentTimeNanos() {
return nanos.get();
}
@Override
public boolean isWallClockBased() {
return false;
}
void advance(Duration duration) {
nanos.addAndGet(duration.toNanos());
}
}
ManualClock clock = new ManualClock();
Bucket bucket = Bucket.builder()
.withCustomTimePrecision(clock)
.addLimit(limit -> limit.capacity(10).refillGreedy(10, Duration.ofMinutes(1)))
.build();
bucket.tryConsume(10);
bucket.tryConsumeAndReturnRemaining(1).getNanosToWaitForRefill(); // 6_000_000_000L: exactly six seconds
clock.advance(Duration.ofSeconds(30));
bucket.getAvailableTokens(); // 5
Source: Bucket4j reference: A custom time measurement strategy,
corrected against the TimeMeter source.
A custom meter that returns false cannot be combined with an aligned refill. Time in distributed buckets, and the
clientClock setting that supplies it, is discussed on distributed rate limiting.
The full testing story is on Bucket4j testing.
Synchronization strategy and thread safety
A local Bucket is safe to share between threads, according to the SynchronizationStrategy chosen at build time. The enum is
in io.github.bucket4j.local and is documented only in the Javadoc.
| Strategy | How it works | When to use |
|---|---|---|
|
Compare-and-swap on an immutable state object. Threads never block each other, at the price of one object allocation per operation. |
When you are unsure, and under high contention |
|
The |
When allocation must be avoided and contention is modest |
|
No synchronization at all |
Only if the bucket is confined to one thread or you provide the synchronization; a bucket used concurrently is corrupted |
A stress test with eight threads trying 500 tryConsume(1) calls each on a bucket of 1,000 tokens (and an hourly refill)
yields exactly 1,000 successes with both LOCK_FREE and SYNCHRONIZED: there is no over-consumption. Distributed buckets
do not use this setting; their atomicity comes from the backend, which is the subject of
Bucket4j backends.
Bucket bucket = Bucket.builder()
.withSynchronizationStrategy(SynchronizationStrategy.SYNCHRONIZED)
.addLimit(limit -> limit.capacity(1000).refillGreedy(1000, Duration.ofMinutes(1)))
.build();
Source: SynchronizationStrategy Javadoc at 8.21.0.
The MathType enum mentioned in older material is experimental and has a single value, INTEGER_64_BITS; nothing in the
public builders asks you to choose it.
From a rejected probe to HTTP 429
The probe carries what an HTTP layer needs. The wait time converts to the Retry-After header, and the bucket’s remaining
tokens to an informational header. Retry-After takes whole seconds, so round up: truncating 0.4 seconds to 0 tells clients
to retry immediately.
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(tokens);
if (probe.isConsumed()) {
response.setHeader("X-Rate-Limit-Remaining", String.valueOf(probe.getRemainingTokens()));
} else {
long retryAfterSeconds = Math.max(1, Math.ceilDiv(probe.getNanosToWaitForRefill(), 1_000_000_000L));
response.setStatus(429);
response.setHeader("Retry-After", String.valueOf(retryAfterSeconds));
}
Source: Bucket4j reference: Limiting the rate of access to a REST API
and RFC 9110: Retry-After. For a bucket that is 15 seconds short, the value is
15; for 15.000000001 seconds it is 16. A complete filter with RFC 9457 ProblemDetail bodies is on
manual Spring Boot integration. The
Resilience4j equivalent, mapping RequestNotPermitted, is in RateLimiter.
Pitfalls
| Pitfall | What happens | Fix |
|---|---|---|
Using |
The value can be stale immediately; two threads can both see tokens and both proceed to consume |
Use |
Blocking consumption on request threads |
Threads park while waiting, and the pool exhausts under a burst |
Answer 429 on servers; block only in batch jobs, client-side throttling or virtual threads |
Weights above the capacity |
|
Keep every weight at or below the smallest capacity |
Truncating the wait to seconds |
|
Round up and use at least 1 |
Replacing configuration without ids on multi-bandwidth buckets |
Bandwidths are matched by id only, never by position: unmatched bandwidths inherit nothing and restart from their initial tokens |
Give each bandwidth a stable id, used on both sides |
Expecting |
The original listener stops receiving events for the decorated view |
Compose listeners yourself |
|
Corrupted state and over-consumption under concurrency |
Use the default |
Custom |
The reference example does not compile |
Implement both methods |
What changed
-
8.5.0:
addLimit(limit → …)and the bandwidth builder replacedBandwidth.simple/classic; see the token bucket page. -
ADDITIVEis the fourthTokensInheritanceStrategy; older descriptions list three. -
8.17 to 8.21: no change to the local
Bucketinterface; the releases changed the distributed builders. See migration and what’s changed for the discrepancy table.
References
-
Bucket4j reference: Limiting the rate of access to a REST API
-
Bucket4j reference: On-the-fly configuration replacement and TokensInheritanceStrategy explanation
-
Bucket4j reference: Listening for bucket events and listener API corner cases
-
Bucket4j core sources at 8.21.0 (
Bucket,ConsumptionProbe,EstimationProbe,BlockingBucket,SchedulingBucket,BucketListener,TimeMeter)