Bucket4j: Spring Cloud Gateway

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.

A gateway is the first place a request meets a limit, and the cheapest place to refuse it: the request never reaches a service, a thread pool or a database. Spring Cloud Gateway ships a rate limiter built on Bucket4j for both of its stacks. This page covers the Web MVC one (Bucket4jFilterFunctions.rateLimit), the WebFlux one (the Bucket4j rate limiter of the RequestRateLimiter filter), what they need from the application (an AsyncProxyManager bean and a Bucket4j version that nobody manages for you), how they differ from the Redis RequestRateLimiter that has existed for years, and how an edge limit and an in-service limit divide the work. The Bucket4j concepts it relies on are in token bucket and bandwidths and distributed rate limiting.

It was written against Bucket4j 8.21.0, Spring Boot 4.1.1, Spring Framework 7.0.9, Spring Cloud 2025.1.3 (Spring Cloud Gateway 5.0.3) and Java 21, and verified on 4 October 2026. Every example on it was run: the Web MVC gateway against a stub bookshelf-service in the same JVM, two gateway instances sharing one Redis 7 in a Testcontainers container, and the WebFlux gateway with the YAML shown here, so the status codes and headers quoted are the observed ones. The numbers are deliberately small (three requests per minute) so that a test can exhaust them; the filter does not care about the size.

Which limiter is on which gateway

Spring Cloud Gateway has two server stacks, and each has its own rate-limiting entry point. They share the idea (a key resolver decides who, a limiter decides whether) and little else.

Table 1. Gateway rate limiters at Gateway 5.0.3
Gateway stack Entry point Algorithm and store Configured in

Web MVC (spring-cloud-starter-gateway-server-webmvc)

Bucket4jFilterFunctions.rateLimit(…​), a HandlerFilterFunction

Bucket4j token bucket in whatever backend the AsyncProxyManager bean uses

Java DSL only; no property binding

WebFlux (spring-cloud-starter-gateway-server-webflux)

RequestRateLimiter filter with the Bucket4j RateLimiter (bucket4j-rate-limiter.* arguments)

Bucket4j token bucket in whatever backend the AsyncProxyManager bean uses

YAML, properties or the Java DSL

WebFlux

RequestRateLimiter filter with RedisRateLimiter (redis-rate-limiter.* arguments)

A token bucket evaluated by a Lua script in Redis

YAML, properties or the Java DSL

The Web MVC filter and the WebFlux limiter both consume a Bucket4j AsyncProxyManager, which is why the backend is a free choice: a local Caffeine cache for one gateway instance and for tests, Redis through Lettuce when several gateway replicas must share a limit, or any other backend from the backends page that supports the asynchronous API. The community Spring Boot starter described on the starter page no longer carries a gateway filter (it was removed in 0.20.0-RC1; 0.14.0 still has it), so on a gateway the native filter below is the supported route.

Dependencies and version alignment

Spring Cloud Gateway uses Bucket4j but does not bring it. In both gateway modules bucket4j_jdk17-core is declared as an <optional> dependency, which Maven does not pass on to the application, so the application declares Bucket4j itself, together with the module of the backend it picks. Nothing manages that version for you either: neither spring-boot-dependencies 4.1.1, nor spring-cloud-dependencies 2025.1.3, nor spring-cloud-gateway-dependencies 5.0.3 mentions Bucket4j. Gateway 5.0.3’s own root pom has <bucket4j.version>8.15.0</bucket4j.version> and manages bucket4j_jdk17-core and -caffeine there, so "Spring Cloud Gateway manages Bucket4j 8.15.0" is true of the gateway’s own build and of nothing a consumer imports. Importing bucket4j_jdk17-bom 8.21.0 is what decides the version; the dependency tree of the application below showed bucket4j_jdk17-core:8.21.0, bucket4j_jdk17-caffeine:8.21.0 and Caffeine 3.2.4 (the version Spring Boot manages).

pom.xml of the Web MVC gateway
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.bucket4j</groupId>
            <artifactId>bucket4j_jdk17-bom</artifactId>
            <version>8.21.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>2025.1.3</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway-server-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>com.bucket4j</groupId>
        <artifactId>bucket4j_jdk17-core</artifactId>
    </dependency>
    <!-- the backend of the AsyncProxyManager: Caffeine here, Lettuce further down -->
    <dependency>
        <groupId>com.bucket4j</groupId>
        <artifactId>bucket4j_jdk17-caffeine</artifactId>
    </dependency>
    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
    </dependency>
</dependencies>

Source: Bucket4j reference: Adding the Bucket4j dependency and the Gateway Web MVC pom at 5.0.3 (Bucket4j <optional>true</optional>). The WebFlux gateway needs the same Bucket4j dependencies with spring-cloud-starter-gateway-server-webflux. The bucket4j_jdk17-bom first appeared in 8.21.0; on an older Bucket4j declare the versions explicitly.

The Gateway reference shows a Caffeine example that calls new CaffeineProxyManager<>(builder, Duration.ofMinutes(1)). Both CaffeineProxyManager constructors are @Deprecated in 8.21.0 (the source says to use Bucket4jCaffeine#builderFor(Caffeine)), the same page links Bucket4j 8.7.0, and the WebFlux section spells the type AsyncProxyMananger. The examples here use the builder.

The AsyncProxyManager bean

Both limiters get their buckets from a Spring bean of type io.github.bucket4j.distributed.proxy.AsyncProxyManager, with String keys on WebFlux. A ProxyManager is turned into one with asAsync(). The simplest backend is Caffeine, a local cache that lives in the gateway JVM:

A Caffeine-backed AsyncProxyManager
@Configuration
class CaffeineProxyConfig {

    @Bean
    AsyncProxyManager<String> asyncProxyManager() {
        return Bucket4jCaffeine.<String>builderFor(Caffeine.newBuilder().maximumSize(10_000))
                .expirationAfterWrite(ExpirationAfterWriteStrategy
                        .basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
                .build()
                .asAsync();
    }
}

Source: Bucket4j reference: Asynchronous API and Bucket4jCaffeine at 8.21.0. maximumSize bounds the memory (one entry per key), and expirationAfterWrite removes the buckets of clients that went away; without both, a key space an attacker controls grows without limit. A Caffeine bucket lives in one JVM: with three gateway replicas a limit of 100 per minute becomes up to 300, exactly as on the distributed page. For a limit that must hold across replicas, back the same bean with Redis through Lettuce:

A Redis-backed AsyncProxyManager shared by every gateway replica
@Configuration
class RedisProxyConfig {

    @Bean(destroyMethod = "shutdown")
    RedisClient redisClient(@Value("${redis.host}") String host, @Value("${redis.port}") int port) {
        return RedisClient.create(RedisURI.create(host, port));
    }

    @Bean
    AsyncProxyManager<String> asyncProxyManager(RedisClient redisClient) {
        return Bucket4jLettuce.casBasedBuilder(redisClient)
                .expirationAfterWrite(ExpirationAfterWriteStrategy
                        .basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
                .build()
                .withMapper((String key) -> key.getBytes(StandardCharsets.UTF_8))
                .asAsync();
    }
}

Source: Bucket4j reference: bucket4j-lettuce and Asynchronous API. It needs bucket4j_jdk17-redis-common and bucket4j_jdk17-lettuce and Lettuce, which Spring Boot manages. Spring Boot creates no RedisClient bean (it exposes a LettuceConnectionFactory), so the configuration declares one. Run: two complete gateway instances on ports 18091 and 18092 pointed at one Redis 7 container, with a limit of three requests per minute per API key, were called alternately with the same key.

Two gateway instances, one Redis, one limit
18091 -> 200 remaining=2
18092 -> 200 remaining=1
18091 -> 200 remaining=0
18092 -> 429 remaining=0
18091 -> 429 remaining=0
other key on 18092 -> 200

The fourth request, sent to the other replica, was refused: the count is in Redis, not in the gateway. The same test with Caffeine would have admitted three requests on each instance. The behaviour of the CAS protocol and of the client-side settings (requestTimeout, maxRetries, clientClock) is on the distributed page; on a gateway they matter more than elsewhere, because the call is on the path of every request, as the timeout below shows.

When no AsyncProxyManager bean exists, the two stacks fail differently. On WebFlux the Bucket4j limiter bean is simply not created (it is conditional on the class and on the bean). On Web MVC the filter looks the bean up on every request, so the application starts and then fails: a gateway without the bean answered 500 to every request, with a key and without one, with NoSuchBeanDefinitionException in the log, because the lookup comes before the key check.

Web MVC: Bucket4jFilterFunctions.rateLimit

The Web MVC filter is a HandlerFilterFunction<ServerResponse, ServerResponse> that is attached to a route with .filter(…​). It has two overloads, both static in org.springframework.cloud.gateway.server.mvc.filter.Bucket4jFilterFunctions:

The two overloads
static HandlerFilterFunction<ServerResponse, ServerResponse> rateLimit(
        long capacity, Duration period, Function<ServerRequest, String> keyResolver)

static HandlerFilterFunction<ServerResponse, ServerResponse> rateLimit(
        Consumer<RateLimitConfig> configConsumer)

Source: Bucket4jFilterFunctions source and Spring Cloud Gateway: Web MVC rate limiter. The first overload is shorthand for the second with capacity, period and keyResolver set. Everything else is a field of RateLimitConfig:

Table 2. RateLimitConfig fields and defaults (Gateway 5.0.3)
Field (setter) Type Default Meaning

capacity

long

0

Size of the bucket, and with the default builder also the tokens added per period

period

Duration

none

The time over which capacity tokens come back. Required unless you supply a configurationBuilder that does not use it

keyResolver

Function<ServerRequest, String>

none

Who is being limited. With no resolver the key is empty and every request is refused with 403

statusCode

HttpStatusCode

429 (TOO_MANY_REQUESTS)

Status of a rejected request

tokens

int, greater than zero

1

Tokens one request costs

headerName

String

X-RateLimit-Remaining

Header that carries the remaining tokens, on allowed and on rejected responses

timeout

Duration

none, the filter waits indefinitely

How long to wait for the bucket store to answer

configurationBuilder

Function<RateLimitConfig, BucketConfiguration>

One greedy bandwidth: capacity tokens, refilled capacity per period

Replaces the default bucket configuration (see below)

The fields come from the 5.0.3 source; the reference page documents capacity, period, keyResolver, statusCode, tokens, headerName and timeout and says nothing about configurationBuilder. The default configuration builder is Bandwidth.builder().capacity(c).refillGreedy(c, period), so the refill is greedy unless you replace it.

A per-client limit for the Bookshelf API

In the Bookshelf scenario every client sends an API key, and the gateway forwards /gw/books to bookshelf-service. The route below uses the consumer form, resolves the key from the X-API-Key header and allows three requests per minute per key:

A rate-limited route (Web MVC)
@Configuration
class GatewayRoutes {

    /** Who is calling: the API key header. A missing header gives no key, and the filter answers 403. */
    static final Function<ServerRequest, String> API_KEY =
            request -> request.headers().firstHeader("X-API-Key");

    @Value("${bookshelf.service.uri}")
    String target;

    @Bean
    RouterFunction<ServerResponse> books() {
        return route("bookshelf_books")
                .GET("/gw/books", http())
                .before(rewritePath("/gw/books", "/api/books"))
                .before(uri(target))
                .filter(rateLimit(c -> c.setCapacity(3)
                        .setPeriod(Duration.ofMinutes(1))
                        .setKeyResolver(API_KEY)))
                .build();
    }

    /** The same limit with the first overload. */
    @Bean
    RouterFunction<ServerResponse> shortForm() {
        return route("bookshelf_short")
                .GET("/gw/short", http())
                .before(rewritePath("/gw/short", "/api/books"))
                .before(uri(target))
                .filter(rateLimit(2, Duration.ofMinutes(1), API_KEY))
                .build();
    }
}

Source: Spring Cloud Gateway: Web MVC rate limiter and the Bucket4jFilterFunctions source. The route path is a distinct gateway path (/gw/) rewritten to the service path (/api/). When the gateway and the stub service share one server, routing /api/** to itself loops through the filter twice, which is how this was discovered; with a real service on another host the paths can be the same.

Observed for the same key, with books (three tokens) and shortForm (two tokens):

Remaining tokens and the rejection
GET /gw/books  X-API-Key: o2   200 X-RateLimit-Remaining: 2
GET /gw/books  X-API-Key: o2   200 X-RateLimit-Remaining: 1
GET /gw/books  X-API-Key: o2   200 X-RateLimit-Remaining: 0
GET /gw/books  X-API-Key: o2   429 X-RateLimit-Remaining: 0     (no Retry-After)

GET /gw/short  X-API-Key: o1   200 remaining 1, 200 remaining 0, 429 remaining 0

An allowed response carries the remaining tokens in the configured header; a rejected response carries the same header (0) and nothing else: no Retry-After, no body, no RateLimit header. Clients that should back off need those added by a later filter, which is a good place for the Retry-After convention discussed on the distributed page. Another key was unaffected by the first key’s exhaustion, which is the point of resolving a key per client.

The key and the 403

The key resolver returns a String. When the resolver is not set, or returns null, an empty string or blank text, the filter answers 403 Forbidden without touching any bucket, and the source has a TODO for making that status configurable, so it is not. Run: no X-API-Key header and a header containing a single space both returned 403, and the response had no rate-limit header.

The resolver decides everything the limit means, so what it reads matters as much as the capacity. A header the client writes is not an identity: a client that sends a different random value on every request gets a fresh full bucket every time. The Gateway reference example uses request.servletRequest().getUserPrincipal().getName(), the authenticated principal; use that, or the client id of a validated token, for per-client limits, and use the client address (mind the proxy headers, covered on the production checklist) for an anonymous limit. Put a prefix such as gw: in front of the key when the store is shared with other data, and do not use a raw secret such as an API key as the key if the store can be read by others: derive an identifier from it.

The Bucket4j key is exactly what the resolver returns. It does not include the route. Two routes that use the same resolver share one bucket per client, and the configuration of whichever route touched the key first wins. Run: /gw/a allows two requests, /gw/b allows 100. After two calls to /gw/a and a rejected third, /gw/b with the same key was rejected too (429, remaining 0), while a fresh key on /gw/b was allowed with 99 remaining. If a client should have an independent budget per route, include the route in the key ("books:" + apiKey). The Redis RequestRateLimiter builds its Redis keys from the route id and the resolver key and therefore behaves the other way round.

tokens, headerName and statusCode

A request that costs more than one token models an expensive endpoint, such as the Bookshelf search that costs five. headerName renames the response header and statusCode replaces 429:

tokens, headerName and statusCode
// 10 tokens, search costs 5, custom header
.filter(rateLimit(c -> c.setCapacity(10)
        .setPeriod(Duration.ofMinutes(1))
        .setTokens(5)
        .setHeaderName("X-Quota-Remaining")
        .setKeyResolver(API_KEY)))

// a different rejection status
.filter(rateLimit(c -> c.setCapacity(1)
        .setPeriod(Duration.ofMinutes(1))
        .setStatusCode(HttpStatusCode.valueOf(503))
        .setKeyResolver(API_KEY)))

Source: Spring Cloud Gateway: Web MVC rate limiter. Run: the search route returned 200 X-Quota-Remaining: 5, 200 X-Quota-Remaining: 0, then 429 X-Quota-Remaining: 0, and the default X-RateLimit-Remaining header was absent. The one-token route with statusCode 503 returned 200 and then 503. A 429 remains the right status for a client that is over its limit; a different one is for an edge that wants to distinguish limiting from other rejections.

configurationBuilder: any bucket configuration

The default bucket has one greedy bandwidth. configurationBuilder takes the RateLimitConfig and returns a complete BucketConfiguration, so everything from the bandwidths page applies: several bandwidths, intervally refill, initial tokens, bandwidth ids. Two examples:

configurationBuilder: two bandwidths, and an intervally refill
// a per-minute budget plus a burst guard: at most 2 requests per 10 seconds
.filter(rateLimit(c -> c.setKeyResolver(API_KEY)
        .setConfigurationBuilder(config -> BucketConfiguration.builder()
                .addLimit(limit -> limit.capacity(100).refillGreedy(100, Duration.ofMinutes(1)))
                .addLimit(limit -> limit.capacity(2).refillGreedy(2, Duration.ofSeconds(10)))
                .build())))

// nothing comes back until the whole period has elapsed, then everything does
.filter(rateLimit(c -> c.setKeyResolver(API_KEY)
        .setConfigurationBuilder(config -> BucketConfiguration.builder()
                .addLimit(limit -> limit.capacity(2).refillIntervally(2, Duration.ofSeconds(2)))
                .build())))

Source: Bucket4j reference: Bandwidth and Refill types. With a custom builder capacity and period are not used unless your lambda reads them. Run: with the two-bandwidth builder the first two requests returned 200 with remaining 1 and 0, and the third returned 429, because the burst bandwidth was exhausted while the minute budget still had 98 tokens; the reported remaining is the smallest of the bandwidths.

Greedy and intervally refill, same capacity 2 per 2 seconds, both emptied at the start:

Refill after 1.2 s and 2.2 s
after 1.2 s: greedy route    200 (remaining 0)      intervally route  429
after 2.2 s: intervally route 200 (remaining 1)

The greedy bucket gave back one token after about a second; the intervally bucket gave nothing back until the period was over and then refilled completely (two tokens, one consumed by the request). Greedy is smoother; intervally permits a burst at every boundary, as shown on the token bucket page.

timeout: do not wait for a dead store

The Web MVC filter blocks the request thread on the future that Bucket4j returns: bucketFuture.get() with no timeout, or get(timeout) when timeout is set. The default is to wait forever, which with a remote store means a hung store hangs requests. Run: against Redis, a route without timeout and a route with setTimeout(Duration.ofMillis(300)) were called while the Redis container was paused (frozen, not stopped):

A frozen Redis
route with timeout 300 ms:   500 after 347 ms
route without timeout:       no answer within 3 s (the client gave up)

Set a timeout, and decide what the gateway should do about the failure. The timeout is a TimeoutException inside the filter, which Spring turns into a 500; there is no built-in fail-open. If an edge limit must not take the API down when the limit store fails, wrap the filter (or the call through the ProxyManager) with your own policy, as the fail-open and fail-closed discussion on fallbacks and composition does for Resilience4j, and prefer a short requestTimeout on the Redis backend over a long wait.

What the filter does, in order

  1. It looks up the AsyncProxyManager bean from the application context (a missing bean is a 500, even for a request with no key).

  2. It calls the key resolver; a blank key returns 403.

  3. It builds a bucket proxy for the key with a configuration supplier that runs the configurationBuilder on the application task executor, and only when the bucket does not yet exist in the store.

  4. It calls tryConsumeAndReturnRemaining(tokens) and waits for the answer.

  5. When the request is allowed, it calls the rest of the route and adds the remaining-tokens header to the response; otherwise it answers with statusCode and the header and does not call the service.

The supplier in step 3 matters in a shared store. A bucket keeps the configuration it was created with, so changing capacity in the route and redeploying does not change buckets that already exist in Redis; they change when they expire, or when you replace the configuration explicitly (see implicit replacement on the distributed page). The filter itself does not do this.

WebFlux: the Bucket4j rate limiter of RequestRateLimiter

The WebFlux gateway has had a RequestRateLimiter filter for years, with RedisRateLimiter as its only built-in limiter. Since Spring Cloud 2025.0 it also ships a Bucket4j RateLimiter. Declaring an AsyncProxyManager<String> bean is enough to create it: the Bucket4jRateLimiter bean is conditional on the AsyncProxyManager class and on the bean. The limiter is configured with bucket4j-rate-limiter.* arguments of the filter, and the KeyResolver is a bean referenced by SpEL:

application.yml of the WebFlux gateway
spring:
  cloud:
    gateway:
      server:
        webflux:
          routes:
            # 3 per minute per API key, greedy refill (the default)
            - id: books
              uri: ${bookshelf.service.uri}
              predicates:
                - Path=/gw/books
              filters:
                - RewritePath=/gw/books, /api/books
                - name: RequestRateLimiter
                  args:
                    key-resolver: "#{@apiKeyResolver}"
                    bucket4j-rate-limiter.capacity: 3
                    bucket4j-rate-limiter.refillPeriod: 1m
            # capacity 4, 2 tokens back every 4 seconds, all at once at the end of the period
            - id: intervally
              uri: ${bookshelf.service.uri}
              predicates:
                - Path=/gw/intervally
              filters:
                - RewritePath=/gw/intervally, /api/books
                - name: RequestRateLimiter
                  args:
                    key-resolver: "#{@apiKeyResolver}"
                    bucket4j-rate-limiter.capacity: 4
                    bucket4j-rate-limiter.refillTokens: 2
                    bucket4j-rate-limiter.refillPeriod: 4s
                    bucket4j-rate-limiter.refillStyle: INTERVALLY
            # the search endpoint costs 5 tokens and reports in its own header
            - id: search
              uri: ${bookshelf.service.uri}
              predicates:
                - Path=/gw/search
              filters:
                - RewritePath=/gw/search, /api/books
                - name: RequestRateLimiter
                  args:
                    key-resolver: "#{@apiKeyResolver}"
                    bucket4j-rate-limiter.capacity: 10
                    bucket4j-rate-limiter.refillPeriod: 1m
                    bucket4j-rate-limiter.requestedTokens: 5
                    bucket4j-rate-limiter.headerName: X-Quota-Remaining
            # refills aligned to a fixed instant
            - id: aligned
              uri: ${bookshelf.service.uri}
              predicates:
                - Path=/gw/aligned
              filters:
                - RewritePath=/gw/aligned, /api/books
                - name: RequestRateLimiter
                  args:
                    key-resolver: "#{@apiKeyResolver}"
                    bucket4j-rate-limiter.capacity: 2
                    bucket4j-rate-limiter.refillPeriod: 1h
                    bucket4j-rate-limiter.refillStyle: INTERVALLY_ALIGNED
                    bucket4j-rate-limiter.timeOfFirstRefill: "2026-01-01T00:00:00Z"
The key resolver and the proxy manager of the WebFlux gateway
@Bean
KeyResolver apiKeyResolver() {
    return exchange -> Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst("X-API-Key"));
}

@Bean
AsyncProxyManager<String> asyncProxyManager() {   // same Caffeine bean as above
    return Bucket4jCaffeine.<String>builderFor(Caffeine.newBuilder().maximumSize(10_000)).build().asAsync();
}

Source: Spring Cloud Gateway: RequestRateLimiter GatewayFilter Factory (section Bucket4j RateLimiter) and the Bucket4jRateLimiter source at 5.0.3. The reference page’s own example sets capacity: 20, refillTokens: 10 and refillPeriod: 1s, which is a bucket of 20 with a refill of 10 per second.

Table 3. bucket4j-rate-limiter arguments (Gateway 5.0.3)
Argument Type Default Meaning

capacity

long

0; must be greater than zero

Tokens the bucket holds, the largest burst. The reference text says "in a single second", which is only true for a one-second period

refillPeriod

Duration, Spring Boot format (1m, 4s)

none, required

Period in which refillTokens come back

refillTokens

long

capacity

Tokens added per refillPeriod

requestedTokens

long

1

Tokens one request costs

refillStyle

GREEDY, INTERVALLY, INTERVALLY_ALIGNED

GREEDY

How refill happens, as on the bandwidths page

timeOfFirstRefill

Instant

none

Only for INTERVALLY_ALIGNED: the instant the first refill is aligned to

headerName

String

X-RateLimit-Remaining

Header that carries the remaining tokens

Observed, with the YAML above: /gw/books for one key returned 200 remaining=2, 200 remaining=1, 200 remaining=0, 429 remaining=0, and the response headers were only content-length and x-ratelimit-remaining. /gw/search returned 200 X-Quota-Remaining: 5, 200 X-Quota-Remaining: 0 and then 429. On /gw/intervally (capacity 4, two tokens per four seconds) four requests passed, the fifth was refused, a request after 2.0 s was still refused, and after 4.2 s two requests passed (remaining 1, then 0) and the third was refused. The aligned route admitted two requests and refused the third. The rejection status is the statusCode argument of RequestRateLimiter itself, 429 by default; status-code: SERVICE_UNAVAILABLE returned 503.

Empty keys: the corrected deny-empty-key prefix

When the KeyResolver returns nothing, RequestRateLimiter denies the request with FORBIDDEN by default. The two global settings are properties of the filter factory. The 5.0.x reference page still shows the old prefix; the code binds spring.cloud.gateway.server.webflux.filter.request-rate-limiter:

Allowing requests with no key, and changing the denial status
spring:
  cloud:
    gateway:
      server:
        webflux:
          filter:
            request-rate-limiter:
              deny-empty-key: false              # default true
              empty-key-status-code: UNAUTHORIZED  # default FORBIDDEN, used only while deny-empty-key is true

Source: Spring Cloud Gateway: RequestRateLimiter GatewayFilter Factory and the RequestRateLimiterGatewayFilterFactory source at 5.0.3 (@ConfigurationProperties("spring.cloud.gateway.server.webflux.filter.request-rate-limiter")). Run, with no X-API-Key header: by default 403; with deny-empty-key=false under the prefix above, 200 and no limiting; with deny-empty-key=false under the old prefix spring.cloud.gateway.filter.request-rate-limiter, still 403, so the old property is silently ignored; with empty-key-status-code=UNAUTHORIZED, 401. Both settings can also be given per route as the deny-empty-key and empty-key-status arguments of the filter. Letting key-less requests through disables the limit for them, so allow it only on routes that have another protection.

Failure: the Bucket4j limiter fails closed

If the call to Bucket4j fails (a store outage, a timeout, an exception), Bucket4jRateLimiter swallows the error, logs it at DEBUG, and answers as if the bucket were empty: ConsumptionProbe.rejected(-1, -1, -1), so the request is refused. Run: an AsyncProxyManager whose bucket returned a failed future made every request answer 429 with X-RateLimit-Remaining: 0. The remaining count is clamped to zero, so a limit that is working and a store that is down look the same from outside, and the only trace is a DEBUG line. Monitor the store, and log or count these errors if a silent 429 would be costly. The Redis limiter does the opposite, as the next section explains.

A shared key across routes

As on Web MVC, the key handed to the AsyncProxyManager is the resolver’s output alone. Run: route-a with capacity 2 and route-b with capacity 100, one resolver: after two calls and a rejected third on route-a, the same key on route-b was also rejected (429); a fresh key on route-b was allowed with 99 remaining. Make the key route-specific in the resolver when budgets must be independent.

The Redis RequestRateLimiter, for contrast

RedisRateLimiter is the older limiter of the same filter. It needs spring-boot-starter-data-redis-reactive, and it is configured with redis-rate-limiter.* arguments:

A Redis token bucket on the same route shape
- id: redis-books
  uri: ${bookshelf.service.uri}
  predicates:
    - Path=/gw/redis
  filters:
    - RewritePath=/gw/redis, /api/books
    - name: RequestRateLimiter
      args:
        key-resolver: "#{@apiKeyResolver}"
        redis-rate-limiter.replenishRate: 1
        redis-rate-limiter.burstCapacity: 3
        redis-rate-limiter.requestedTokens: 1

Source: Spring Cloud Gateway: RequestRateLimiter GatewayFilter Factory (Redis RateLimiter section). Run against Redis 7: 200 remaining=2, 200 remaining=1, 200 remaining=0, then 429 remaining=0 twice, and every response carried X-RateLimit-Replenish-Rate: 1, X-RateLimit-Burst-Capacity: 3 and X-RateLimit-Requested-Tokens: 1. The Redis keys were request_rate_limiter.{redis-books.k1}.tokens and .timestamp: the route id is in the key.

Table 4. Redis RequestRateLimiter against the Bucket4j limiter
Aspect Redis RequestRateLimiter (RedisRateLimiter) Bucket4j limiter

Arguments

replenishRate (tokens per second, integer), burstCapacity (at least replenishRate), requestedTokens

capacity, refillTokens, refillPeriod (any Duration), requestedTokens, refillStyle, timeOfFirstRefill, headerName

Time unit

One second, fixed. A rate below one per second is expressed through requestedTokens

Any period

Refill models

Continuous only

Greedy, intervally, intervally aligned, or any BucketConfiguration in Java on Web MVC

Stores

Redis only, evaluated by a Lua script

Any Bucket4j backend with an async API (Caffeine, Redis, JCache, Hazelcast and others)

Several bandwidths

No

Yes with a custom configuration (Java on Web MVC; the WebFlux arguments describe one bandwidth)

Key

request_rate_limiter.{routeId.key} per route and per client

The resolver’s output only; add the route yourself if you need it

Response headers

X-RateLimit-Remaining, -Replenish-Rate, -Burst-Capacity, -Requested-Tokens

Only the remaining-tokens header (X-RateLimit-Remaining by default)

When the store fails

Logs the error and allows the request, with X-RateLimit-Remaining: -1

Answers as if the bucket were empty: the request is denied

Stack

WebFlux only

WebFlux and Web MVC

The Redis row on failure comes from the 5.0.3 source and from the measurement on Gateway and OpenFeign, which saw X-RateLimit-Remaining: -1 and a 200 against an unreachable Redis. Whether failing open or closed is right depends on what the limit protects; decide it on purpose, because the two built-in limiters choose differently.

Both limiters on one gateway

With spring-boot-starter-data-redis-reactive on the classpath and an AsyncProxyManager bean, there are two RateLimiter beans (redisRateLimiter and bucket4jRateLimiter), and the RequestRateLimiter factory wants exactly one by default. Run: without a primary bean the application failed at startup with NoUniqueBeanDefinitionException: …​ expected single matching bean but found 2: redisRateLimiter,bucket4jRateLimiter. Mark one as primary, and name the other in the routes that want it:

A primary RedisRateLimiter, and a route that picks the Bucket4j one
@Bean
@Primary
RedisRateLimiter redisRateLimiter(ReactiveStringRedisTemplate redisTemplate,
        @Qualifier(RedisRateLimiter.REDIS_SCRIPT_NAME) RedisScript<List<Long>> redisScript,
        ConfigurationService configurationService) {
    return new RedisRateLimiter(redisTemplate, redisScript, configurationService);
}
- id: bucket4j-books
  uri: ${bookshelf.service.uri}
  predicates:
    - Path=/gw/bucket4j
  filters:
    - RewritePath=/gw/bucket4j, /api/books
    - name: RequestRateLimiter
      args:
        key-resolver: "#{@apiKeyResolver}"
        rate-limiter: "#{@bucket4jRateLimiter}"
        bucket4j-rate-limiter.capacity: 3
        bucket4j-rate-limiter.refillPeriod: 1m

Source: Spring Cloud Gateway: RequestRateLimiter GatewayFilter Factory (custom RateLimiter by SpEL). Run: /gw/redis behaved as above and /gw/bucket4j returned 200 remaining=2, 200 remaining=1, 200 remaining=0, 429 remaining=0 with no X-RateLimit-Burst-Capacity header, each on its own limiter.

Edge limits and in-service limits

An edge limit and an in-service limit answer different questions, and a system usually needs both.

  • The edge limit is about who: it protects the whole system from one client, in one place, before any resource is spent. It knows the client but not the cost of the work behind the route. It needs to be cheap, and it may be approximate.

  • The in-service limit is about what: it protects a resource the service owns (a database, an expensive endpoint, a quota with a third party such as the ISBN provider of the Bookshelf) whoever calls it, including callers that never pass through the gateway: other services, jobs, and anyone who finds the service address.

flowchart LR subgraph clients["Clients"] A["free-alice, 20 per minute"] B["pro-bob, 200 per minute"] end subgraph edge["Edge layer: Spring Cloud Gateway"] G["rateLimit per X-API-Key: free-alice 10 per minute, pro-bob 100 per minute"] end subgraph svc["In-service layer: bookshelf-service"] S["Interceptor per client on search: 2 per minute"] Q["ISBN provider quota shared by all clients: 100 per day"] end A -->|"X-API-Key"| G B -->|"X-API-Key"| G G -->|"allowed"| S G -.->|"over the edge limit: 429, the service is never called"| A S -.->|"over the service limit: 429 with X-Limited-By"| A S --> Q X["Other caller: another service or a job"] -->|"no gateway"| S

The figure is the Bookshelf system with limits at both layers. A client is limited per key at the gateway; the service limits its search endpoint per client and holds the shared outbound quota for the ISBN provider; callers that bypass the gateway meet only the second layer. The numbers in the figure are an example; the ones used in the run below were smaller.

Run: the gateway allowed ten requests per minute per key on /gw/search, forwarded to /api/search, and bookshelf-service had its own local per-client bucket of two per minute on that path, in a HandlerInterceptor:

The in-service layer: a per-client interceptor
@Configuration
class ServiceLimitConfig implements WebMvcConfigurer {

    private static final BucketConfiguration TWO_PER_MINUTE = BucketConfiguration.builder()
            .addLimit(limit -> limit.capacity(2).refillGreedy(2, Duration.ofMinutes(1)))
            .build();

    // bounded and expiring: a caller who invents keys or addresses cannot grow it without limit
    private final ProxyManager<String> buckets = Bucket4jCaffeine.<String>builderFor(Caffeine.newBuilder().maximumSize(10_000))
            .expirationAfterWrite(ExpirationAfterWriteStrategy
                    .basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
            .build();

    private final RateLimitProperties properties;

    ServiceLimitConfig(RateLimitProperties properties) {
        this.properties = properties;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new HandlerInterceptor() {
            @Override
            public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
                String apiKey = request.getHeader("X-API-Key");
                String key = apiKey != null && properties.planOfApiKey(apiKey) != null
                        ? "key:" + ClientIdentifier.fingerprint(apiKey)     // issued keys only
                        : "ip:" + request.getRemoteAddr();                // unknown or missing keys: limited by address
                var probe = buckets.getProxy(key, () -> TWO_PER_MINUTE).tryConsumeAndReturnRemaining(1);
                if (probe.isConsumed()) {
                    response.setHeader("X-Service-Remaining", String.valueOf(probe.getRemainingTokens()));
                    return true;
                }
                long retryAfterSeconds = Math.max(1, (probe.getNanosToWaitForRefill() + 999_999_999L) / 1_000_000_000L);
                response.setStatus(429);
                response.setHeader("X-Limited-By", "bookshelf-service");
                response.setHeader("Retry-After", String.valueOf(retryAfterSeconds));  // rounded up, never early
                return false;
            }
        }).addPathPatterns("/api/search");
    }
}

Source: Bucket4j reference: Quick start examples and Spring Framework: Interceptors. The buckets live in a bounded, expiring Caffeine ProxyManager (the same rules as the Caffeine bean above), and only issued keys (layers, direct and layers-other in the run) get a bucket of their own; ClientIdentifier.fingerprint and RateLimitProperties come from the manual integration page. The in-service layers of the Bookshelf (the search interceptor, the shared outbound quota, the ISBN ClientHttpRequestInterceptor) are built on the manual integration page; this one is the minimum needed to show the layering.

One client through the gateway, and another straight to the service
GET /gw/search  X-API-Key: layers   200 gateway remaining 9, service remaining 1
GET /gw/search  X-API-Key: layers   200 gateway remaining 8, service remaining 0
GET /gw/search  X-API-Key: layers   429 gateway remaining 7, X-Limited-By: bookshelf-service

GET /api/search X-API-Key: direct   200 service remaining 1, 200 service remaining 0, 429
GET /gw/search  X-API-Key: layers-other   200

Three things show up in the run. The service refused the third request on its own limit while the gateway still had seven tokens, so the stricter limit wins for each client, and the X-Limited-By header (a convention of this example, not of Bucket4j) tells the client which layer spoke. The gateway spent a token on a request that the service then refused, because the gateway cannot know what the service will do; a gateway limit counts requests it admitted, not requests that succeeded. And the direct call to the service, with no gateway, was limited by the service alone, which is why the in-service layer is not optional when the service is reachable another way.

Where the limit should live is part of the same decision as the shape of the gateway itself, which the architecture discussion in API gateway and BFF covers. A gateway limit by client plan, by IP for anonymous traffic, and a coarse global limit that sheds load before the services notice it, belong at the edge; limits that depend on business cost (five tokens for a search, a daily ISBN quota) belong where the cost is known. Resilience4j at the same gateway is about protecting callers of a failing service, not limiting clients: the circuit breaker and the rate limiter of the gateway are on Gateway and OpenFeign. Beyond a library inside the gateway, a deployment can use a dedicated API manager or gateway product that offers plans, keys, quotas and analytics out of the box; that is a product choice, not a Bucket4j one, and it still leaves the in-service layer in place.

Pitfalls

Table 5. Pitfalls of Bucket4j in Spring Cloud Gateway
Pitfall What happens What to do

Bucket4j is not on the classpath, or is an old version

Gateway declares it <optional>; no BOM you import manages it; the gateway’s own 8.15.0 does not reach you

Import bucket4j_jdk17-bom 8.21.0 and declare bucket4j_jdk17-core plus the backend module

No AsyncProxyManager bean

Web MVC: every request returns 500 (observed, even with no key). WebFlux: the Bucket4j limiter bean is not created

Declare the bean; type it AsyncProxyManager<String> for WebFlux

Following the docs example

new CaffeineProxyManager<>(builder, Duration) is deprecated in 8.21.0; the docs link Bucket4j 8.7.0 and spell the type AsyncProxyMananger

Bucket4jCaffeine.builderFor(…​).build().asAsync() and the 8.21.0 documentation

Caffeine behind several gateway replicas

Each replica has its own buckets, so the limit multiplies with the replica count

Redis through Lettuce (or another shared backend) behind the same bean

Client-controlled key such as a plain header

Every new value is a fresh full bucket, so the limit can be bypassed

Key on the authenticated principal or a validated client id; keep a separate limit by address for anonymous requests

Missing or blank key

Web MVC always answers 403 (the status is not configurable). WebFlux denies with 403 unless deny-empty-key is false

Decide what no-key traffic should be; do not turn deny-empty-key off without another protection

The old deny-empty-key prefix

spring.cloud.gateway.filter.request-rate-limiter is ignored in 5.0.3 (still 403 in the run); the docs page shows it

spring.cloud.gateway.server.webflux.filter.request-rate-limiter

Two routes, one resolver

The key does not contain the route, so the routes share one bucket per client and the first configuration wins (observed on both stacks)

Put the route in the key when budgets must be independent

No timeout on Web MVC

A hung store hangs the request thread (no answer in 3 s against a frozen Redis); with a 300 ms timeout the request returned 500 after 347 ms

Set timeout, and set a short requestTimeout on the backend; add your own fail-open if the limit must not stop traffic

A store outage on WebFlux

The limiter denies every request with a 429 whose X-RateLimit-Remaining is 0, the same as an exhausted bucket; the error is only a DEBUG log

Monitor the store; the Redis limiter fails open instead, so know which one you run

Changing capacity in a route

Existing buckets in a shared store keep the configuration they were created with

Let them expire, or replace the configuration explicitly

Expecting Retry-After

Rejected responses carry only the remaining-tokens header and have no body

Add a response filter if clients need Retry-After or a problem body

Counting rejected-by-service requests

The gateway spent a token on a request that the service refused with its own 429

Expect it; keep the service limit stricter than, or independent from, the edge limit

Two RateLimiter beans

With both Redis and a Bucket4j AsyncProxyManager, startup fails with NoUniqueBeanDefinitionException

@Primary on one, and rate-limiter: "#{@bucket4jRateLimiter}" in the routes that use the other

Routing /api/** of the gateway to itself

The request goes through the filter twice (found while running this)

A distinct gateway path with rewritePath, or a real upstream

What changed

  • The Web MVC gateway has a Bucket4j filter (Bucket4jFilterFunctions.rateLimit) and the WebFlux gateway a Bucket4j RateLimiter next to the Redis one (announced with Spring Cloud 2025.0). Both consume an AsyncProxyManager.

  • The Spring Cloud Gateway artifacts were renamed for the two stacks (spring-cloud-starter-gateway-server-webmvc and -server-webflux), and the WebFlux property prefix gained server.webflux: routes are under spring.cloud.gateway.server.webflux.routes, and the request-rate-limiter properties under spring.cloud.gateway.server.webflux.filter.request-rate-limiter, which the reference page for deny-empty-key still shows in its old form.

  • Bucket4j 8.21.0 (2 October 2026) added bucket4j_jdk17-bom; Gateway 5.0.3 still builds against 8.15.0 and its documentation links 8.7.0 and a deprecated Caffeine constructor. The Bucket4jCaffeine.builderFor form is the supported one.

  • The community Spring Boot starter’s gateway filter was removed in 0.20.0-RC1 (0.14.0 still has it); see the starter page.

  • The full list of differences between the documentation and the 8.21.0 sources is on migration and what’s changed, and how the limiters fit with Resilience4j in Resilience4j versus Bucket4j.