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.

Table 1. Method summary of the local bucket builder
Method Meaning

addLimit(Bandwidth)

Adds a prebuilt bandwidth

addLimit(Function<BandwidthBuilderCapacityStage, BandwidthBuilderBuildStage>)

Adds a bandwidth configured by a lambda: addLimit(limit → limit.capacity(20).refillGreedy(20, ofMinutes(1)))

withMillisecondPrecision()

Wall-clock time in milliseconds (the default, TimeMeter.SYSTEM_MILLISECONDS)

withNanosecondPrecision()

System.nanoTime() based time (TimeMeter.SYSTEM_NANOTIME); not wall-clock based

withCustomTimePrecision(TimeMeter)

Your own time source, for tests or special clocks

withSynchronizationStrategy(SynchronizationStrategy)

LOCK_FREE (default), SYNCHRONIZED or NONE

withListener(BucketListener)

Receives consumption events

build()

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.

Building the Bookshelf free-plan bucket
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.

Consuming a token for each request
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.

Weighted consumption on the search endpoint
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

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

isConsumed()

Whether the tokens were taken

getRemainingTokens()

Tokens left after the call (on a rejection, the tokens that were and still are available)

getNanosToWaitForRefill()

When rejected, the nanoseconds until enough tokens will have been refilled for this request; zero when consumed. A request that can never succeed reports Long.MAX_VALUE.

getNanosToWaitForReset()

Nanoseconds until the bucket is full again

Remaining tokens and time to refill
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

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

Adding tokens back and inspecting the state

Method Behaviour

addTokens(long)

Adds tokens up to the capacity: newTokens = min(capacity, currentTokens + tokensToAdd). The bucket never overflows.

forceAddTokens(long)

Adds tokens without the capacity cap, so the bucket can hold more than its capacity

reset()

Refills the bucket to its maximum capacity

getAvailableTokens()

The current token count. It may be negative after consumeIgnoringRateLimits. In a concurrent or distributed setting the value can be stale the moment it returns, so do not make decisions with it; use tryConsume.

consumeIgnoringRateLimits(long)

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.

Returning tokens when the downstream call fails
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.

Rate-limit headers from the verbose API
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

consume(long)

Waits as long as needed; throws InterruptedException

consumeUninterruptibly(long)

Waits as long as needed and ignores interruption (restoring the interrupt flag afterwards)

tryConsume(long, Duration)

Waits at most the given time. If the wait would be longer, returns false at once and takes nothing.

tryConsumeUninterruptibly(long, Duration)

The same without InterruptedException

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.

Waiting for a token, with and without a limit
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);

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 in consume(1) sees -1. This keeps callers in order.

  • consume can 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 returns false.

  • 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.

Delaying a task until a token is available
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

PROPORTIONALLY

new = before x (newCapacity / oldCapacity)

new capacity 200 gives 80; new capacity 20 gives 8

AS_IS

new = min(before, newCapacity)

200 gives 40; 20 gives 20

ADDITIVE

new = min(before, newCapacity) + max(0, newCapacity - oldCapacity)

200 gives 140; 20 gives 20

RESET

The old state is erased, the bucket starts as if newly built (full unless initialTokens says otherwise)

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.

Upgrading a client from the free plan to the pro plan
BucketConfiguration pro = BucketConfiguration.builder()
        .addLimit(limit -> limit.capacity(200).refillGreedy(200, Duration.ofMinutes(1)))
        .build();

bucket.replaceConfiguration(pro, TokensInheritanceStrategy.PROPORTIONALLY);

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 null id by default. A null id matches another null id 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.

Replacing a two-bandwidth configuration by id
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().

Counting consumed and rejected tokens
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 to other only; 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. tryConsume and tryConsumeAndReturnRemaining notify. estimateAbilityToConsume, getAvailableTokens and addTokens do not.

  • The blocking API notifies in a particular order: onConsumed first (the tokens are reserved), then beforeParking, then onParked once the wait is over; a timeout without a wait calls onRejected, and an interrupted wait calls onInterrupted and rethrows. The scheduling API calls onConsumed and then onDelayed.

  • 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(): true if the meter behaves like System.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:

A controllable clock for tests
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

LOCK_FREE (default)

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

SYNCHRONIZED

The synchronized keyword. Never allocates, but a thread preempted while holding the lock delays the others.

When allocation must be avoided and contention is modest

NONE

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.

Choosing a strategy
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.

sequenceDiagram participant C as Client participant F as Rate limit filter participant B as Bucket for the API key participant H as Controller C->>F: GET /books/search with X-API-Key F->>B: tryConsumeAndReturnRemaining(5) alt tokens available B-->>F: probe consumed, remaining tokens F->>H: continue the chain H-->>C: 200 with X-Rate-Limit-Remaining else bucket empty B-->>F: probe rejected, getNanosToWaitForRefill Note over F: retryAfter = ceil(nanos / 1e9) seconds F-->>C: 429 Too Many Requests with Retry-After end
Mapping a probe to the response
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 getAvailableTokens() to decide

The value can be stale immediately; two threads can both see tokens and both proceed to consume

Use tryConsume or the probe, which decide and consume atomically

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

tryConsume is always false, and the probe’s wait is Long.MAX_VALUE

Keep every weight at or below the smallest capacity

Truncating the wait to seconds

Retry-After: 0 for sub-second waits makes clients retry at once

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 toListenable to chain

The original listener stops receiving events for the decorated view

Compose listeners yourself

NONE strategy on a shared bucket

Corrupted state and over-consumption under concurrency

Use the default LOCK_FREE

Custom TimeMeter that does not implement isWallClockBased()

The reference example does not compile

Implement both methods

What changed

  • 8.5.0: addLimit(limit → …​) and the bandwidth builder replaced Bandwidth.simple/classic; see the token bucket page.

  • ADDITIVE is the fourth TokensInheritanceStrategy; older descriptions list three.

  • 8.17 to 8.21: no change to the local Bucket interface; the releases changed the distributed builders. See migration and what’s changed for the discrepancy table.