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
<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-RemainingandRetry-Afterare 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:
{
"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
/loginis credential stuffing; a spike on/api/v1/exportis 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
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
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.
Spring Boot + Kafka — Build a Real-Time Messaging System
Produce and consume Kafka messages from Spring Boot with proper serialization, error handling and consumer groups.
Spring Boot + Redis Caching — Make Your API 10× Faster
Cache hot reads with Redis and Spring's @Cacheable in minutes. Includes TTLs, eviction and key design tips.
