Stop Getting Hammered: How to Implement Rate Limiting in Spring Boot for Bulletproof API Protection

Meta Description: Learn how to implement rate limiting in Spring Boot with Bucket4j, Redis, and custom filters. Real code, production tips, and common mistakes to avoid.

Introduction

I still remember the Slack message that ruined my Tuesday afternoon: “API is down, customers are complaining.” When I pulled up the logs, it wasn’t a bug, it wasn’t a bad deploy — it was one client hammering our /search endpoint with over 4,000 requests per minute because someone on their team had a retry loop with no backoff. Our database connection pool maxed out, and every other tenant on the platform got 500 errors for twenty minutes.

That was the day I stopped treating rate limiting as a “nice to have” and started treating it as a first-class citizen in every Spring Boot API I ship. In this guide, I’m walking you through everything I’ve learned implementing rate limiting in Spring Boot in production — from the simplest in-memory approach to distributed rate limiting with Redis that scales across multiple instances.

By the end of this article, you’ll know exactly which approach fits your situation, how to implement it with real code, and which mistakes to avoid because I already made them so you don’t have to.

What Is Rate Limiting in Spring Boot?

Rate limiting in Spring Boot is the practice of controlling how many requests a client can make to your API within a given time window, implemented either through custom filters, interceptors, or dedicated libraries like Bucket4j, typically combined with Redis for distributed environments. It protects your backend from abuse, accidental overload, and cascading failures.

Why Rate Limiting Actually Matters (Beyond “Security”)

When people ask me why rate limiting matters, they usually expect a security answer. Security is part of it, but in my experience, the bigger day-to-day wins are operational:

  • Protecting downstream resources. Your database, third-party APIs, and internal services all have limits. Rate limiting at the edge keeps you from exceeding them.
  • Fair usage across tenants. In a multi-tenant system, one noisy client shouldn’t be able to degrade service for everyone else.
  • Cost control. If you’re paying per-request for a downstream API (OpenAI, Twilio, Stripe), an uncontrolled loop from a bug can turn into a very expensive bill overnight.
  • Predictable capacity planning. When you know your ceiling, you can size your infrastructure with confidence instead of guessing.
  • Abuse and bot mitigation. Credential stuffing, scraping, and brute-force login attempts almost always show up as abnormal request volume first.

I learned the cost-control lesson the hard way when a retry storm against a paid geocoding API burned through a month’s budget in six hours. A simple rate limiter on our outbound calls would have prevented it entirely.

The Main Approaches to Rate Limiting in Spring Boot

There isn’t a single “correct” way to implement rate limiting — it depends on your architecture. Here’s the comparison table I wish someone had shown me when I started.

ApproachBest ForDistributed?ComplexityPrecision
In-memory (Guava RateLimiter)Single instance, low trafficNoLowMedium
Custom Servlet FilterFull control, learning purposesNo (unless backed by shared store)MediumMedium
Bucket4j (in-memory)Small to medium apps, single nodeNoLow-MediumHigh
Bucket4j + RedisMicroservices, multiple instancesYesMediumHigh
Spring Cloud Gateway RateLimiterAPI Gateway layer, reactive stacksYesMedium-HighHigh
Nginx / API Gateway (external)Infrastructure-level protectionYesLow (ops-side)Medium

In my experience, most teams start with something too simple (in-memory) and later regret it when they scale horizontally and realize each instance has its own independent counter, which means your “100 requests per minute” limit actually becomes “100 requests per minute per pod.” I’ll show you how to avoid that trap.

Step 1: Basic In-Memory Rate Limiting with a Servlet Filter

Let’s start simple. This is a good learning exercise and works fine for a single-instance app or an internal admin API.

@Component
public class SimpleRateLimitFilter extends OncePerRequestFilter {

    // Tracks request counts per client IP
    private final Map<String, RequestCounter> requestCounts = new ConcurrentHashMap<>();

    private static final int MAX_REQUESTS = 60; // requests
    private static final long WINDOW_MILLIS = 60_000; // per 1 minute

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

        String clientIp = request.getRemoteAddr();
        RequestCounter counter = requestCounts.computeIfAbsent(clientIp, k -> new RequestCounter());

        synchronized (counter) {
            long now = System.currentTimeMillis();

            // Reset the window if it has expired
            if (now - counter.windowStart > WINDOW_MILLIS) {
                counter.windowStart = now;
                counter.count = 0;
            }

            counter.count++;

            if (counter.count > MAX_REQUESTS) {
                response.setStatus(429); // Too Many Requests
                response.setHeader("Retry-After", "60");
                response.getWriter().write("Rate limit exceeded. Try again later.");
                return;
            }
        }

        filterChain.doFilter(request, response);
    }

    private static class RequestCounter {
        long windowStart = System.currentTimeMillis();
        int count = 0;
    }
}

This works, and I’ve shipped versions of this exact filter for small internal tools. But it has real limitations I want to be upfront about:

  • The Map grows forever unless you add eviction for stale IPs — this is a memory leak waiting to happen.
  • It only works correctly on a single instance. Scale to three pods behind a load balancer, and your effective limit triples.
  • The fixed-window approach allows bursts at window boundaries (a client can send 60 requests at 0:59 and another 60 at 1:01 — 120 requests in 2 seconds).

That last problem is exactly why I moved to a proper token bucket algorithm using Bucket4j.

Step 2: Production-Grade Rate Limiting with Bucket4j

Bucket4j implements the token bucket algorithm, which smooths out bursts far better than a fixed window counter. Here’s why I prefer it: each client has a “bucket” that refills at a steady rate, and every request consumes a token. If the bucket is empty, the request is rejected. No sudden double-burst at window edges.

Add the dependency:

<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j_jdk17-core</artifactId>
    <version>8.10.1</version>
</dependency>

Pro tip: Always check the current Bucket4j version on Maven Central before pinning it — the artifact naming has changed across major versions (from bucket4j-core to JDK-specific artifacts), and this trips people up constantly when copy-pasting old tutorials.

Here’s a clean implementation using an interceptor instead of a filter, which I now prefer because it gives me easier access to Spring’s HandlerMethod for per-endpoint rules:

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

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

    @Override
    public boolean preHandle(HttpServletRequest request,
                              HttpServletResponse response,
                              Object handler) throws IOException {

        String apiKey = request.getHeader("X-API-KEY");
        if (apiKey == null) {
            apiKey = request.getRemoteAddr(); // fallback to IP
        }

        Bucket bucket = buckets.computeIfAbsent(apiKey, this::createNewBucket);

        if (bucket.tryConsume(1)) {
            return true; // allow the request
        }

        response.setStatus(429);
        response.setContentType("application/json");
        response.getWriter().write("{\"error\": \"Rate limit exceeded\"}");
        return false;
    }

    private Bucket createNewBucket(String key) {
        // 100 tokens, refilled at 100 tokens per minute (roughly 1.67/sec)
        Bandwidth limit = Bandwidth.classic(100,
                Refill.greedy(100, Duration.ofMinutes(1)));
        return Bucket.builder().addLimit(limit).build();
    }
}

Register it:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final RateLimitInterceptor rateLimitInterceptor;

    public WebConfig(RateLimitInterceptor rateLimitInterceptor) {
        this.rateLimitInterceptor = rateLimitInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(rateLimitInterceptor)
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/health");
    }
}

I keyed this off an API key header with an IP fallback because in almost every production system I’ve built, per-client limiting matters more than global IP-based limiting — a single office or NAT gateway can have dozens of legitimate users sharing one IP, and you don’t want to punish all of them for one bad actor.

Step 3: Distributed Rate Limiting with Redis (For Multiple Instances)

Here’s the problem I mentioned earlier: ConcurrentHashMap buckets only live in the memory of a single JVM. The moment you run more than one instance behind a load balancer, each instance enforces its own limit independently. A client hitting three pods in round-robin can triple their effective quota.

I ran into this exact issue after we auto-scaled a service from 1 to 4 pods during a traffic spike — the rate limiter effectively stopped working right when we needed it most. The fix is to back Bucket4j with a shared store like Redis.

<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j_jdk17-redis</artifactId>
    <version>8.10.1</version>
</dependency>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>5.1.0</version>
</dependency>
@Configuration
public class RedisRateLimitConfig {

    @Bean
    public ProxyManager<String> proxyManager() {
        JedisPool jedisPool = new JedisPool("localhost", 6379);
        return Bucket4jJedis.casBasedBuilder(jedisPool).build();
    }
}
@Component
public class DistributedRateLimitInterceptor implements HandlerInterceptor {

    private final ProxyManager<String> proxyManager;

    public DistributedRateLimitInterceptor(ProxyManager<String> proxyManager) {
        this.proxyManager = proxyManager;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                              HttpServletResponse response,
                              Object handler) throws IOException {

        String apiKey = Optional.ofNullable(request.getHeader("X-API-KEY"))
                .orElse(request.getRemoteAddr());

        BucketConfiguration config = BucketConfiguration.builder()
                .addLimit(Bandwidth.classic(100, Refill.greedy(100, Duration.ofMinutes(1))))
                .build();

        Bucket bucket = proxyManager.builder().build("rate-limit:" + apiKey, () -> config);

        if (bucket.tryConsume(1)) {
            return true;
        }

        response.setStatus(429);
        response.getWriter().write("{\"error\": \"Rate limit exceeded\"}");
        return false;
    }
}

Now every instance checks the same Redis-backed bucket, so the limit is enforced globally no matter how many pods you’re running. This is the setup I now use as the default for anything that isn’t a single-instance side project.

Pro tip: Set a TTL-friendly key naming convention (rate-limit:{clientId}) and let Redis expire idle buckets naturally instead of tracking cleanup yourself. It keeps your Redis instance from filling up with buckets for clients who left months ago.

Choosing a Rate Limiting Strategy: Fixed Window vs Sliding Window vs Token Bucket

AlgorithmBurst HandlingImplementation ComplexityMemory UsageMy Recommendation
Fixed WindowPoor (edge bursts)Very LowLowOnly for quick prototypes
Sliding Window LogExcellentHighHigh (stores timestamps)High-precision needs, low volume
Sliding Window CounterGoodMediumLowGood middle ground
Token Bucket (Bucket4j)ExcellentMediumLowMy default recommendation
Leaky BucketExcellent (smooths output)Medium-HighLowQueue-like processing needs

I default to token bucket for almost everything because Bucket4j makes it easy to implement correctly, it handles bursts gracefully, and it doesn’t require storing a timestamp per request like sliding window logs do.

Applying Different Limits Per Endpoint or User Tier

One thing that took me a while to get right in production: not every endpoint needs the same limit, and not every user tier deserves the same quota. A /login endpoint should be far stricter than a /products listing endpoint, and a paid tier customer probably deserves a higher ceiling than a free tier user.

private Bandwidth resolveLimitForRequest(HttpServletRequest request, String tier) {
    String path = request.getRequestURI();

    if (path.startsWith("/api/auth/login")) {
        // Strict limit to slow down brute-force attempts
        return Bandwidth.classic(5, Refill.greedy(5, Duration.ofMinutes(1)));
    }

    if ("premium".equals(tier)) {
        return Bandwidth.classic(1000, Refill.greedy(1000, Duration.ofMinutes(1)));
    }

    // default / free tier
    return Bandwidth.classic(100, Refill.greedy(100, Duration.ofMinutes(1)));
}

I pull the tier value from the authenticated principal (a custom claim in a JWT, for example) rather than trusting a client-supplied header — never let the caller tell you what tier they’re on.

Common Mistakes I’ve Seen (and Made)

  • Rate limiting only by IP. Mobile carriers and corporate NATs put thousands of users behind one IP. Combine IP-based limiting with API-key or user-based limiting whenever possible.
  • Forgetting to set the Retry-After header. Clients (and well-behaved SDKs) use this header to back off intelligently. Omitting it leads to tighter retry loops, which makes the problem worse.
  • No cleanup strategy for in-memory maps. I’ve seen a ConcurrentHashMap grow unbounded over weeks of uptime because nobody evicted stale entries. Use a cache with expiration (Caffeine, for example) instead of a raw map for anything long-running.
  • Rate limiting after expensive processing. Always check the limit as early as possible in the request pipeline — ideally at the filter or gateway level, before hitting the database or invoking business logic.
  • Testing rate limits only in isolation. A limiter that works perfectly on one instance can silently break the moment you scale horizontally, exactly like the Redis example above illustrates.
  • Returning a generic 500 instead of 429. Clients need the correct HTTP status code (429 Too Many Requests) to know they should back off rather than treat it as a server error and retry immediately.

Performance Considerations

In my testing, Bucket4j’s in-memory implementation adds negligible overhead — we’re talking low single-digit microseconds per check. The Redis-backed version adds a network round trip, which in a well-provisioned environment (Redis in the same VPC/availability zone) typically costs 1-3ms. That’s a completely acceptable tradeoff for correctness across a distributed system, but if you’re extremely latency-sensitive, consider:

  • Using a local cache as a first-pass check with a shorter, looser limit, and Redis as the authoritative distributed check.
  • Batching Redis calls where the client library supports pipelining.
  • Placing Redis close to your application nodes — cross-region Redis calls for rate limiting are a mistake I made once and won’t make again.

Security Considerations

Rate limiting is a security control, not just a performance one. A few things I always double-check:

  • Apply stricter limits to authentication endpoints (/login, /reset-password, /register) since these are prime targets for credential stuffing and brute force.
  • Don’t rely solely on client-supplied identifiers for rate limit keys — an attacker can rotate a spoofable header to bypass your limiter entirely.
  • Log rate limit violations. A sudden spike in 429 responses from a single client is often the earliest signal of an attack in progress, well before your WAF or SIEM catches it.
  • Consider combining application-level rate limiting with infrastructure-level protection (Cloudflare, AWS WAF, API Gateway throttling) for defense in depth — never rely on a single layer.

Rate Limiting Checklist

  • [ ] Identify which endpoints need protection (start with auth, write operations, and expensive queries)
  • [ ] Choose an algorithm — token bucket (Bucket4j) is my default recommendation
  • [ ] Decide your rate limit key — API key, user ID, or IP (with awareness of shared IP scenarios)
  • [ ] Confirm whether you need distributed enforcement (multiple instances = yes, use Redis)
  • [ ] Return proper HTTP 429 responses with a Retry-After header
  • [ ] Add different limits per tier or endpoint sensitivity
  • [ ] Add monitoring/alerting on rate limit violations
  • [ ] Load test your limiter under realistic concurrent traffic
  • [ ] Document your public rate limits for API consumers

Frequently Asked Questions

1. What is the best rate limiting algorithm for Spring Boot APIs? In my experience, the token bucket algorithm — implemented through Bucket4j — is the best default choice. It handles bursts gracefully, is memory efficient, and integrates cleanly with both in-memory and Redis-backed setups.

2. Does Spring Boot have built-in rate limiting? No. Spring Boot itself doesn’t ship with a built-in rate limiter. You need to implement it yourself using a filter or interceptor, or use a library like Bucket4j. Spring Cloud Gateway does include a RequestRateLimiter filter, but that’s a separate project from core Spring Boot.

3. How do I rate limit by user instead of by IP address in Spring Boot? Extract the user identifier from your authentication context — typically a JWT claim or an API key mapped to a user record — and use that as the key for your rate limiter bucket instead of request.getRemoteAddr().

4. What HTTP status code should a rate-limited request return? Return 429 Too Many Requests, and include a Retry-After header telling the client how long to wait before retrying.

5. Can I use Redis for rate limiting in a multi-instance Spring Boot deployment? Yes, and I’d say it’s necessary rather than optional once you run more than one instance. Redis acts as a shared counter store so all instances enforce the same limit consistently, avoiding the per-instance quota multiplication problem covered earlier in this article.

6. How do I test rate limiting in Spring Boot? Write an integration test that fires requests in a loop past your configured threshold and asserts that subsequent requests return 429. For distributed setups, spin up a test Redis instance (Testcontainers works well for this) to validate cross-instance behavior realistically.

Final Thoughts

Rate limiting isn’t glamorous work, but it’s one of those things that quietly saves you from a 2 a.m. incident call. Start simple if you’re a single-instance app, but the moment you scale horizontally, move to a Redis-backed Bucket4j setup — I promise it’ll save you the same headache it saved me.

If you found this useful, check out more backend and DevOps guides on SpiritCode.blog — I regularly break down real production problems like this one, with working code you can drop straight into your project.