Configurable Rate Limiter in Spring Boot


Why Do We Need Rate Limiting?
Let’s say you have an API that shortens URLs. Everything is going great until one day, a bot starts hitting your API thousands of times per minute. Your server struggles, real users get slow responses, and your database is overloaded. This is where rate limiting comes in.
Rate limiting helps:
✔️ Prevent abuse (e.g., spammers, bots)
✔️ Ensure fair usage (so one user doesn’t hog resources)
✔️ Protect your backend from overload
What We’re Building
We’ll create a configurable rate limiter where:
✅ You define limits in application.properties
(no hardcoded values)
✅ You can apply limits using @RateLimited
annotation
✅ It works per IP and per API endpoint
✅ It resets automatically after a set time
1. Define Rate Limits in application.properties
Instead of hardcoding limits in the code, we’ll define them in application.properties
:
rate-limiter.enabled=true
# Limits for different API endpoints
rate-limiter.limits.SHORTEN_URL.limit=50
rate-limiter.limits.SHORTEN_URL.timeFrameMinutes=1
rate-limiter.limits.ADVANCED_SHORTEN_URL.limit=30
rate-limiter.limits.ADVANCED_SHORTEN_URL.timeFrameMinutes=2
rate-limiter.limits.LINK_FOLIO.limit=20
rate-limiter.limits.LINK_FOLIO.timeFrameMinutes=5
What’s Happening Here?
The shorten URL API (
/shorten
) allows 50 requests per minute.The advanced shorten URL API allows 30 requests per 2 minutes.
The Link Folio API allows 20 requests per 5 minutes.
We can change these values anytime without redeploying the app.
2. Load Configurations in a Kotlin Class
Spring Boot provides a way to read these values using @ConfigurationProperties
:
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Configuration
@Configuration
@ConfigurationProperties(prefix = "rate-limiter")
class RateLimiterConfig {
var enabled: Boolean = true
var limits: Map<String, RateLimitProperties> = emptyMap()
data class RateLimitProperties(var limit: Int = 0, var timeFrameMinutes: Long = 0)
}
What’s Happening Here?
enabled
: Turns rate limiting on/off dynamically.limits
: A map of API names to their rate limits.Now, we can inject this
RateLimiterConfig
anywhere in the app.
3. Create a @RateLimited
Annotation
To make things easy, let’s create an annotation that we can apply to API methods:
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RateLimited(val service: String)
This allows us to do things like:
@RateLimited(service = "SHORTEN_URL")
@PostMapping("/shorten")
fun shortenUrl(@RequestBody request: UrlShortenRequest): ResponseEntity<String> {
return ResponseEntity.ok("Shortened URL")
}
Now, our rate limiter will know this API should be limited based on the SHORTEN_URL settings.
4. Build the Rate Limiter Logic
We need a service that will track API requests per IP address and per service type.
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.util.*
import java.util.concurrent.*
@Service
class RateLimiterService(private val config: RateLimiterConfig) {
private val requestMap = ConcurrentHashMap<String, ConcurrentHashMap<String, Pair<Int, LocalDateTime>>>()
fun isRateLimited(identifier: String, service: String): Boolean {
val limitConfig = config.limits[service] ?: return false
val currentTime = LocalDateTime.now()
synchronized(requestMap) {
val serviceMap = requestMap.getOrPut(identifier) { ConcurrentHashMap() }
val (requestCount, lastRequestTime) = serviceMap.getOrDefault(service, Pair(0, currentTime))
if (lastRequestTime.plusMinutes(limitConfig.timeFrameMinutes).isBefore(currentTime)) {
serviceMap[service] = Pair(0, currentTime)
return false
}
return requestCount >= limitConfig.limit
}
}
fun registerRequest(identifier: String, service: String) {
val limitConfig = config.limits[service] ?: return
if (isRateLimited(identifier, service)) {
throw RateLimitExceededException(identifier, limitConfig.limit)
}
val currentTime = LocalDateTime.now()
synchronized(requestMap) {
val serviceMap = requestMap.getOrPut(identifier) { ConcurrentHashMap() }
val (requestCount, _) = serviceMap.getOrDefault(service, Pair(0, currentTime))
serviceMap[service] = Pair(requestCount + 1, currentTime)
Timer().schedule(object : TimerTask() {
override fun run() {
synchronized(requestMap) {
requestMap[identifier]?.remove(service)
if (requestMap[identifier]?.isEmpty() == true) {
requestMap.remove(identifier)
}
}
}
}, TimeUnit.MINUTES.toMillis(limitConfig.timeFrameMinutes))
}
}
}
What’s Happening Here?
Tracks requests in
ConcurrentHashMap
(thread-safe).If the last request is older than the time limit, it resets the count.
If the limit is exceeded, it throws an exception.
Uses a timer to automatically remove expired entries.
5. Enforce Rate Limits with Spring AOP
Now, we’ll use Spring AOP to intercept methods with @RateLimited
.
First, Add the AOP Dependency
If you haven’t already, add this to build.gradle.kts
:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-aop")
}
Now, Create the AOP Aspect
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.springframework.stereotype.Component
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
@Aspect
@Component
class RateLimiterAspect(private val rateLimiterService: RateLimiterService, private val config: RateLimiterConfig) {
@Around("@annotation(rateLimited)")
fun enforceRateLimit(joinPoint: ProceedingJoinPoint, rateLimited: RateLimited): Any? {
if (!config.enabled) return joinPoint.proceed() // Skip if disabled
val request = (RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.request
val ipAddress = request?.remoteAddr ?: "UNKNOWN"
rateLimiterService.registerRequest(ipAddress, rateLimited.service)
return joinPoint.proceed()
}
}
6. Handle Errors Gracefully
If a user exceeds the limit, we should return a HTTP 429 Too Many Requests error.
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(RateLimitExceededException::class)
fun handleRateLimitExceeded(ex: RateLimitExceededException) =
ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(ex.message)
}
Real-World Example
Let’s say you have a public link shortener service. Without rate limiting, users can:
Flood your API with thousands of shorten requests per second.
Use bots to create spam links.
Crush your database with unlimited writes.
By adding @RateLimited(service = "SHORTEN_URL")
, you prevent spam, improve security, and ensure fair access.
Learning
🔹 Now your APIs are protected!
🔹 No more hardcoded limits – just update application.properties
.
🔹 Easily apply rate limits with @RateLimited
annotation.
That’s it for today. Happy coding…
Subscribe to my newsletter
Read articles from Romman Sabbir directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Romman Sabbir
Romman Sabbir
Senior Android Engineer from Bangladesh. Love to contribute in Open-Source. Indie Music Producer.