Rate Limiting in Spring Boot: Bucket4j, Resilience4j, and Custom Filters
Learn how to implement rate limiting in Spring Boot using Bucket4j, Resilience4j, and custom filters.
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Java 17+
- ✓Spring Boot 3.2+
- ✓Basic knowledge of Spring Web and servlet filters
- ✓Maven or Gradle build tool
โข Rate limiting protects APIs from abuse by controlling request rates per client โข Bucket4j uses token bucket algorithm for in-memory rate limiting with JCache support โข Resilience4j offers RateLimiter module with annotations and declarative configuration โข Custom filters with Spring's OncePerRequestFilter provide full control over rate limit logic โข Production deployments require distributed rate limiting via Redis or Hazelcast
Think of rate limiting like a nightclub bouncer. The club has a maximum capacity (rate limit). When people arrive, the bouncer counts them. Once the club is full, new arrivals must wait outside until someone leaves. Some clubs let in VIPs faster (priority queues), and some have different limits for different sections (endpoint-specific limits). Just like a bouncer prevents chaos inside, rate limiting prevents your server from being overwhelmed.
Rate limiting is one of those things you ignore until your API gets hammered by a misconfigured client or, worse, a DDoS attack. I've seen production systems go down because a single user's script sent 10,000 requests per second to a search endpoint that took 500ms each. The result? Database connection pool exhaustion, cascading failures, and a very angry CTO.
Spring Boot doesn't provide rate limiting out of the box, but the ecosystem offers several battle-tested libraries. In this article, we'll explore three approaches: Bucket4j (token bucket algorithm), Resilience4j (circuit breaker family), and custom servlet filters. Each has its trade-offs. Bucket4j is great for simple in-memory rate limiting with JCache support. Resilience4j integrates beautifully with Spring's annotation-driven programming model. Custom filters give you the most flexibility but require more boilerplate.
We'll cover real code examples, production pitfalls, and how to debug when things go wrong. By the end, you'll be able to choose the right approach for your use case and implement it with confidence.
Why Rate Limiting Matters in Spring Boot
Rate limiting is not just about preventing abuse. It's about protecting your system's resources. Without rate limiting, a single misbehaving client can consume all your database connections, fill up your thread pool, and cause a denial of service for legitimate users. In Spring Boot, rate limiting is often implemented at the API gateway or servlet filter level. The three most common approaches are:
- Bucket4j: Implements the token bucket algorithm. Tokens are added at a fixed rate, and each request consumes a token. If no tokens are available, the request is rejected. Bucket4j supports JCache, making it easy to integrate with distributed caches like Redis.
- Resilience4j: Provides a RateLimiter module that works with Spring Boot via annotations. It supports thread-based and semaphore-based isolation. It's part of a larger resilience toolkit including circuit breakers and retries.
- Custom Filters: Using Spring's OncePerRequestFilter, you can implement any rate limiting algorithm. This gives you full control over how limits are applied, stored, and enforced.
In production, you'll likely combine these approaches. For example, use a custom filter for global rate limiting and Resilience4j for per-endpoint limits.
What the Official Docs Won't Tell You
The official documentation for Bucket4j and Resilience4j covers basic setup, but there are several critical details they gloss over:
- Thread safety matters more than you think: Bucket4j's default implementation is thread-safe, but if you use custom filters with ConcurrentHashMap, you must handle synchronization properly. We once had a production issue where two threads simultaneously checked the rate limit, both passed, and then both updated the counter โ resulting in double the allowed requests.
- Distributed rate limiting is non-trivial: The official docs show in-memory examples. In a multi-instance deployment, each instance has its own bucket. A client can exhaust the limit on one instance, then hit another instance and get through. You need a distributed cache (Redis, Hazelcast) to share state.
- Rate limiting headers are expected by clients: RFC 6585 defines the 429 Too Many Requests status code. Clients expect headers like X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. Most libraries don't add these automatically โ you must implement them.
- Resilience4j RateLimiter is not a filter: It's an aspect that wraps method calls. It doesn't work with raw HTTP requests unless you integrate it with a servlet filter or WebFlux filter.
I've seen teams spend days debugging why their RateLimiter annotation didn't work on a controller method, only to realize they forgot to enable @EnableAspectJAutoProxy.
Bucket4j: Token Bucket with JCache
Bucket4j is a Java library that implements the token bucket algorithm. It's designed for high performance and supports JCache (JSR 107) for distributed caching. The basic idea: you have a bucket that holds tokens. Tokens are added at a fixed rate (e.g., 10 tokens per second). Each request consumes one token. If the bucket is empty, the request is rejected.
Bucket4j supports two modes: greedy and intervally. Greedy refills tokens as soon as they are consumed, while intervally refills at fixed time intervals. For most APIs, greedy is fine.
Here's how to integrate Bucket4j with Spring Boot:
- Add the dependency: com.github.vladimir-bukhtoyarov:bucket4j-core:8.7.0
- Create a Bucket bean with configuration
- Use a servlet filter to check the bucket before processing requests
One gotcha: Bucket4j's default configuration uses wall clock time. In distributed systems, clock skew can cause inconsistent rate limiting. Use NTP-synchronized clocks or Redis-backed buckets.
Resilience4j RateLimiter: Annotation-Driven Approach
Resilience4j's RateLimiter module provides a declarative way to rate limit method calls using annotations. It's part of the Resilience4j ecosystem, which includes CircuitBreaker, Retry, TimeLimiter, and Bulkhead. The RateLimiter supports two isolation strategies:
- Semaphore-based: Uses a semaphore to limit concurrent calls. Fast and non-blocking.
- Thread-based: Uses a separate thread pool. Supports timeouts but adds overhead.
To use it with Spring Boot:
- Add spring-boot-starter-aop and resilience4j-spring-boot3 dependencies
- Configure RateLimiter properties in application.yml
- Annotate controller methods with @RateLimiter(name = "myLimiter")
One important detail: Resilience4j RateLimiter applies at the method level, not the HTTP request level. If you have a controller method that handles multiple HTTP methods or paths, the rate limit applies to all of them. This can be confusing.
Also, Resilience4j does not automatically set HTTP response headers. You need to implement a custom exception handler to catch RequestNotPermitted exceptions and return 429 with proper headers.
Custom Filters: Full Control with OncePerRequestFilter
When you need complete control over rate limiting logic, custom filters are the way to go. Spring's OncePerRequestFilter ensures the filter executes once per request, even if the request goes through multiple filter chains (e.g., forward or include).
- Implement any rate limiting algorithm (token bucket, sliding window, fixed window)
- Use any storage backend (in-memory, Redis, Hazelcast, database)
- Add custom response headers and error messages
- Apply different limits based on request attributes (path, HTTP method, user role, geolocation)
The downside is more boilerplate and the responsibility of handling thread safety, caching, and eviction.
Here's a production-ready pattern: use a ConcurrentHashMap with Caffeine as the cache for per-client rate limiters. Caffeine supports automatic eviction of idle entries, preventing memory leaks. For distributed deployments, replace the map with Redis-backed cache using Redisson or Spring Cache.
One pattern I've used successfully: create a RateLimitService interface with implementations for in-memory and Redis. Use Spring profiles to switch between them for local development vs. production.
Distributed Rate Limiting with Redis
In production, you'll almost certainly need distributed rate limiting. When you have multiple instances of your Spring Boot application behind a load balancer, in-memory rate limiting breaks because each instance has its own counter. A client can send 100 requests to instance A, then 100 more to instance B, effectively doubling the limit.
Redis is the most common solution for distributed rate limiting. The token bucket algorithm can be implemented atomically using Redis Lua scripts. Redisson, a Redis client for Java, provides a ready-made RateLimiter implementation that uses Lua scripts under the hood.
Here's the approach: 1. Use Redisson's RRateLimiter with a distributed bucket 2. Configure the rate limit in Redis with a TTL 3. Use a servlet filter that checks the distributed bucket
- Redis latency adds 1-5ms per request. Keep it under 1ms by using Redis in the same region.
- If Redis goes down, you need a fallback. Either block all requests (fail-closed) or allow all requests (fail-open). Fail-open is risky but keeps the system running.
- Use Redis Sentinel or Cluster for high availability.
We once had a production incident where Redis became unavailable due to a network partition. Our fail-open strategy allowed a burst of traffic that overwhelmed the database. We switched to fail-closed with a circuit breaker pattern.
Testing Rate Limiting in Spring Boot
Testing rate limiting is tricky because it involves time-dependent behavior. You can't wait 60 seconds in a unit test for a rate limit to reset. Here are strategies:
- Unit tests with mocked time: Use Bucket4j's TimeMeter interface to control time. Implement a custom TimeMeter that allows you to advance time programmatically.
- Integration tests with TestRestTemplate: Send multiple requests and verify that the 429 status appears after the limit is exceeded. Use @DirtiesContext to reset state between tests.
- Load tests with Gatling or JMeter: Simulate real-world traffic patterns. Test concurrent requests from multiple clients.
- Property-based testing: Use libraries like jqwik to test that rate limiting works correctly under various configurations.
One common mistake: testing with a single client and assuming it works for all. Test with multiple concurrent clients to verify thread safety.
For Redis-based rate limiting, use Testcontainers to spin up a Redis instance in your integration tests. This ensures your tests match production behavior.
Monitoring and Debugging Rate Limiting in Production
Rate limiting can cause mysterious issues if not monitored properly. Common symptoms: users complaining about intermittent failures, support tickets about "random" 429 errors, or sudden drops in traffic.
Here's what to monitor:
- Rate limit violation count: Use Micrometer metrics to track how many requests are being rate limited. Bucket4j and Resilience4j both expose metrics.
- Rate limit configuration: Log the current rate limit configuration at startup. This helps debug when limits change unexpectedly.
- Client distribution: Track which clients are hitting rate limits most often. This can reveal misconfigured clients or abuse.
- Redis latency: If using Redis, monitor Redis response times. High latency can cause rate limiting to degrade.
For debugging, add a custom header like X-Debug-RateLimit with the current bucket state (tokens remaining, reset time). Only enable this in non-production environments or for specific IPs.
One production story: A client was getting 429 errors intermittently. We added debug headers and discovered their requests were being routed to different instances, each with its own in-memory rate limiter. The fix was switching to Redis-backed distributed rate limiting.
The 500% Traffic Spike That Took Down Our Payment Gateway
- Always rate limit write-heavy endpoints (POST, PUT, DELETE) regardless of CDN protection
- Use distributed rate limiting when you have multiple app servers
- Monitor rate limit metrics in production to detect abuse patterns early
curl -I https://api.example.com/endpoint | grep -i ratekubectl logs -l app=myapp --tail=50 | grep 'rate.limit.violations'| File | Command / Code | Purpose |
|---|---|---|
| RateLimitConfig.java | @Configuration | Why Rate Limiting Matters in Spring Boot |
| RateLimitFilter.java | public class RateLimitFilter extends OncePerRequestFilter { | What the Official Docs Won't Tell You |
| Bucket4jConfig.java | @Configuration | Bucket4j |
| RateLimitedController.java | @RestController | Resilience4j RateLimiter |
| AdvancedRateLimitFilter.java | public class AdvancedRateLimitFilter extends OncePerRequestFilter { | Custom Filters |
| RedisRateLimiterConfig.java | @Configuration | Distributed Rate Limiting with Redis |
| RateLimitTest.java | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) | Testing Rate Limiting in Spring Boot |
| RateLimitMetricsConfig.java | @Configuration | Monitoring and Debugging Rate Limiting in Production |
Key takeaways
Interview Questions on This Topic
Explain the token bucket algorithm and how it differs from a fixed window counter.
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
That's Spring Boot. Mark it forged?
6 min read · try the examples if you haven't