Rate Limiting with Redis in Spring Boot

1. Overview

Rate limiting helps you block clients from making too many API calls within a time period, preventing system overload or abuse. This article shows how to use Redis as a centralized counter, combined with Lua scripts to ensure atomicity when multiple application instances access it simultaneously. After reading, you’ll be able to write your own RateLimiter using fixed window and sliding window algorithms, integrate it into Spring Boot via an interceptor, and write tests to verify its behavior.

2. Why Choose Redis for Rate Limiting

When an application runs multiple instances (horizontal scaling), counters stored in each instance’s memory (for example, AtomicInteger) can’t stay synchronized. Redis solves this problem because:

  • It’s a centralized data store where every instance reads and writes in one place.
  • It supports TTL (EXPIRE) to automatically clean up old keys without needing a cron job.
  • It supports Lua scripts that run atomically on the server, avoiding race conditions when incrementing counters and checking limits.

Note: If your system has only one instance and doesn’t need to share state, using an in-memory limiter (for example, Guava’s RateLimiter) will be faster and simpler. Only use Redis when you need distributed rate limiting.

3. Setting Up Dependencies

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring.data.redis.host=localhost
spring.data.redis.port=6379

Spring Boot automatically configures RedisConnectionFactory and StringRedisTemplate based on these properties.

4. Fixed Window Algorithm

Fixed window divides time into fixed intervals (for example, every 60 seconds), counting the number of requests in each interval. If the limit is exceeded, the request is rejected.

4.1. Atomic Lua Script

Use a Lua script to combine INCR and EXPIRE into a single atomic command, preventing other requests from interfering between the two steps.

public class FixedWindowRateLimiter {

    private static final String SCRIPT = """
        local current = redis.call('INCR', KEYS[1])
        if current == 1 then
            redis.call('EXPIRE', KEYS[1], ARGV[1])
        end
        return current
        """;

    private final StringRedisTemplate redisTemplate;
    private final RedisScript<Long> redisScript;

    public FixedWindowRateLimiter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.redisScript = RedisScript.of(SCRIPT, Long.class);
    }

    public boolean isAllowed(String clientId, int limit, int windowSeconds) {
        String key = "rate:fixed:" + clientId;
        Long current = redisTemplate.execute(
                redisScript,
                List.of(key),
                String.valueOf(windowSeconds));
        return current != null && current <= limit;
    }
}

Explanation:

  • INCR KEYS[1] increments the key’s counter and returns the new value.
  • If current == 1 (first time creating the key), set the TTL to windowSeconds seconds.
  • The entire script runs atomically on the Redis server and won’t be interleaved by other requests, even with multithreading or multiple instances.
  • isAllowed returns true if the current number of requests hasn’t exceeded the limit.

4.2. Using in a Spring Boot Interceptor

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    private final FixedWindowRateLimiter rateLimiter;

    public RateLimitInterceptor(FixedWindowRateLimiter rateLimiter) {
        this.rateLimiter = rateLimiter;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                              HttpServletResponse response,
                              Object handler) throws IOException {
        String clientId = request.getRemoteAddr();
        boolean allowed = rateLimiter.isAllowed(clientId, 100, 60);
        if (!allowed) {
            response.setStatus(429);
            response.getWriter().write("Too many requests");
            return false;
        }
        return true;
    }
}

The interceptor takes the client’s IP as clientId, limiting to 100 requests per 60 seconds. If the limit is exceeded, it returns HTTP 429 and blocks the request from reaching the controller.

Note: Fixed window has a “burst at the boundary” problem: if a client sends 100 requests at the end of second 59 and 100 more at the start of second 60, there are actually 200 requests in 1 second even though each window is within the limit. If you need more precision, use sliding window.

5. Sliding Window Log Algorithm

Sliding window log stores the timestamp of each request in a Sorted Set, removing old timestamps on each check. It’s more accurate than fixed window but uses more memory because it stores each request.

public class SlidingWindowRateLimiter {

    private static final String SCRIPT = """
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local windowMs = tonumber(ARGV[2])
        local limit = tonumber(ARGV[3])

        redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)
        local count = redis.call('ZCARD', key)
        if count < limit then
            redis.call('ZADD', key, now, now)
            redis.call('PEXPIRE', key, windowMs)
            return 1
        end
        return 0
        """;

    private final StringRedisTemplate redisTemplate;
    private final RedisScript<Long> redisScript;

    public SlidingWindowRateLimiter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.redisScript = RedisScript.of(SCRIPT, Long.class);
    }

    public boolean isAllowed(String clientId, int limit, int windowMs) {
        String key = "rate:sliding:" + clientId;
        long now = System.currentTimeMillis();
        Long result = redisTemplate.execute(
                redisScript,
                List.of(key),
                String.valueOf(now),
                String.valueOf(windowMs),
                String.valueOf(limit));
        return result != null && result == 1;
    }
}

Explanation of each step in the Lua script:

  • ZREMRANGEBYSCORE deletes entries with a score (timestamp) less than now - windowMs, removing requests that have left the time window.
  • ZCARD counts the remaining entries in the Sorted Set—this is the number of requests in the current window.
  • If count < limit, add a new entry with score now, set the key’s TTL to windowMs (to auto-clean when there’s no traffic), and return 1 (allow).
  • If it’s full, return 0 (reject) without adding a new entry.
  • Result: the window slides continuously in real time with no boundary burst problem like fixed window.

6. Comparing the Two Algorithms

Criteria Fixed Window Sliding Window Log
Accuracy Low, with boundary burst High
Memory O(1) per key O(limit) per key
Complexity Simple More complex
Use case Coarse limiting, high traffic Sensitive APIs, need precision

If you’re unsure which algorithm to choose, start with fixed window—it’s simple, performant, and sufficient for most internal APIs.

7. Writing Tests with Testcontainers

To write tests that don’t depend on a pre-installed Redis, use Testcontainers to launch Redis in Docker.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@Testcontainers
class FixedWindowRateLimiterTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379);

    FixedWindowRateLimiter rateLimiter;

    @BeforeEach
    void setUp() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(
                redis.getHost(), redis.getMappedPort(6379));
        LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
        factory.afterPropertiesSet();

        StringRedisTemplate template = new StringRedisTemplate(factory);
        template.afterPropertiesSet();

        rateLimiter = new FixedWindowRateLimiter(template);
    }

    @Test
    void shouldBlockRequestsExceedingLimit() {
        String clientId = "client-A";
        int limit = 3;

        boolean r1 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r2 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r3 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r4 = rateLimiter.isAllowed(clientId, limit, 60);

        assertThat(r1).isTrue();
        assertThat(r2).isTrue();
        assertThat(r3).isTrue();
        assertThat(r4).isFalse();
    }
}

This test sends 4 consecutive requests with limit = 3. The first three requests are accepted (isTrue), and the fourth is rejected (isFalse) because it exceeds the limit in the same window. Testcontainers automatically starts and stops the Redis container, ensuring tests are independent of the CI machine’s environment.

8. Common Mistakes

  • Not setting TTL for keys: if you forget EXPIRE, the key will persist forever in Redis, causing memory leaks and the counter never resets.
  • Incrementing and checking the limit with two separate commands (GET then INCR): creates a race condition when multiple requests arrive at the same time, leading to actual overages. Always combine into a single Lua script or use atomic INCR.
  • Using keys that don’t distinguish clients: if you forget to include clientId in the key, all clients share one counter, blocking unintended users.
  • Sliding window log with extreme traffic: the Sorted Set bloats if the limit is high and many clients connect simultaneously. Consider using sliding window counter (combining two fixed windows) to save more memory.

9. Conclusion

Redis combined with Lua scripts is a popular choice for building distributed rate limiters: it’s simple, atomic, and easy to scale with the number of application instances. Fixed window works for most cases due to its simplicity, while sliding window log is suitable when you need precise control at time boundaries. Always thoroughly test behavior at the boundary before deploying to production, as this is where logic errors are most likely to occur.

redisrate-limitingspring-bootlua-scriptjava