# Token bucket rate limiter with Redis and Java

```json metadata
{
  "schema_version": 2,
  "title": "Token bucket rate limiter with Redis and Java",
  "description": "Implement a token bucket rate limiter using Redis and Lua scripts in Java",
  "categories": ["docs","develop","stack","oss","rs","rc"],
  "tableOfContents": {"sections":[{"id":"overview","title":"Overview"},{"children":[{"id":"why-use-redis","title":"Why use Redis?"}],"id":"how-it-works","title":"How it works"},{"children":[{"id":"script-breakdown","title":"Script breakdown"},{"id":"why-atomicity-matters","title":"Why atomicity matters"}],"id":"the-lua-script","title":"The Lua script"},{"id":"installation","title":"Installation"},{"children":[{"id":"configuration-parameters","title":"Configuration parameters"},{"id":"rate-limit-keys","title":"Rate limit keys"},{"id":"script-caching-with-evalsha","title":"Script caching with EVALSHA"},{"id":"thread-safety","title":"Thread safety"}],"id":"using-the-java-class","title":"Using the Java class"},{"children":[{"id":"get-the-source-files","title":"Get the source files"},{"id":"start-the-demo-server","title":"Start the demo server"}],"id":"running-the-demo","title":"Running the demo"},{"id":"response-headers","title":"Response headers"},{"children":[{"id":"using-as-a-servlet-filter","title":"Using as a servlet filter"},{"id":"error-handling","title":"Error handling"}],"id":"customization","title":"Customization"},{"children":[{"id":"fixed-window-counter","title":"Fixed window counter"},{"id":"sliding-window-log","title":"Sliding window log"},{"id":"sliding-window-counter","title":"Sliding window counter"},{"id":"leaky-bucket-policing","title":"Leaky bucket (policing)"}],"id":"alternative-rate-limiting-algorithms","title":"Alternative rate limiting algorithms"},{"id":"learn-more","title":"Learn more"}]}

,
  "codeExamples": []
}
```


This guide shows you how to implement a distributed token bucket rate limiter using Redis and Lua scripts in Java with the [`Jedis`](https://redis.io/docs/latest/develop/clients/jedis) client library.

## Overview

Rate limiting is a critical technique for controlling the rate at which operations are performed. Common use cases include:

* Limiting API requests per user or IP address
* Preventing abuse and protecting against denial-of-service attacks
* Ensuring fair resource allocation across multiple clients
* Throttling background jobs or batch operations

The **token bucket algorithm** is a popular rate limiting approach that allows bursts of traffic while maintaining an average rate limit over time. This guide covers the Java implementation using the [`Jedis`](https://redis.io/docs/latest/develop/clients/jedis) client library, taking advantage of Java's `try-with-resources` for connection management, `JedisPool` for connection pooling, and checked exceptions for error handling.

## How it works

The token bucket algorithm works like a bucket that holds tokens:

1. **Initialization**: The bucket starts with a maximum capacity of tokens
2. **Refill**: Tokens are added to the bucket at a constant rate (for example, 1 token per second)
3. **Consumption**: Each request consumes one token from the bucket
4. **Decision**: If tokens are available, the request is allowed; otherwise, it's denied
5. **Capacity limit**: The bucket never exceeds its maximum capacity

This approach allows for burst traffic (using accumulated tokens) while enforcing an average rate limit over time.

### Why use Redis?

Redis is ideal for distributed rate limiting because:

* **Atomic operations**: Lua scripts execute atomically, preventing race conditions
* **Shared state**: Multiple application servers can share the same rate limit counters
* **High performance**: In-memory operations provide microsecond latency
* **Automatic expiration**: Keys can be set to expire automatically (though not used in this implementation)

## The Lua script

The core of this implementation is a Lua script that runs atomically on the Redis server. This ensures that checking and updating the token bucket happens in a single operation, preventing race conditions in distributed environments.

Here's how the script works:

```lua
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local refill_interval = tonumber(ARGV[3])
local now = tonumber(ARGV[4])

-- Get current state or initialize
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])

-- Initialize if this is the first request
if tokens == nil then
    tokens = capacity
    last_refill = now
end

-- Calculate token refill
local time_passed = now - last_refill
local refills = math.floor(time_passed / refill_interval)

if refills > 0 then
    tokens = math.min(capacity, tokens + (refills * refill_rate))
    last_refill = last_refill + (refills * refill_interval)
end

-- Try to consume a token
local allowed = 0
if tokens >= 1 then
    tokens = tokens - 1
    allowed = 1
end

-- Update state
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)

-- Return result: allowed (1 or 0) and remaining tokens
return {allowed, tokens}
```

### Script breakdown

1. **State retrieval**: Uses [`HMGET`](https://redis.io/docs/latest/commands/hmget) to fetch the current token count and last refill time from a hash
2. **Initialization**: On first use, sets tokens to full capacity
3. **Token refill calculation**: Computes how many tokens should be added based on elapsed time
4. **Capacity enforcement**: Uses `math.min()` to ensure tokens never exceed capacity
5. **Token consumption**: Decrements the token count if available
6. **State update**: Uses [`HMSET`](https://redis.io/docs/latest/commands/hmset) to save the new state
7. **Return value**: Returns both the decision (allowed/denied) and remaining tokens

### Why atomicity matters

Without atomic execution, race conditions could occur:

* **Double spending**: Two requests could read the same token count and both succeed when only one should
* **Lost updates**: Concurrent updates could overwrite each other's changes
* **Inconsistent state**: Token count and refill time could become desynchronized

Using [`EVAL`](https://redis.io/docs/latest/commands/eval) or [`EVALSHA`](https://redis.io/docs/latest/commands/evalsha) ensures the entire operation executes atomically, making it safe for distributed systems.

## Installation

Add the Jedis dependency to your project:

* If you use **Maven**:

  ```xml
  <dependency>
      <groupId>redis.clients</groupId>
      <artifactId>jedis</artifactId>
      <version>5.2.0</version>
  </dependency>
  ```

* If you use **Gradle**:

  ```groovy
  implementation 'redis.clients:jedis:5.2.0'
  ```

## Using the Java class

The `TokenBucket` class provides a thread-safe interface for rate limiting
([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/rate-limiter/java-jedis/TokenBucket.java)):

```java
import redis.clients.jedis.JedisPool;

public class Main {
    public static void main(String[] args) {
        // Create a Redis connection pool
        JedisPool jedisPool = new JedisPool("localhost", 6379);

        // Create a rate limiter: 10 requests per second
        TokenBucket limiter = new TokenBucket(10, 1, 1.0, jedisPool);

        // Check if a request should be allowed
        TokenBucket.RateLimitResult result = limiter.allow("user:123");

        if (result.allowed()) {
            System.out.printf("Request allowed. %.0f tokens remaining.%n", result.remaining());
            // Process the request
        } else {
            System.out.println("Request denied. Rate limit exceeded.");
            // Return 429 Too Many Requests
        }
    }
}
```

Jedis operations are synchronous and thread-safe when using `JedisPool`, which manages a pool of connections internally. The `allow()` method returns a `RateLimitResult` record containing both the decision and the remaining token count.

### Configuration parameters

* **capacity**: Maximum number of tokens in the bucket (controls burst size)
* **refillRate**: Number of tokens added per refill interval
* **refillInterval**: Time in seconds between refills

For example:
* `capacity=10, refillRate=1, refillInterval=1.0` allows 1 request per second with bursts up to 10
* `capacity=100, refillRate=10, refillInterval=1.0` allows 10 requests per second with bursts up to 100
* `capacity=60, refillRate=1, refillInterval=60.0` allows 1 request per minute with bursts up to 60

### Rate limit keys

The `key` parameter identifies what you're rate limiting. Common patterns:

* **Per user**: `user:{userId}` - Limit each user independently
* **Per IP address**: `ip:{ipAddress}` - Limit by client IP
* **Per API endpoint**: `api:{endpoint}:{userId}` - Different limits per endpoint
* **Global**: `global:api` - Single limit shared across all requests

### Script caching with EVALSHA

The Java implementation uses [`EVALSHA`](https://redis.io/docs/latest/commands/evalsha) for optimal performance. On first use, the Lua script is loaded into Redis with `SCRIPT LOAD`, and subsequent calls use the cached SHA1 hash. If the script is evicted from the cache, the class automatically falls back to [`EVAL`](https://redis.io/docs/latest/commands/eval) and reloads the script. The script loading uses `volatile` and synchronization to ensure thread safety across multiple threads.

```java
// The class handles script caching automatically.
// First call loads the script, subsequent calls use EVALSHA.
TokenBucket.RateLimitResult result1 = limiter.allow("user:123"); // Uses EVAL + caches
TokenBucket.RateLimitResult result2 = limiter.allow("user:123"); // Uses EVALSHA (faster)
```

### Thread safety

The `TokenBucket` class is thread-safe. You can share a single instance across your application, including from multiple threads in a servlet container or web framework:

```java
// Create a shared limiter instance
TokenBucket limiter = new TokenBucket(10, 1, 1.0, jedisPool);

// Safe to call from multiple threads
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 20; i++) {
    final int id = i;
    executor.submit(() -> {
        TokenBucket.RateLimitResult result = limiter.allow("shared:resource");
        System.out.printf("thread %d: allowed=%b remaining=%.0f%n",
                id, result.allowed(), result.remaining());
    });
}
executor.shutdown();
```

## Running the demo

### Get the source files

The demo consists of two Java files. Download them from the [`java-jedis` source folder](https://github.com/redis/docs/tree/main/content/develop/use-cases/rate-limiter/java-jedis) on GitHub, or grab them with `curl`:

```bash
mkdir rate-limiter-demo && cd rate-limiter-demo
BASE=https://raw.githubusercontent.com/redis/docs/main/content/develop/use-cases/rate-limiter/java-jedis
curl -O $BASE/TokenBucket.java
curl -O $BASE/DemoServer.java
```

### Start the demo server

A demonstration HTTP server is included to show the rate limiter in action
([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/rate-limiter/java-jedis/DemoServer.java)):

```bash
# Compile
javac -cp jedis-5.2.0.jar TokenBucket.java DemoServer.java

# Run the demo server
java -cp .:jedis-5.2.0.jar DemoServer
```

The demo provides an interactive web interface where you can:

* Submit requests and see them allowed or denied in real-time
* View the current token count
* Adjust rate limit parameters dynamically
* Test different rate limiting scenarios

The demo assumes Redis is running on `localhost:6379` but you can specify a different host and port using the `--redis-host HOST` and `--redis-port PORT` command-line arguments. Visit `http://localhost:8080` in your browser to try it out.

## Response headers

It's common to include rate limit information in HTTP response headers:

```java
int capacity = 10;
double refillInterval = 1.0;
TokenBucket limiter = new TokenBucket(capacity, 1, refillInterval, jedisPool);

TokenBucket.RateLimitResult result = limiter.allow("user:" + userId);

// Add standard rate limit headers
response.setHeader("X-RateLimit-Limit", String.valueOf(capacity));
response.setHeader("X-RateLimit-Remaining", String.valueOf((int) result.remaining()));
response.setHeader("X-RateLimit-Reset",
        String.valueOf(System.currentTimeMillis() / 1000 + (long) refillInterval));

if (!result.allowed()) {
    response.setHeader("Retry-After", String.valueOf((int) refillInterval));
    response.setStatus(429); // Too Many Requests
}
```

## Customization

### Using as a servlet filter

You can wrap the rate limiter as a servlet filter for use with any Java web framework:

```java
public class RateLimitFilter implements Filter {
    private TokenBucket limiter;

    @Override
    public void init(FilterConfig config) {
        JedisPool jedisPool = new JedisPool("localhost", 6379);
        limiter = new TokenBucket(10, 1, 1.0, jedisPool);
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpReq = (HttpServletRequest) req;
        HttpServletResponse httpRes = (HttpServletResponse) res;

        String key = "ip:" + httpReq.getRemoteAddr();
        TokenBucket.RateLimitResult result = limiter.allow(key);

        httpRes.setHeader("X-RateLimit-Remaining",
                String.valueOf((int) result.remaining()));

        if (!result.allowed()) {
            httpRes.setStatus(429);
            httpRes.getWriter().write("{\"error\": \"Rate limit exceeded\"}");
            return;
        }
        chain.doFilter(req, res);
    }
}
```

### Error handling

The `allow()` method may throw a `JedisException` if the Redis connection is lost. Wrap calls in try/catch blocks for production use:

```java
try {
    TokenBucket.RateLimitResult result = limiter.allow("user:123");
    // Handle result
} catch (JedisException e) {
    System.err.println("Rate limiter error: " + e.getMessage());
    // Fail open: allow the request when Redis is unavailable
    // Or fail closed: deny the request
}
```

## Alternative rate limiting algorithms

The token bucket algorithm above handles most use cases but Redis supports
other rate limiter patterns that might fit your requirements better. The table
below lists four other algorithms alongside token bucket and summarizes
their features:

| Algorithm | Memory | Accuracy | Burst behavior | Best for |
|---|---|---|---|---|
| [Token bucket](#how-it-works) | 1 key (hash) | Exact | Controlled bursts | APIs with bursty traffic |
| [Fixed window counter](#fixed-window-counter) | 1 key (string) | Approximate | 2x burst at boundaries | Simple API limits |
| [Sliding window log](#sliding-window-log) | O(n) entries | Exact | No bursts | High-value APIs, audit trails |
| [Sliding window counter](#sliding-window-counter) | 2 keys (string) | Near-exact | Smoothed boundaries | General-purpose APIs |
| [Leaky bucket (policing)](#leaky-bucket-policing) | 1 key (hash) | Exact | No bursts | Strict no-burst enforcement |

The sections below give example implementations of these other algorithms.
The three time-based algorithms call `redis.call('TIME')` inside the Lua
script to derive the current timestamp from the Redis server clock. This
eliminates clock drift when the limiter runs across multiple application
servers. The fixed window counter reads no clock: the key's TTL defines
the window.

### Fixed window counter

Counts requests within discrete, non-overlapping time intervals.
Simplest algorithm — one key per window, one `EVAL` round trip.

```java
import java.util.List;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

public class FixedWindowLimiter {

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

            local count = redis.call('INCR', key)
            if count == 1 then
                redis.call('EXPIRE', key, window)
            end

            local ttl = redis.call('PTTL', key)

            if count > limit then
                return {0, ttl}
            end
            return {1, ttl}
            """;

    public record Decision(boolean allowed, long retryAfterMs) {}

    @SuppressWarnings("unchecked")
    public static Decision allow(JedisPool pool, String key, int limit, int windowSeconds) {
        try (Jedis jedis = pool.getResource()) {
            List<Long> result = (List<Long>) jedis.eval(
                    SCRIPT,
                    List.of(key),
                    List.of(String.valueOf(limit), String.valueOf(windowSeconds)));

            boolean allowed = result.get(0) == 1L;
            return new Decision(allowed, allowed ? 0L : result.get(1));
        }
    }
}
```

**Trade-off**: A client can make 2x requests by sending `limit` requests
at the end of one window and `limit` requests at the start of the next.

### Sliding window log

Records the exact timestamp of every request in a sorted set.
Provides a true rolling window with no boundary bursts.

```java
import java.util.List;
import java.util.UUID;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

public class SlidingWindowLogLimiter {

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

            local t      = redis.call('TIME')
            local now    = tonumber(t[1]) + tonumber(t[2]) / 1e6
            local cutoff = now - window

            redis.call('ZREMRANGEBYSCORE', key, '-inf', cutoff)

            local count = redis.call('ZCARD', key)

            if count < limit then
                redis.call('ZADD', key, now, member)
                redis.call('EXPIRE', key, window * 2)
                return {1, 0}
            end

            local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
            local retry_after_ms = 0
            if oldest[2] then
                retry_after_ms = math.floor((tonumber(oldest[2]) + window - now) * 1000)
            end

            return {0, retry_after_ms}
            """;

    public record Decision(boolean allowed, long retryAfterMs) {}

    @SuppressWarnings("unchecked")
    public static Decision allow(JedisPool pool, String key, int limit, int windowSeconds) {
        try (Jedis jedis = pool.getResource()) {
            List<Long> result = (List<Long>) jedis.eval(
                    SCRIPT,
                    List.of(key),
                    List.of(String.valueOf(limit),
                            String.valueOf(windowSeconds),
                            UUID.randomUUID().toString()));

            return new Decision(result.get(0) == 1L, result.get(1));
        }
    }
}
```

**Trade-off**: Memory grows O(n) with request volume. Not ideal for
high-volume, high-cardinality rate limiting.

### Sliding window counter

Blends two fixed-window counters using a weighted average to approximate
a true sliding window. Near-exact accuracy with the same low memory
footprint as a fixed window. The two keys use hash tags so they map
to the same slot in Redis Cluster.

```java
import java.util.List;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

public class SlidingWindowCounterLimiter {

    private static final String SCRIPT = """
            local base   = KEYS[1]
            local limit  = tonumber(ARGV[1])
            local window = tonumber(ARGV[2])

            local t   = redis.call('TIME')
            local now = tonumber(t[1]) + tonumber(t[2]) / 1e6

            local window_num = math.floor(now / window)
            local elapsed     = (now % window) / window

            local curr_key = base .. ':' .. window_num
            local prev_key = base .. ':' .. (window_num - 1)

            local prev = tonumber(redis.call('GET', prev_key) or 0)
            local curr = tonumber(redis.call('GET', curr_key) or 0)

            local estimate = prev * (1 - elapsed) + curr

            if estimate >= limit then
                return {0, 0}
            end

            local new_count = redis.call('INCR', curr_key)
            if new_count == 1 then
                redis.call('EXPIRE', curr_key, window * 2)
            end

            return {1, 0}
            """;

    @SuppressWarnings("unchecked")
    public static boolean allow(JedisPool pool, String key, int limit, int windowSeconds) {
        try (Jedis jedis = pool.getResource()) {
            List<Long> result = (List<Long>) jedis.eval(
                    SCRIPT,
                    List.of("{" + key + "}"),
                    List.of(String.valueOf(limit), String.valueOf(windowSeconds)));

            return result.get(0) == 1L;
        }
    }
}
```

**Trade-off**: The weighted estimate may let slightly more or fewer
requests through than the exact limit. Negligible for most apps.

### Leaky bucket (policing)

A virtual bucket fills with incoming requests and drains at a fixed rate.
If the bucket is full, requests are rejected immediately. This is the
policing variant — requests are allowed or denied instantly with no delay.

```java
import java.util.List;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

public class LeakyBucketLimiter {

    private static final String SCRIPT = """
            local key       = KEYS[1]
            local capacity  = tonumber(ARGV[1])
            local leak_rate = tonumber(ARGV[2])

            local t   = redis.call('TIME')
            local now = tonumber(t[1]) + tonumber(t[2]) / 1e6

            local data      = redis.call('HGETALL', key)
            local level     = 0
            local last_leak = now

            if #data > 0 then
                for i = 1, #data, 2 do
                    if data[i] == 'level' then
                        level = tonumber(data[i+1])
                    elseif data[i] == 'last_leak' then
                        last_leak = tonumber(data[i+1])
                    end
                end
            end

            local elapsed = now - last_leak
            level = math.max(0, level - elapsed * leak_rate)

            if level + 1 > capacity then
                return {0, math.floor((level + 1 - capacity) / leak_rate * 1000)}
            end

            level = level + 1
            local ttl = math.ceil(capacity / leak_rate) + 1

            redis.call('HSET', key, 'level', level, 'last_leak', now)
            redis.call('EXPIRE', key, ttl)

            return {1, 0}
            """;

    public record Decision(boolean allowed, long retryAfterMs) {}

    @SuppressWarnings("unchecked")
    public static Decision allow(JedisPool pool, String key, int capacity, double leakRate) {
        try (Jedis jedis = pool.getResource()) {
            List<Long> result = (List<Long>) jedis.eval(
                    SCRIPT,
                    List.of(key),
                    List.of(String.valueOf(capacity), String.valueOf(leakRate)));

            return new Decision(result.get(0) == 1L, result.get(1));
        }
    }
}
```

**Trade-off**: Overflow traffic is rejected immediately. Clients must
handle `429 Too Many Requests` and retry with backoff.

## Learn more

* [EVAL command](https://redis.io/docs/latest/commands/eval) - Execute Lua scripts
* [EVALSHA command](https://redis.io/docs/latest/commands/evalsha) - Execute cached Lua scripts
* [Lua scripting](https://redis.io/docs/latest/develop/programmability/eval-intro) - Introduction to Redis Lua scripting
* [HMGET command](https://redis.io/docs/latest/commands/hmget) - Get multiple hash fields
* [HMSET command](https://redis.io/docs/latest/commands/hmset) - Set multiple hash fields
* [Jedis client](https://redis.io/docs/latest/develop/clients/jedis) - Redis Java client documentation


