Implementing Rate Limiting in Spring Boot with Bucket4j

Public APIs need protection from accidental traffic spikes, abusive clients, and poorly behaved integrations. A rate limiter controls how many requests a user, token, IP address, or application can make within a defined period.

Bucket4j is a practical Java library for this task because it implements token-bucket rules and works cleanly with Spring Boot. The approach is suitable for REST endpoints, authentication APIs, payment services, and other integrations where predictable traffic matters.

For an Australian application, limits may need to account for Sydney and Melbourne business-hour surges, mobile connections in regional Queensland, and customers sharing a public IP through a corporate network. A useful policy protects the service without making legitimate users feel blocked.

Understanding The Token Bucket Model

A token bucket starts with a defined capacity. Each incoming request consumes one token, while tokens are replenished at a configured rate. When the bucket is empty, the application can reject the request with HTTP 429 Too Many Requests or ask the client to retry later.

For example, a bucket with 100 tokens and a refill rate of 100 requests per minute permits a short burst of 100 calls. After that burst, access is gradually restored. This is generally more flexible than a rigid fixed-window counter, which can allow an unexpected double burst at the boundary between two time periods.

Bucket4j uses a fluent API to define capacity and bandwidth. A basic dependency can be added with Maven:

<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j_jdk17-core</artifactId>
    <version>8.10.1</version>
</dependency>

Select a version compatible with the Java runtime and application build. JavaWhizz also covers practical API foundations in its guide to building a Java API, which is useful when placing this protection around controller endpoints.

Adding A Simple Spring Boot Limiter

A service can keep one bucket per client identifier. For a first implementation, an in-memory ConcurrentHashMap is enough for a single application instance:

@Service
public class RateLimitService {

    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

    private Bucket newBucket() {
        Bandwidth limit = Bandwidth.classic(
                100,
                Refill.greedy(100, Duration.ofMinutes(1))
        );
        return Bucket.builder().addLimit(limit).build();
    }

    public boolean allowed(String clientId) {
        Bucket bucket = buckets.computeIfAbsent(clientId,
                key -> newBucket());
        return bucket.tryConsume(1);
    }
}

An HTTP filter is a convenient place to apply the rule before controller logic runs. The identifier might come from an authenticated subject, API key, or carefully evaluated IP address:

@Component
public class RateLimitFilter extends OncePerRequestFilter {

    private final RateLimitService rateLimitService;

    public RateLimitFilter(RateLimitService rateLimitService) {
        this.rateLimitService = rateLimitService;
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {

        String clientId = request.getHeader("X-Api-Key");

        if (clientId == null || !rateLimitService.allowed(clientId)) {
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.setHeader("Retry-After", "60");
            return;
        }

        chain.doFilter(request, response);
    }
}

Choosing The Right Client Identity

An API key is usually safer than a raw IP address. Many Australian offices, universities, hotels, and mobile networks place numerous users behind one public address, so IP-only throttling can block unrelated customers. If the API supports login, a user ID or client credential is usually a better primary key.

Untrusted forwarding headers require care. X-Forwarded-For should be read only when a known load balancer or reverse proxy has been configured to set it. Otherwise, a caller can change the header on every request and bypass the policy.

Different operations can have different costs. A login endpoint might allow five attempts per minute, while a read-only catalogue endpoint can accept hundreds. Payment creation, password reset, and one-time-code endpoints should usually have stricter limits because they attract automated abuse.

Handling Distributed Deployments

An in-memory bucket is local to one JVM. If the service runs across several containers behind an AWS load balancer, requests can reach different instances and receive separate limits. That can multiply the effective allowance and make behaviour inconsistent.

For production clusters, use a distributed Bucket4j configuration backed by a suitable store, such as Hazelcast, Redis, or another supported grid. Keep the bucket state close to the API’s deployment region to reduce latency. This matters when customers in Perth call a service hosted on Australia’s east coast and already experience network delay.

Spring Boot applications should also expose useful response headers. Retry-After tells clients when to try again, while custom headers can communicate remaining capacity when that information is safe to reveal. Clients should implement exponential backoff rather than retrying every few milliseconds during an arvo traffic spike.

Testing And Operating The Limiter

Automated tests should verify that permitted calls succeed, excess calls return 429, and tokens become available after the refill period. Use a controllable clock or short test durations instead of making tests wait for a full minute. Integration tests should also confirm that the filter does not interfere with health checks, documentation endpoints, or internal service calls.

Monitoring should record rejected requests by route, client category, and status code. Avoid logging API keys or personal information. Metrics can reveal whether a limit is too strict, whether one integration is misconfigured, or whether a burst reflects a legitimate event such as an EOFY promotion or a major sporting final.

The following choices suit common Spring Boot API scenarios:

Strategy Best use Main limitation
In-memory Bucket4j One instance or local development Limits are not shared between nodes
Redis-backed buckets Multiple containers and predictable shared limits Adds network and operational dependency
IP-based identity Anonymous public endpoints Shared Australian networks can cause false blocking
API-key identity Partner and customer integrations Requires secure key management
Route-specific limits Protecting login, payments, and search differently Needs more configuration and monitoring

Practical Configuration Recommendations

A reliable implementation benefits from a small policy document that states who is limited, which routes are covered, and what response clients should expect. Start with measured traffic rather than arbitrary numbers, then adjust limits using metrics from real usage across Australian time zones.

Rate limiting should work alongside request validation, authentication, gateway controls, and sensible payload limits. Bucket4j handles consumption of request capacity; it does not replace protection against oversized uploads, stolen credentials, or application-level fraud.