00:00

Rate Limiting Pattern in Microservices

In a microservices architecture, many independent services and clients send requests to each other all day long. Keeping that traffic stable and fair is essential. One of the most important patterns for this is rate limiting. It acts as a traffic officer for your APIs, preventing overload and keeping the experience good for every user.

This guide explains how rate limiting works, compares the main algorithms, and shows working Java examples: a token bucket and sliding window in plain Java, a Spring Boot filter, Spring Cloud Gateway with Redis, Resilience4j, and distributed limits shared across many instances.

1. What Is the Rate Limiting Pattern?

The rate limiting pattern controls how much traffic a client can send to a service within a given time window. It sets a limit on how many requests a user, service or IP address may make, for example 100 requests per minute. Once the limit is reached, further requests are rejected, delayed or answered with an error until the window resets.

Rate limiting is not about denying service, but about protecting it. Without it, one misbehaving client, a sudden traffic spike or a malicious attack can use up all available resources and slow down or crash the service for everyone.

Think of a toll gate on a motorway that lets only a certain number of cars through per minute. Traffic keeps flowing behind the gate, instead of jamming the whole road.

2. Why Rate Limiting Is Crucial in Microservices

In a monolith, resource use is managed in one place. In a distributed microservices system, the need for rate limiting grows, because many clients and services share the same back ends:

ReasonHow rate limiting helps
Resource protectionStops any single consumer from using up CPU, memory or database connections
Cost controlIn the cloud, usage means money; limits stop runaway clients from creating huge bills
SecurityA first line of defense against denial-of-service and brute-force attacks, such as password guessing
Fair usage and quotasShares capacity fairly between clients, and enables plans such as free and premium tiers
ReliabilitySmooths out spikes, so performance stays consistent for everyone
Protecting downstream servicesKeeps your service from overwhelming slower dependencies or paid third-party APIs

Several patterns deal with “too much load”. They are related but answer different questions:

PatternQuestion it answersTypical reaction
Rate limitingHow many requests may this client make per time window?Reject extra requests with 429 Too Many Requests
ThrottlingHow do we slow traffic down instead of rejecting it?Queue or delay requests
Load sheddingIs the whole service overloaded right now?Drop low-priority work to save the rest
BulkheadHow many calls may run at the same time?Limit concurrent calls per dependency
Circuit breakerIs the service we call failing?Stop calling it for a while

In everyday speech, “throttling” is often used to mean rate limiting. The important difference is whether extra requests are rejected (rate limiting) or slowed down (throttling).

4. Common Rate Limiting Algorithms

Several algorithms can enforce a rate limit. Each has its own strengths:

AlgorithmHow it worksAllows bursts?Good for
Token bucketA bucket fills with tokens at a steady rate up to a maximum. Each request takes a token; no token means the request is rejectedYes, up to the bucket sizeMost APIs; the most widely used
Leaky bucketRequests enter a queue that “leaks” (is processed) at a constant rate. A full queue rejects new requestsNo; output is perfectly smoothProtecting systems that need an even flow
Fixed window counterCounts requests in fixed windows, such as each calendar minute, and resets at the start of each windowYes, at window edgesSimple limits where small bursts are acceptable
Sliding window logStores the timestamp of every request and counts those within the last windowNoExact limits for low traffic; uses more memory
Sliding window counterCombines the current and previous window counts, weighted by time, to estimate a rolling windowVery smallAccurate limits with little memory

4.1 The fixed-window edge problem

With a limit of 100 requests per minute, a fixed window resets at 12:01:00. A client can send 100 requests at 12:00:59 and another 100 at 12:01:01: 200 requests in two seconds, while each window only counted 100. Sliding windows and token buckets avoid this problem.

Fixed window edge burst
 Window 12:00-12:01          Window 12:01-12:02
|--------------------------|--------------------------|
                      100 requests | 100 requests
                       at 12:00:59 | at 12:01:01
                     <- 200 requests in 2 seconds ->

5. Building a Token Bucket in Plain Java

The token bucket is easy to understand and to build. The bucket starts full. Tokens are added back continuously, and each request takes one:

TokenBucket.java
package com.example.ratelimit;

import java.time.Duration;

public class TokenBucket {

    private final long capacity;          // maximum burst size
    private final double tokensPerNano;   // refill speed
    private double availableTokens;
    private long lastRefillTime;

    public TokenBucket(long capacity, long refillTokens, Duration refillPeriod) {
        this.capacity = capacity;
        this.tokensPerNano = (double) refillTokens / refillPeriod.toNanos();
        this.availableTokens = capacity;
        this.lastRefillTime = System.nanoTime();
    }

    public synchronized boolean tryConsume() {
        refill();
        if (availableTokens >= 1) {
            availableTokens -= 1;
            return true;    // request allowed
        }
        return false;       // bucket empty: reject
    }

    private void refill() {
        long now = System.nanoTime();
        double newTokens = (now - lastRefillTime) * tokensPerNano;
        availableTokens = Math.min(capacity, availableTokens + newTokens);
        lastRefillTime = now;
    }
}
Usage
// 100 requests per minute, with bursts of up to 100
TokenBucket bucket = new TokenBucket(100, 100, Duration.ofMinutes(1));

if (bucket.tryConsume()) {
    // handle the request
} else {
    // respond with 429 Too Many Requests
}

The bucket size controls how big a burst can be, and the refill rate controls the long-term average. A bucket of 20 refilled with 100 tokens per minute allows short bursts of 20 while still averaging 100 per minute.

6. Building a Sliding Window Counter

The sliding window counter keeps only two numbers per client: the count for the current window and the count for the previous one. It estimates the requests in the last full window like this:

Sliding window formula
estimated = previousCount x (1 - elapsedInCurrentWindow / windowLength)
          + currentCount

Example: limit 100/minute, 15 seconds into the current minute
  previousCount = 80, currentCount = 30
  estimated = 80 x (1 - 15/60) + 30 = 60 + 30 = 90  -> allowed
SlidingWindowCounter.java
package com.example.ratelimit;

import java.time.Duration;

public class SlidingWindowCounter {

    private final int limit;
    private final long windowMillis;
    private long currentWindowStart;
    private int currentCount;
    private int previousCount;

    public SlidingWindowCounter(int limit, Duration window) {
        this.limit = limit;
        this.windowMillis = window.toMillis();
        this.currentWindowStart = System.currentTimeMillis();
    }

    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        long elapsed = now - currentWindowStart;

        if (elapsed >= windowMillis) {
            // Move forward; if more than one full window passed, the previous count is 0
            previousCount = (elapsed < 2 * windowMillis) ? currentCount : 0;
            currentCount = 0;
            currentWindowStart = now - (elapsed % windowMillis);
            elapsed = now - currentWindowStart;
        }

        double previousWeight = 1.0 - (double) elapsed / windowMillis;
        double estimated = previousCount * previousWeight + currentCount;

        if (estimated < limit) {
            currentCount++;
            return true;
        }
        return false;
    }
}

These plain-Java classes are great for learning and for a single instance. For production, libraries such as Bucket4j, Resilience4j or your API gateway’s built-in limiter add tested implementations, metrics and distributed storage.

7. A Practical Example: E-Commerce API

Let us look at a real scenario in an e-commerce platform built with microservices.

Scenario: a ProductService returns product details. It is critical and is called by the website, the mobile app and several internal services. During a flash sale, a bug in the mobile app makes it loop, firing thousands of requests per second for the same product.

7.1 Without rate limiting

The product service is overwhelmed: its database connections are used up, CPU reaches 100% and it stops responding. This affects not only the buggy app but everyone: the website’s product catalog goes down, the flash sale is ruined and revenue is lost.

7.2 With rate limiting

We add a rule for GET /products/{id}: each client, identified by API key or IP address, may make at most 100 requests per minute.

  1. The buggy app starts sending rapid requests to GET /products/123.
  2. Each request is checked by the API gateway, a common place to enforce limits, or by a filter inside the service.
  3. Requests 1–100 within the minute are processed normally and return 200 OK.
  4. Request 101 and later are rejected immediately with 429 Too Many Requests and a Retry-After header saying when to try again.
  5. When the next minute starts, the client may send requests again.

Outcome: only the buggy app sees errors, a local problem. The product service stays fast for every other user, and the flash sale continues without interruption.

8. Responding to Clients: 429 and Rate Limit Headers

A good rate limiter tells clients clearly what happened and what to do next. Use status code 429 Too Many Requests, a Retry-After header, and headers describing the limit:

HTTP response
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1791129600

{
  "error": "too_many_requests",
  "message": "Rate limit of 100 requests per minute exceeded. Try again in 30 seconds."
}
HeaderMeaning
Retry-AfterSeconds to wait (or a date) before trying again; a standard HTTP header
X-RateLimit-LimitThe maximum number of requests in the window
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetWhen the window resets, often as a Unix timestamp

The X-RateLimit-* headers are a widely used convention rather than a formal standard; an official set of RateLimit headers is being developed by the IETF. Whichever you use, document it and send the headers on successful responses too, so clients can slow down before hitting the limit.

9. A Rate Limiting Filter in Spring Boot

A servlet filter can apply the token bucket from section 5 to every incoming request, with one bucket per client:

RateLimitFilter.java
package com.example.product.ratelimit;

import java.io.IOException;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import com.example.ratelimit.TokenBucket;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Component
public class RateLimitFilter extends OncePerRequestFilter {

    private static final int LIMIT_PER_MINUTE = 100;

    private final Map<String, TokenBucket> buckets = new ConcurrentHashMap<>();

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        String clientKey = resolveClientKey(request);
        TokenBucket bucket = buckets.computeIfAbsent(clientKey,
                key -> new TokenBucket(LIMIT_PER_MINUTE, LIMIT_PER_MINUTE, Duration.ofMinutes(1)));

        if (bucket.tryConsume()) {
            chain.doFilter(request, response);
            return;
        }

        response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
        response.setHeader("Retry-After", "1");
        response.setHeader("X-RateLimit-Limit", String.valueOf(LIMIT_PER_MINUTE));
        response.setContentType("application/json");
        response.getWriter().write(
                "{\"error\":\"too_many_requests\",\"message\":\"Please slow down.\"}");
    }

    // Prefer an API key; fall back to the caller's IP address
    private String resolveClientKey(HttpServletRequest request) {
        String apiKey = request.getHeader("X-API-Key");
        return (apiKey != null) ? "key:" + apiKey : "ip:" + request.getRemoteAddr();
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // Never rate limit health checks
        return request.getRequestURI().startsWith("/actuator/health");
    }
}
Limitations: This map grows with every new client; use a cache with expiry, such as Caffeine, in real code. The limits also live in the memory of one instance, so with three instances a client effectively gets three times the limit. Section 12 solves this with Redis.

10. Rate Limiting at the API Gateway

Enforcing limits at the API gateway gives one consistent place for all services. Spring Cloud Gateway has a built-in, Redis-backed token bucket limiter. Add the reactive Redis starter (spring-boot-starter-data-redis-reactive), then configure a route:

application.yml (gateway)
spring:
  cloud:
    gateway:
      server:
        webflux:
          routes:
            - id: product-service
              uri: lb://product-service
              predicates:
                - Path=/api/products/**
              filters:
                - StripPrefix=1
                - name: RequestRateLimiter
                  args:
                    redis-rate-limiter.replenishRate: 2      # tokens per second (about 120/minute)
                    redis-rate-limiter.burstCapacity: 20     # maximum short burst
                    redis-rate-limiter.requestedTokens: 1    # tokens per request
                    key-resolver: "#{@apiKeyResolver}"
RateLimitKeyConfig.java
package com.example.gateway.config;

import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import reactor.core.publisher.Mono;

@Configuration
public class RateLimitKeyConfig {

    // One bucket per API key; requests without a key fall back to the IP address
    @Bean
    public KeyResolver apiKeyResolver() {
        return exchange -> {
            String apiKey = exchange.getRequest().getHeaders().getFirst("X-API-Key");
            if (apiKey != null) {
                return Mono.just("key:" + apiKey);
            }
            return Mono.justOrEmpty(exchange.getRequest().getRemoteAddress())
                    .map(address -> "ip:" + address.getAddress().getHostAddress())
                    .defaultIfEmpty("ip:unknown");
        };
    }
}

Because the counters live in Redis, every gateway instance shares the same limits. Rejected requests get 429, and the gateway adds headers such as X-RateLimit-Remaining automatically.

11. Limiting Outgoing Calls with Resilience4j

Rate limiting also protects the services you call. A paid third-party API may allow only 10 requests per second; going over it might cost money or get your account blocked. Resilience4j’s rate limiter caps outgoing calls:

application.yml
resilience4j:
  ratelimiter:
    instances:
      currency-api:
        limit-for-period: 10          # 10 calls...
        limit-refresh-period: 1s      # ...per second
        timeout-duration: 200ms       # wait up to 200 ms for permission
CurrencyClient.java
package com.example.order.client;

import java.math.BigDecimal;

import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;

@Component
public class CurrencyClient {

    private final RestClient restClient;
    private final ExchangeRateCache cache;

    public CurrencyClient(RestClient.Builder builder, ExchangeRateCache cache) {
        this.restClient = builder.baseUrl("https://api.currency.example").build();
        this.cache = cache;
    }

    @RateLimiter(name = "currency-api", fallbackMethod = "cachedRate")
    public BigDecimal getRate(String from, String to) {
        BigDecimal rate = restClient.get()
                .uri("/rates?from={from}&to={to}", from, to)
                .retrieve()
                .body(BigDecimal.class);
        cache.put(from, to, rate);
        return rate;
    }

    // Used when our own limit is reached
    private BigDecimal cachedRate(String from, String to, RequestNotPermitted ex) {
        return cache.get(from, to);
    }
}

The annotation requires the resilience4j-spring-boot3 dependency and Spring AOP. Note that this limit applies per application instance; with several instances, divide the provider’s limit between them or use a shared limiter.

12. Distributed Rate Limiting with Redis

Microservices usually run as several instances behind a load balancer. If each instance keeps its own counters, a client spread across three instances gets three times the intended limit. The solution is a shared store, most often Redis, that every instance updates.

Here is a simple fixed-window limiter using Redis’s atomic INCR command with Spring Data Redis:

RedisRateLimiter.java
package com.example.ratelimit;

import java.time.Duration;
import java.time.Instant;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class RedisRateLimiter {

    private static final int LIMIT = 100;
    private static final Duration WINDOW = Duration.ofMinutes(1);

    private final StringRedisTemplate redis;

    public RedisRateLimiter(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public boolean tryAcquire(String clientKey) {
        // One key per client per minute, for example "rate:key:abc123:29852160"
        long window = Instant.now().getEpochSecond() / WINDOW.toSeconds();
        String redisKey = "rate:" + clientKey + ":" + window;

        // INCR is atomic, so concurrent requests on all instances are counted correctly
        Long count = redis.opsForValue().increment(redisKey);

        if (count != null && count == 1) {
            // First request in this window: let Redis delete the key afterwards
            redis.expire(redisKey, WINDOW.plusSeconds(5));
        }
        return count != null && count <= LIMIT;
    }
}
  • All instances share the same counter, so the limit is exact across the cluster.
  • Keys expire automatically, so Redis does not fill up with old windows.
  • For a token bucket or sliding window in Redis, run the logic as a small Lua script, so the read and update happen atomically in one step. Spring Cloud Gateway and Bucket4j already do this for you.
  • Decide what happens if Redis is down: usually allow requests (“fail open”) so a Redis problem does not take down your API, and alert on it.

13. Choosing the Rate Limiting Key

KeyGood forWatch out for
API keyPublic and partner APIs; supports per-plan limitsKeys can be shared or leaked
User ID (from the login token)Fair limits for logged-in usersNot available before login
IP addressAnonymous traffic, login pages, basic abuse protectionMany users can share one IP (offices, mobile networks); behind proxies, read the client IP carefully
EndpointProtecting expensive operations, such as search or reportsCombine with a client key so one client cannot use the whole budget
CombinationFor example user + endpoint, or IP + endpoint for loginMore keys to store

Use different limits for different endpoints: stricter for expensive or sensitive ones (search, login, password reset) and relaxed or none for cheap ones (health checks, static data).

14. Being a Good Client: Handling 429 Responses

Clients, including your own services calling each other, should react sensibly to 429 responses instead of retrying immediately and making things worse:

PoliteApiClient.java
package com.example.client;

import java.time.Duration;

import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;

public class PoliteApiClient {

    private static final int MAX_ATTEMPTS = 3;

    private final RestClient restClient;

    public PoliteApiClient(RestClient restClient) {
        this.restClient = restClient;
    }

    public String getProduct(String productId) throws InterruptedException {
        for (int attempt = 1; ; attempt++) {
            try {
                return restClient.get()
                        .uri("/products/{id}", productId)
                        .retrieve()
                        .body(String.class);
            } catch (HttpClientErrorException.TooManyRequests ex) {
                if (attempt == MAX_ATTEMPTS) {
                    throw ex;
                }
                Thread.sleep(waitTime(ex, attempt).toMillis());
            }
        }
    }

    // Respect Retry-After if present; otherwise back off exponentially: 1s, 2s, 4s...
    private Duration waitTime(HttpClientErrorException ex, int attempt) {
        String retryAfter = ex.getResponseHeaders() != null
                ? ex.getResponseHeaders().getFirst("Retry-After")
                : null;
        if (retryAfter != null && retryAfter.chars().allMatch(Character::isDigit)) {
            return Duration.ofSeconds(Long.parseLong(retryAfter));
        }
        return Duration.ofSeconds(1L << (attempt - 1));
    }
}

Good client habits: respect Retry-After, back off exponentially with a little random jitter, cap the number of attempts, and watch the remaining-requests headers to slow down early. See the Retry pattern for more.

15. Best Practices

  1. Use the API gateway for consistent, central limits across all services, and add service-level limits for special cases.
  2. Choose the right key: API key, user ID, IP address or a combination, based on your security and business needs.
  3. Communicate limits clearly with 429, Retry-After and rate limit headers, and document them.
  4. Differentiate between endpoints: stricter limits for expensive operations such as search, relaxed ones for cheap requests.
  5. Use shared storage, such as Redis, when services run as several instances.
  6. Allow reasonable bursts with a token bucket, so normal users clicking quickly are not punished.
  7. Offer tiers: different limits for free, paid and internal clients.
  8. Never limit health checks or monitoring traffic.
  9. Monitor and adapt: track rejections per client and endpoint, and adjust limits to real traffic and capacity.
  10. Combine with other patterns such as circuit breakers, bulkheads and timeouts for complete protection.

16. Common Mistakes to Avoid

MistakeWhat goes wrongBetter approach
In-memory counters with many instancesClients get several times the intended limitStore counters in Redis or use the gateway’s shared limiter
Limiting only by IP addressWhole offices or mobile networks are blocked togetherPrefer API keys or user IDs where possible
Returning 500 or 503 instead of 429Clients cannot tell overload from a real errorReturn 429 with Retry-After
Clients retrying immediatelyRejected traffic multiplies and makes overload worseRespect Retry-After and use exponential backoff
Limits that are too strictNormal users hit errors during regular useBase limits on measured traffic and allow bursts
One limit for every endpointCheap endpoints are blocked, expensive ones are under-protectedSet limits per endpoint group
Rate limiting health checksLoad balancers think healthy instances are downExclude health and monitoring endpoints
Unbounded per-client mapsMemory grows with every new clientUse caches with expiry or external storage

17. Summary

The rate limiting pattern is a foundation of resilient, secure and fair microservices. It controls how many requests each client may make in a time window, protects services from spikes, bugs and attacks, keeps cloud costs predictable and enables fair, tiered plans. Choose an algorithm that fits your needs, usually a token bucket or sliding window; enforce limits centrally at the API gateway with shared Redis storage; protect outgoing calls with Resilience4j; and always answer with 429 and clear headers so clients can back off politely. With thoughtful limits in place, your services stay available and fast for everyone, even under unexpected load. Continue with the Bulkhead pattern to learn how to limit concurrent calls as well.