Building a Rate Limiter and Throttling Layer Using Spring Boot and Redis
Learn to build a lightweight, in-app rate limiter using Spring Boot and Redis to prevent API overload, brute-force attacks, and accidental misuse.
Join the DZone community and get the full member experience.
Join For FreeImagine your backend API is stable, performant and deployed to production. Then someone writes a buggy frontend loop or a bot goes rogue, and suddenly your endpoint gets hit 100 times a second.
That’s how your server’s CPU spikes, your database becomes overloaded, response times shoot up, and eventually your application turns unusable for real users. Even well-architected systems can crumble under this kind of stress, which leads to unhappy customers and costly incidents.
That's why rate limiting isn't optional but essential for protecting APIs.
Rate limiting protects your application from brute-force attacks, accidental misuse, or backend overexposure. While tools like API gateways and external services can help, but sometimes you need more control directly inside your application logic. Having it in-app makes debugging easier, behavior more transparent, and rules highly customizable for each endpoint.
In this tutorial, we’ll build a simple and scalable rate limiter using:
- Java + Spring boot
- Redis for distributed storage
- Spring AOP for annotation-based control
No third-party rate-limiting libraries. No magic. Just clean, understandable code.
What We’re Building
We'll create a custom annotation-based rate limiter from scratch, complete with:
@RateLimit(limit = 5, timeWindowSeconds = 60) annotation- Redis-backed logic to store and check request counts
- A Spring AOP-based layer that intercepts annotated endpoints
- A custom error handler to send back 429 responses
- Bonus: Works with distributed servers since Redis is centralized
Let’s dive into each part.
Define the @RateLimit Annotation
This is the annotation you’ll add to any controller method you want to protect. You can apply different limits to different endpoints without changing the controller logic.
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
int limit();
int timeWindowSeconds();
}
Explanation:
@Target(ElementType.METHOD): You can use it only on methods.@Retention(RetentionPolicy.RUNTIME): It's available during runtime (so AOP can read it).- Parameters like
limitandtimeWindowSecondsmake it reusable and flexible
Example usage:
@RateLimit(limit = 5, timeWindowSeconds = 60)
@GetMapping("/api/data")
public String getData() {
return "Hello World!";
}
RedisRateLimiter—Central Logic for Request Tracking
This is the service that talks to Redis and handles the count logic.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
public class RedisRateLimiter {
@Autowired
private StringRedisTemplate redisTemplate;
public boolean isAllowed(String key, int limit, int timeWindowSeconds) {
Long currentCount = redisTemplate.opsForValue().increment(key);
if (currentCount != null && currentCount == 1) {
redisTemplate.expire(key, Duration.ofSeconds(timeWindowSeconds));
}
return currentCount <= limit;
}
}
Explanation:
- We generate a unique Redis key for each user + endpoint combination
- We used
INCR(increment) the count each time the endpoint is hit. - If it’s the first hit, we also set an expiration (TTL).
- If the count exceeds the limit, we return
false.
Bonus: Redis handles expiration automatically. No cleanup needed.
RateLimitAspect—Interceptor Logic With Spring AOP
This uses Spring AOP to intercept any method with @RateLimit.
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
@Aspect
@Component
public class RateLimitAspect {
@Autowired
private RedisRateLimiter redisRateLimiter;
@Autowired
private HttpServletRequest httpServletRequest;
@Around("@annotation(com.service.ratelimiter.RateLimit)")
public Object rateLimit(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RateLimit rateLimit = method.getAnnotation(RateLimit.class);
String clientIp = httpServletRequest.getRemoteAddr();
String redisKey = "ratelimit:"+clientIp+":"+method.getName();
boolean allowed = redisRateLimiter.isAllowed(redisKey, rateLimit.limit(), rateLimit.timeWindowSeconds());
if (!allowed) throw new RateLimitExceedException("Too Many Requests. Please try again.");
return joinPoint.proceed();
}
}
Why AOP?
Spring AOP allows us to enforce the logic without changing our controller code. In this case It keeps rate limiting:
- Reusable (Any method, Any controller)
- Centralized (Logic in one place)
- Transparent (No business logic impact)
Explanation:
- It grabs the annotation’s values (
limit,timeWindowSeconds). - Constructs a Redis key specific to that client and endpoint.
- Checks if the request should go through or be blocked.
RateLimitExceededException—Custom Exception
public class RateLimitExceedException extends RuntimeException {
public RateLimitExceedException(String message) {
super(message);
}
}
Why custom?
- Makes it easy to catch and handle just this case.
- Cleaner than using generic exceptions.
GlobalExceptionHandler—Send Proper 429 Response
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
@ControllerAdvice
public class ExceptionHandler {
@org.springframework.web.bind.annotation.ExceptionHandler(RateLimitExceedException.class)
public ResponseEntity<String> handleRateLimitExceeedException(RateLimitExceedException rateLimitExceedException) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(rateLimitExceedException.getMessage());
}
}
Why this matters:
- 429 Too Many Requests is the proper HTTP code
- Makes our API more predictable and frontend-friendly
- Without it, Spring would return a generic 500 error
DemoControllerTry It Yourself
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class DemoController {
@RateLimit(limit = 5, timeWindowSeconds = 60)
@GetMapping("/api/data")
public String getData() {
return "Hello World!";
}
}
Try it:
- Hit this endpoint 6 times in a minute using Postman or curl.
- The 6th request should return:
{ "error": "Too many requests. Please wait and try again." }
Running Redis Locally (Docker)
`docker run --name redis -p 6379:6379 redis`
Add to application.properties:
spring.application.name=ratelimiter
server.port=9090
spring.redis.host=localhost
spring.redis.port=6379
Bonus Ideas for Scaling
- Use user ID or API token instead of IP for multi-user apps.
- Add metrics (
rate_limit_blocked_total) to Prometheus/Grafana. - Use Redis Lua scripts for atomic increments + TTL setting
- Add configurable backoff strategy (e.g., exponential retry)
Why Not Just Use Spring Cloud Gateway or Bucket4j?
Both are great tools, but they add complexity.
For smaller services or internal APIs, a lightweight in-app solution gives:
- More transparency
- Fewer dependencies
- Easier debugging and customization
This approach also helps you understand how rate limiting really works behind the scenes.
Final Thoughts
This rate limiter shows how much power and control you have with just Spring Boot and Redis.
With fewer than 200 lines of code, you can:
- Protect your services from overload
- Improve system resilience
- Scale across nodes with centralized tracking
Beyond the code, the real takeaway is understanding the pattern itself. Once you grasp how rate limiting works internally, you can extend it further by adding features like user-based quotas, adaptive backoff strategies, or real-time monitoring dashboards. These small enhancements can make your system not only more resilient but also smarter in handling unpredictable workloads.
Want the Code?
GitHub Repo: VivekRajyaguru/spring-redis-ratelimiter
Published at DZone with permission of Vivek Rajyaguru. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments