Implement Rate Limiting in Spring Boot REST APIs (Production Guide)

Introduction

Rate limiting protects an API from being overwhelmed — whether by a misbehaving client, a traffic spike, or a deliberate abuse attempt — by capping how many requests a given client can make in a time window. Without it, a single aggressive client can exhaust thread pools and connection pools for every other consumer of the same API.

The part most guides skip is what happens when the rate limiter itself fails — if your limiting infrastructure depends on Redis and Redis has a bad moment, does every request get rejected, or does limiting silently disable itself? That single design decision, made explicitly or by accident, determines whether a rate limiter protects your availability or becomes a new cause of outages. This guide covers the algorithms, the implementation, and that failure-mode decision.

Prerequisites

  • Basic Redis usage
  • Spring Cloud Gateway fundamentals
  • HTTP status code semantics

Related reading:

Learning Objectives

  • Compare fixed window, sliding window, token bucket, and leaky bucket algorithms.
  • Implement application-level and Redis-backed distributed rate limiting.
  • Configure gateway-level rate limiting with Spring Cloud Gateway.
  • Decide fail-open vs. fail-closed behavior deliberately, not by default.

Table of Contents

Why Rate Limiting Matters

Without rate limiting:
Client (buggy retry loop) ──▶ 10,000 req/sec ──▶ Thread pool exhausted ──▶ All clients affected

With rate limiting:
Client (buggy retry loop) ──▶ 429 after limit ──▶ Other clients unaffected

Rate Limiting Algorithms

AlgorithmHow it worksTrade-off
Fixed windowN requests per fixed time blockSimple, but allows bursts at window boundaries
Sliding windowN requests per rolling time windowSmoother, slightly more computation
Token bucketTokens refill at a fixed rate; each request consumes oneAllows controlled bursts, industry standard
Leaky bucketRequests processed at a constant output rateSmooths bursts completely, adds latency under load

Application-Level Rate Limiting with Bucket4j

@Component
public class RateLimitFilter extends OncePerRequestFilter {

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

    private Bucket newBucket() {
        Bandwidth limit = Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1)));
        return Bucket.builder().addLimit(limit).build();
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws ServletException, IOException {
        String clientKey = request.getHeader("X-API-Key");
        Bucket bucket = buckets.computeIfAbsent(clientKey, k -> newBucket());

        if (bucket.tryConsume(1)) {
            chain.doFilter(request, response);
        } else {
            response.setStatus(429);
            response.setHeader("Retry-After", "60");
            response.getWriter().write("{\"error\":\"RATE_LIMIT_EXCEEDED\"}");
        }
    }
}

Distributed Rate Limiting with Redis

@Bean
public ProxyManager<String> proxyManager(RedisClient redisClient) {
    StatefulRedisConnection<String, byte[]> connection =
            redisClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));
    return LettuceBasedProxyManager.builderFor(connection)
            .withExpirationStrategy(
                    ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(2)))
            .build();
}

Gateway-Level Rate Limiting (Spring Cloud Gateway)

spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 50
                redis-rate-limiter.burstCapacity: 100
                redis-rate-limiter.requestedTokens: 1

Returning the Correct Response on Limit Exceeded

Always return 429 Too Many Requests with a Retry-After header — this ties into Spring Boot HTTP Status Codes Explained with Best Practices.

Production Case Studies

Case Study 1 – Rate Limiter Outage Caused by Fail-Closed Default

Symptoms: A brief Redis connectivity blip took down the entire API, far beyond the scope of the actual Redis issue.

Investigation: The rate-limiting client library’s default behavior on a Redis connection failure was to throw, which propagated as a request failure for every single request, not just ones near their limit.

Root Cause: Fail-closed behavior chosen implicitly by library default, never explicitly reviewed or tested.

Solution: Wrapped the rate-limit check in a try/catch that fails open (allows the request) on infrastructure error, with an alert firing separately so the Redis issue itself still gets attention.

Case Study 2 – Legitimate Burst Traffic Misclassified as Abuse

Symptoms: A popular third-party integration’s users experienced intermittent 429s that looked like abuse from the API provider’s dashboard.

Investigation: The integration made several rapid, valid calls per user action — a structurally bursty but legitimate pattern the original limit, tuned against typical usage at launch, never anticipated.

Root Cause: A single rate-limit tier applied uniformly to a now-diverse consumer ecosystem.

Solution: Introduced a distinct burst-tolerant tier (larger burst capacity, same sustained rate) for verified partner integrations.

Architect’s Perspective: Trade-offs, Scale & War Story

Trade-off Analysis: Fairness vs. Simplicity in Limit Keying

Keying limits per API key/user is fairer but requires reliable client identity at the point of limiting. Layered limits — a coarse IP-based backstop plus a tighter per-identity limit — is the common resolution.

At Scale: The Rate Limiter’s Own Availability Becomes Critical Path

Once rate limiting is Redis-backed and enforced on every request, the rate limiter itself becomes a hard dependency on the request path. Most production systems choose fail-open specifically so an unavailable rate limiter doesn’t become a full outage.

When Rate Limiting Isn’t the Right Protection Mechanism

Rate limiting protects against volume-based abuse, not a single expensive request within its limit. For that, resource-based throttling or a bulkhead pattern is the right mechanism.

Observability: Distinguishing Legitimate Bursts from Abuse

Track rate-limit-rejected requests segmented by client identity — a single client consistently hitting the ceiling suggests abuse or a misbehaving retry loop; many different clients occasionally brushing the limit suggests the limit is too conservative.

Production War Story

See Case Study 1 above — the clearest lesson in this guide: fail-open vs. fail-closed for the rate limiter’s own infrastructure dependency needs to be an explicit, tested architectural decision, not whatever the client library happens to do by default.

Common Mistakes

MistakeImpactBetter Practice
In-memory rate limiting in a multi-instance deploymentEffective limit multiplied by instance countUse a Redis-backed shared store
Rate limiting by IP onlyPenalizes shared-IP users unfairlyLayer IP-based and identity-based limits
Returning 500/403 instead of 429Client can’t distinguish rate limiting from other failuresAlways return 429 with Retry-After
Fail-closed rate limiter by unreviewed defaultInfra blip becomes a full outageExplicitly choose and test fail-open behavior
One global limit for all endpoints/consumersUnder-restricts abuse or over-restricts legitimate burstsTune per-endpoint and per-consumer-tier limits

Best Practices Checklist

  • [ ] Rate limit keyed by API key/user ID where available, falling back to IP only when necessary
  • [ ] Redis-backed (or equivalent shared store) limiter for multi-instance deployments
  • [ ] 429 with Retry-After returned consistently on limit exceeded
  • [ ] Per-endpoint limits tuned to actual resource cost, not a single global value
  • [ ] Fail-open vs. fail-closed behavior explicitly decided and tested for infra failure
  • [ ] Rejected-request metrics segmented by client identity to distinguish abuse from legitimate bursts

Decision Tree: Where to Rate Limit

Choosing where to enforce rate limits
│
├─ Single service, single instance? → In-memory Bucket4j is sufficient
├─ Multiple instances of the same service? → Redis-backed Bucket4j
├─ Multiple microservices behind one gateway? → Gateway-level RequestRateLimiter
└─ Need both coarse (overall) and fine (per-endpoint) control? → Combine gateway + application-level limits

Interview Questions

  1. What’s the difference between token bucket and fixed window rate limiting?
  2. Why does in-memory rate limiting fail correctness in a multi-instance deployment?
  3. Why is 429 the correct status code for rate limiting, and what header should accompany it?
  4. What are the trade-offs of rate limiting at the gateway versus at the application layer?
  5. Should a rate limiter fail open or fail closed when its backing store is unavailable? Why?

FAQs

Q: Should I rate limit at the gateway, the application, or both?
Gateway-level limiting protects against gross traffic spikes efficiently; application-level limiting allows finer, per-endpoint control. Production systems commonly use both together.

Q: What should a client do when it receives a 429?
Respect the Retry-After header and back off accordingly rather than retrying immediately.

Continue Reading on SpringBootFixes

Conclusion

Rate limiting is straightforward to implement and easy to get subtly wrong at the availability layer — the algorithm matters less than whether limits are correctly scoped per identity and whether the limiter’s own failure mode has been deliberately chosen rather than inherited by accident from a library default.

External References

Leave a Comment

Your email address will not be published. Required fields are marked *