Java & Spring Boot6 min read·By Liyabona Saki··

API Rate Limiting in Spring Boot with Bucket4j and Redis

Protect your APIs from abuse with per-user and per-IP rate limiting using Bucket4j, Redis and a clean filter-based implementation.

You are probably rate limiting in the wrong place

Most rate-limiting tutorials add a filter in front of the application that counts requests by IP and rejects past a threshold. That works for crude protection. It also misses the three rate-limiting decisions that actually matter in production: *what to limit by*, *where in the stack to enforce it*, and *what to do when a legitimate user is throttled*.

This article is the rate-limiting setup I actually run in front of a Spring Boot service, and the reasoning for each decision.

What to limit by — the most important choice

Limiting by IP is the default and usually wrong. A corporate NAT can put thousands of users behind one IP; a single attacker can rotate IPs trivially. Better signals, in order of usefulness:

1. Authenticated user id. Best signal. The user pays for their own rate. 2. API key. Same idea for machine-to-machine traffic. 3. Authenticated tenant. Bucket the whole organization together. 4. IP — fallback for unauthenticated endpoints (login, signup).

The right setup is a *layered* limit: a low IP limit on unauthenticated endpoints (to slow credential stuffing), and a higher per-user limit on authenticated endpoints. Each layer protects against a different abuse pattern.

Where to enforce it

Three viable layers:

  • CDN / edge (Cloudflare, Fastly). Best for crude IP limiting and DDoS-class protection. Cheapest. Cannot see authenticated user.
  • API gateway (Spring Cloud Gateway, Kong, NGINX). Best for per-user limiting on authenticated routes. Centralized config.
  • Application layer. Best for per-feature limits ("user X can only export 5 reports per hour"). Has full request context.

A real system uses all three. The CDN swats the obvious abuse, the gateway enforces the contract per API key, and the application layer handles business-logic limits.

This article focuses on the application layer with Bucket4j + Redis, which is the layer Spring Boot owns directly.

The algorithm: token bucket, not fixed window

Fixed-window limiting ("100 requests per minute") has a sharp edge: a user can burst 200 requests across the minute boundary. Sliding window is correct but expensive (you have to track every request).

Token bucket is the right default. You have a bucket of N tokens; each request consumes one; tokens refill at a fixed rate. Bursts are bounded by the bucket size; sustained rate is bounded by the refill rate. Bucket4j implements it cleanly.

The Spring Boot setup

xml
<dependency>
  <groupId>com.giffing.bucket4j.spring.boot.starter</groupId>
  <artifactId>bucket4j-spring-boot-starter</artifactId>
  <version>0.12.7</version>
</dependency>
<dependency>
  <groupId>com.bucket4j</groupId>
  <artifactId>bucket4j-redis</artifactId>
  <version>8.10.1</version>
</dependency>

A custom filter (not the starter's URL config, because we want per-user keying):

```java
@Component
class RateLimitFilter extends OncePerRequestFilter {

private final ProxyManager<String> buckets;

public RateLimitFilter(RedissonClient redisson) { this.buckets = Bucket4jRedisson.casBasedBuilder(redisson).build(); }

protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException { var key = resolveKey(req); var bucket = buckets.builder().build(key, () -> config()); var probe = bucket.tryConsumeAndReturnRemaining(1);

if (probe.isConsumed()) { res.addHeader("X-RateLimit-Remaining", String.valueOf(probe.getRemainingTokens())); chain.doFilter(req, res); } else { var retry = Duration.ofNanos(probe.getNanosToWaitForRefill()).toSeconds(); res.addHeader("Retry-After", String.valueOf(retry)); res.addHeader("X-RateLimit-Remaining", "0"); res.setStatus(429); res.getWriter().write(""" {"type":"https://errors.example.com/rate-limited", "title":"Too Many Requests", "detail":"Retry after %d seconds"} """.formatted(retry)); } }

private String resolveKey(HttpServletRequest req) { var auth = SecurityContextHolder.getContext().getAuthentication(); if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getName())) { return "rl:user:" + auth.getName(); } return "rl:ip:" + clientIp(req); }

private BucketConfiguration config() { return BucketConfiguration.builder() .addLimit(Bandwidth.builder() .capacity(100) .refillIntervally(100, Duration.ofMinutes(1)) .build()) .build(); } } ```

Three things worth pointing at:

  • Redis-backed buckets. Token counts live in Redis, so the limit is enforced across all instances. An in-memory bucket per JVM means the effective limit is N × per-instance limit, which is rarely what you want.
  • Authenticated user as the key when available. IP only as fallback. This is the layered model from earlier.
  • Standard response headers. X-RateLimit-Remaining and Retry-After are what well-behaved clients look for. Sending them turns "the API is broken" support tickets into "the client backs off correctly".

The 429 response that helps clients

A throttled client needs to know two things: *how long to wait* and *whether it was their fault or a global throttle*. The Retry-After header (seconds or HTTP date) answers the first. A structured error body answers the second:

json
{
  "type": "https://errors.example.com/rate-limited",
  "title": "Too Many Requests",
  "detail": "Retry after 23 seconds",
  "scope": "user"
}

The scope field tells the client whether the limit is per-user (back off only this user's requests) or global (back off all requests). Without it, the client has to guess.

What to do for legitimate burst traffic

A user with a spreadsheet pasting 500 rows hits a rate limit. They are not abusive; they are just doing the thing the UI invited. Options:

1. Higher per-tenant limits with backpressure. The UI sees 429, queues the next request, retries with the Retry-After delay. The user sees a slower UI, not an error. 2. A batch endpoint. "Submit 500 things in one request" with its own (lower) rate limit. Beats trying to make 500 individual requests fit under the same limit. 3. A signed "burst token" issued by a separate slow operation, that allows N requests within a window. Useful for "we expect this user to do bulk work now".

The general principle: rate limiting should make abuse expensive and legitimate use *visibly degraded but functional*. A flat 429 with no recovery path makes the API feel broken.

What to monitor

  • 429 rate by endpoint. A spike on /login is credential stuffing; a spike on /api/v1/export is a power user. Different responses.
  • Top throttled users / keys. Often legitimate, sometimes a bug in a client they wrote.
  • Bucket utilization. Average tokens remaining tells you whether the limit is sized appropriately. If everyone consistently uses 5% of their budget, the limit is decorative; if everyone hovers near 100%, the limit is too tight.

What this setup deliberately does not do

  • No dynamic per-user limits. You can add them, but a simple uniform limit plus an override list (paid tiers, internal services) gets you 95% of the value with 10% of the complexity.
  • No leaky-bucket queueing. Bucket4j supports it; in practice the operational story is harder to reason about than "request is either accepted or rejected".
  • No global circuit-breaker behaviour. Rate limiting protects against *too many* requests; circuit breaking protects against requests to a *broken downstream*. Different layer, different tool.

The closing rule

Pick the key carefully, layer the limits (CDN → gateway → application), return useful headers, and put a graph on 429s. A rate limit is a contract with your clients; the better you communicate it, the less it feels like a wall and the more it feels like a working API.

Go deeper

Further reading

Source Code

Get the full project on GitHub

View repo →
#Spring Boot#Rate Limiting#Redis#Security

Stay in the Loop

Get the next tutorial in your inbox

Continue reading in Java & Spring Boot

Building REST APIs with Spring Boot: A Complete Guide

Design and build a production-ready REST API with Spring Boot — proper layering, DTOs, validation, error handling and testing.

Related tutorials