What token bucket rate limiting is and how it compares with window counters
This section is for backend and platform engineers running public or multi-tenant REST APIs on Spring Boot. Rate limiting (also called API throttling) caps how many requests a client can make over time. Public APIs use it for three reasons: to protect databases and downstream services from overload, to enforce the quota each plan pays for, and to stop one noisy client from using capacity that other tenants need.
A token bucket holds up to capacity tokens. Each request uses one token, and when the bucket is empty the request is rejected with 429 Too Many Requests. Tokens come back at a refill rate:
- Greedy refill adds tokens continuously. At 100 per minute, one token returns about every 600 ms.
- Interval refill adds all 100 tokens at once when each minute ends.
Capacity sets the largest burst a client can send. Refill rate sets the sustained throughput.
Illustrative estimate. Assume a limit of 100 requests per minute. A client sends 100 requests at 00:59 and another 100 at 01:00.
| Algorithm | Allowed in that ~1-second span | Why |
|---|---|---|
| Fixed window | ~200 | The counter resets at 01:00 |
| Sliding window | ~100 | The previous minute's requests still count against the limit |
| Token bucket (greedy) | ~101 | The 00:59 burst emptied the bucket, and only about one token refilled in a second |
Fixed windows can let through twice the limit at a window boundary. Sliding windows and token buckets both prevent this. The token bucket also lets you configure the allowed burst size directly.
Why per-instance buckets fail and how Bucket4j stores state in Redis
If you keep buckets in each instance's memory, every replica enforces the limit independently. With three replicas behind a round-robin load balancer and a limit of 100 requests per minute, a client's traffic is spread across three separate buckets, so it can get close to 300 requests through. The real figure depends on routing. Sticky sessions push it back toward 100. Uneven weighting lands somewhere in between. Autoscaling changes it every time the replica count changes. That makes the limit you configured hard to reason about and impossible to publish as a contract.
Bucket4j addresses this by moving bucket state out of the JVM. A proxy manager maps each rate-limit key to a bucket whose state is serialized under a Redis key. The application holds only a lightweight proxy. On each request, the consume operation runs against Redis, and the backend applies it atomically. Depending on the integration, that is done either with compare-and-swap or with a server-side script. As a result, two replicas consuming from the same bucket at the same moment cannot both spend the last token.
client ──► load balancer
│
┌─────────┼─────────┐
▼ ▼ ▼
┌───────┐ ┌───────┐ ┌───────┐
│app #1 │ │app #2 │ │app #3 │
│limiter│ │limiter│ │limiter│
│filter │ │filter │ │filter │
└───┬───┘ └───┬───┘ └───┬───┘
└─────────┼─────────┘
▼
┌─────────────┐
│ Redis │
│ bucket keys │
└─────────────┘
Bucket4j has Redis integrations for Lettuce, Jedis and Redisson. Module names and supported client versions have changed over time, so check the current Bucket4j documentation before picking a dependency. Pick the integration that matches the Redis client your service already uses. That way you don't end up running two connection pools.
┌─────────┐
│ client │
└────┬────┘
│ requests
▼
┌───────────────┐
│ load balancer │
└───────┬───────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ app #1 │ │ app #2 │ │ app #3 │
│ limiter │ │ limiter │ │ limiter │
│ filter │ │ filter │ │ filter │
│ proxy mgr │ │ proxy mgr │ │ proxy mgr │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ consume │ consume │ consume
└─────────────┼─────────────┘
▼
┌─────────────────────────────┐
│ Redis │
│ rate-limit key → bucket │
│ state (tokens, last refill)│
│ consume applied atomically │
│ (CAS or server-side script)│
└─────────────────────────────┘
Every replica's limiter filter consumes tokens through a Bucket4j proxy manager from one bucket per rate-limit key stored in shared Redis, so the configured limit holds no matter how the load balancer routes requests.
Choosing a trustworthy rate-limit key
The bucket is only as fair as the key that selects it. Prefer an identity the server has already verified: the API key after it has been validated, or the authenticated principal from the security context. These identities are stable across IP changes, and they map directly to billing and support conversations. Use the client IP only for anonymous endpoints such as login, signup or public search, where no identity exists yet.
When you do use the IP, do not read X-Forwarded-For blindly. Any client can send that header, so an attacker can claim a new address on every request and get a fresh bucket each time. Only honor it when your own load balancer or ingress sets or overwrites it, and take the address that proxy appended, not the leftmost value. IP keys also over-limit legitimate users. Carrier-grade NAT, corporate egress gateways and university networks can put thousands of people behind one address, so set IP limits generously and treat them as abuse protection rather than quota enforcement.
No key is attacker-proof. Free accounts, leaked API keys and residential proxy pools let a determined client spread load across many buckets. Pair per-key limits with coarser controls such as per-tenant ceilings and signup friction.
Namespace keys so they can be read and cleared on purpose, for example rl:{tenant}:{plan}:{route}:{principal}. Keep these keys away from cache data. Use a dedicated Redis instance or logical database configured with noeviction. Under an allkeys-lru policy, Redis can silently evict a bucket under memory pressure, and that resets the client's limit.
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.servlet.HandlerMapping;
public final class RateLimitKeyResolver {
// Builds rl:{tenant}:{plan}:{route}:{principal}. Tenant and plan come from the
// validated API key or account record, never from client-supplied headers.
public String resolve(HttpServletRequest request, String tenant, String plan) {
// Use the route template (/v1/orders/{id}), not the raw URI, so each endpoint
// gets one bucket per client instead of one per distinct URL.
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
String route = pattern != null ? pattern.toString() : "unmatched";
return String.join(":", "rl", tenant, plan, route, principal(request));
}
private String principal(HttpServletRequest request) {
// 1. API key ID, set by the auth filter only after the key was validated.
// Store the key's ID, never the secret itself, in Redis.
Object apiKeyId = request.getAttribute("auth.apiKeyId");
if (apiKeyId != null) return "key-" + apiKeyId;
// 2. Authenticated principal from the Spring Security context.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated()
&& !(auth instanceof AnonymousAuthenticationToken)) {
return "user-" + auth.getName();
}
// 3. Anonymous fallback. getRemoteAddr() reflects X-Forwarded-For only when
// server.forward-headers-strategy is enabled behind a trusted proxy.
// Never parse the header by hand here. Replace ':' so IPv6 can't break the key layout.
return "ip-" + request.getRemoteAddr().replace(':', '_');
}
}
A Spring key resolver that prefers a validated API key ID, then the authenticated principal, and falls back to the proxy-resolved client IP. It namespaces the result as rl:{tenant}:{plan}:{route}:{principal}.
How to implement the limiter in Spring Boot
Build the limiter from three pieces. A proxy manager wraps your Redis client and resolves a bucket by key. Each call runs an atomic compare-and-swap or script on the server, so concurrent instances never read stale token counts. A bucket configuration describes the bandwidths for a plan. A resolver maps each request to a key and a configuration. Create the proxy manager once as a singleton bean. Pass the configuration lazily, through a supplier, so Redis stores it only when the key first appears.
Enforce the limit in a servlet filter, or a WebFilter on WebFlux. Rejecting traffic before deserialization, validation and database calls is the reason to limit at all. If the key depends on the authenticated principal, the filter must run after Spring Security has populated the security context. Register it inside the security filter chain, positioned after the authentication filter. Do not give it a global order that runs before the chain, because there the principal is still null and every caller falls back to the IP key.
| Approach | Knows matched route | Runs before body parsing | Best for |
|---|---|---|---|
Servlet filter / WebFilter |
No (path matching only) | Yes | Global or per-client quotas |
| Handler interceptor | Yes | Yes | Route-specific costs |
| Annotation + AOP | Yes | No | Fine-grained, declarative limits |
For each request, use the consumption probe instead of a boolean try-consume. When it fails, return 429. Set Retry-After to the probe's nanoseconds-to-refill converted to seconds and rounded up, because rounding down invites a retry that fails again. On every response, emit remaining tokens and limit in X-RateLimit-* headers. These headers are a convention, not a finalized standard, so document their exact names and semantics in your API reference.
// Not a @Component: Boot would also register it globally, before Spring Security.
// Add it to the chain instead: http.addFilterAfter(filter, BearerTokenAuthenticationFilter.class)
public class RateLimitFilter extends OncePerRequestFilter {
private final ProxyManager<String> buckets; // singleton bean backed by Redis
private final PlanResolver plans; // your code: request -> key, plan, limit
public RateLimitFilter(ProxyManager<String> buckets, PlanResolver plans) {
this.buckets = buckets;
this.plans = plans;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
// Runs after authentication, so the principal is populated here.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String key = plans.keyFor(req, auth); // principal id if authenticated, else client IP
// Supplier is lazy: config is written to Redis only when the key is first seen.
ConsumptionProbe probe = buckets.builder()
.build(key, () -> plans.configFor(auth))
.tryConsumeAndReturnRemaining(1);
// Convention, not a standard: document these names and meanings for consumers.
res.setHeader("X-RateLimit-Limit", String.valueOf(plans.limitFor(auth)));
res.setHeader("X-RateLimit-Remaining", String.valueOf(probe.getRemainingTokens()));
if (probe.isConsumed()) {
chain.doFilter(req, res); // only now do body parsing and DB work happen
return;
}
// Round up: a rounded-down Retry-After invites a retry that fails again.
long nanos = probe.getNanosToWaitForRefill();
long seconds = Math.max(1, (nanos + 999_999_999L) / 1_000_000_000L);
res.setHeader("Retry-After", String.valueOf(seconds));
res.setStatus(429);
}
}
A Spring Security–chained servlet filter resolves a Redis-backed Bucket4j bucket per principal, uses the consumption probe to set remaining-quota headers, and returns 429 with a rounded-up Retry-After before any request body or database work runs.
Tiered plans, multiple bandwidths and Redis key hygiene
A plan is usually two bandwidths on one bucket: a small capacity with fast refill to absorb bursts (for example, 20 requests per second) and a larger capacity with slow refill to cap sustained use (for example, 10,000 per hour). A request must take a token from both. Without the second bandwidth, a client could stay at its burst rate all day.
Resolve the plan per request from a cached lookup of the client's tier, and pass it to the proxy manager as the configuration supplier. Because the plan lives in data, a tier change is a database update, not a redeploy. However, Bucket4j only applies that supplier when the key does not exist in Redis yet. Existing buckets keep their stored configuration. To apply a new plan to an existing bucket, call replaceConfiguration with a TokensInheritanceStrategy:
RESETrefills the bucket.AS_ISandPROPORTIONALLYcarry over the current balance.
Choose deliberately, because the strategy decides whether an upgrade grants an immediate burst. You can also delete the key, which effectively resets it.
Set expiration on bucket keys so idle clients don't leave buckets behind. Once a bucket has been idle long enough to refill to full capacity, its stored state is equivalent to a fresh bucket, so a TTL of at least that full-refill time loses nothing.
Illustrative estimate: assume 2 million active keys and about 200 bytes each (serialized state plus Redis per-key overhead). That comes to roughly 400 MB.
Heavy clients and global limits create hot keys. In Redis Cluster, each key maps to a single slot, so all traffic for that key lands on one shard. Contention makes this worse: Bucket4j's Redis backends use compare-and-swap, so concurrent writers retry and add round trips to the shard that is already saturated. For a global limit, split it into N sub-buckets with capacity/N each and spread them across slots.
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.BucketConfiguration;
import io.github.bucket4j.TokensInheritanceStrategy;
import io.github.bucket4j.distributed.proxy.ProxyManager;
import java.time.Duration;
public class PlanLimiter {
// Plan rows live in the database; the lookup is cached so this is cheap per request.
public record Plan(long burstPerSecond, long sustainedPerHour) {}
public interface PlanLookup { Plan forClient(String clientId); }
private final ProxyManager<String> proxyManager; // Redis-backed; configure key expiration when building it
private final PlanLookup plans;
public PlanLimiter(ProxyManager<String> proxyManager, PlanLookup plans) {
this.proxyManager = proxyManager;
this.plans = plans;
}
static BucketConfiguration toConfig(Plan p) {
return BucketConfiguration.builder()
// Short window: absorbs bursts, refills fast.
.addLimit(Bandwidth.builder().capacity(p.burstPerSecond())
.refillGreedy(p.burstPerSecond(), Duration.ofSeconds(1)).build())
// Long window: caps sustained use. A request must pass both.
.addLimit(Bandwidth.builder().capacity(p.sustainedPerHour())
.refillGreedy(p.sustainedPerHour(), Duration.ofHours(1)).build())
.build();
}
public boolean tryAcquire(String clientId) {
// The supplier is only used if the key does not exist in Redis yet.
Bucket bucket = proxyManager.builder()
.build("rl:" + clientId, () -> toConfig(plans.forClient(clientId)));
return bucket.tryConsume(1);
}
// Call after a tier change: existing keys keep their stored config otherwise.
public void applyPlanChange(String clientId) {
Bucket bucket = proxyManager.builder()
.build("rl:" + clientId, () -> toConfig(plans.forClient(clientId)));
// AS_IS keeps the current balance, so an upgrade does not grant an instant burst.
bucket.replaceConfiguration(toConfig(plans.forClient(clientId)), TokensInheritanceStrategy.AS_IS);
}
}
A two-bandwidth plan loaded per client from data, with replaceConfiguration and AS_IS used to apply a tier change to a bucket that already exists in Redis.
Failure modes and when to avoid application-level limiting
Every limited request now makes a Redis round trip, so the limiter adds latency and becomes a hard dependency on the critical path. Set connect and command timeouts in the low milliseconds. A limiter that waits seconds for Redis turns a cache problem into an API outage.
When Redis is unreachable, pick a policy deliberately:
| Policy | Behaviour | Risk |
|---|---|---|
| Fail-open | Allow all requests | Backend absorbs unlimited load during the outage |
| Fail-closed | Reject with 503 or 429 | Redis outage becomes a full API outage |
| Local fallback | In-memory bucket at roughly limit ÷ instance count | Approximate when autoscaling changes the instance count |
Replication and time also affect accuracy. Redis replication is asynchronous, so a failover can lose recent bucket writes and briefly hand out extra tokens. If refill is calculated from the application node's clock, skew between nodes makes refill uneven. Keep NTP healthy.
Rejections are not free. Each 429 still costs a TLS handshake, a Redis call and a log line. Do not consume tokens on rejected requests. Otherwise a client that retries steadily never recovers. Clients that ignore Retry-After retry in tight loops, which produces retry storms. Add jitter guidance to your API docs and consider escalating penalties for abusive keys.
When to avoid it
Application-level limiting does not stop volumetric DDoS, because the traffic has already consumed bandwidth and connections before your code runs. Alternatives:
- Edge or gateway limiting for coarse per-IP protection.
- In-memory limits for single-instance services.
- A layered combination: coarse limits at the edge, per-key limits in Bucket4j.
Each extra layer has its own limits and windows, which makes behaviour harder to reason about. Document which layer returned a given 429.
Testing, observability and impact on engineering cost
Testing the limiter
Unit tests should never sleep. Feed the bucket a controllable time source so a test can consume all tokens, advance the clock by a known interval, and assert exactly how many tokens came back. That makes refill math testable down to the token.
Integration tests need a real Redis in a disposable container, because mocks hide serialization, key expiry and compare-and-swap retries. Add a concurrency test that starts two application instances against the same Redis and fires parallel requests through both. Assert that total successes equal the bucket capacity, not double it. If that assertion fails, the limit is not actually shared.
What to measure
Emit allowed and rejected counters tagged by key class (anonymous, API key, plan tier) and route, along with a latency histogram for the Redis call on the limiter path. Rejections concentrated on a few keys usually mean abuse. Rejections spread across many paying keys on one route usually mean the limit is misconfigured. Rising Redis latency points to the limiter itself.
Cost
Illustrative estimate (assumed numbers, not a benchmark): Redis ops/sec ≈ peak requests/sec × limiter calls per request × (1 + CAS retry rate). At an assumed 2,000 rps, 1 call per request and a 5% retry rate, that is about 2,100 ops/sec.
Beyond Redis capacity, budget for:
- One network round trip of added latency per request.
- Versioned plan configuration.
- Alerts that someone has to own on call.
Where it saves time
The return comes from three things:
- One shared library replaces ad hoc limits written separately in each service.
- Plan changes ship as configuration rather than code.
- The metrics above turn a vague "clients are getting 429s" report into a specific key, route and cause.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to spring boot rate limiting, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.