Fixed window, sliding window, and token bucket done right
Unlimited APIs are an open door for abuse, scraping and accidental overload caused by misbehaving clients. This article explains how fixed window, sliding window and token bucket algorithms work, how to configure sensible rate limits per IP address or API key and how gateway layer and application logic can be combined in Magento environments with Redis to keep systems stable and fair.
Table of Contents
- 1. Why API rate limiting is essential
- 2. Rate limiting algorithms overview: fixed window, sliding window, token bucket
- 3. Burst traffic vs. abuse: recognizing legitimate load spikes
- 4. Per-IP vs. per-API-key: choosing the right limiting strategy
- 5. Rate limit headers: X-RateLimit-* for well-behaved clients
- 6. Gateway/CDN layer vs. application layer
- 7. Redis-backed rate limiting in PHP and Magento
- 8. Monitoring and alerting for rate limiting systems
- 9. Rate limiting algorithms compared side by side
- 10. Summary
- 11. FAQ
1. Why API rate limiting is essential
Every publicly reachable REST or GraphQL API is an attack surface for automated abuse: scraping bots that harvest an entire product catalog, credential stuffing attacks against the login endpoint, or GraphQL queries made artificially expensive through deeply nested relations. Without rate limiting, a single misconfigured client or a targeted attacker can exhaust the database connection pool and slow down or completely take down the service for every user at once. The OWASP API Security Top 10 project lists unrestricted resource consumption (API4:2023) as its own, highly critical vulnerability category for exactly this reason.
Rate limiting is therefore not an optimization but a baseline defensive control, on the same level as authentication and input validation. The difference from classic firewall logic: rate limiting operates at the application layer and understands the context of a request, such as which customer, which API key, or which endpoint is affected. For Magento stores with public REST and GraphQL endpoints for product search, cart, and checkout, the combination of high reach and expensive database queries makes them a preferred target for abuse that only becomes visible once the system fails.
2. Rate limiting algorithms overview: fixed window, sliding window, token bucket
The fixed window algorithm counts requests in fixed time windows, for example 100 requests per minute, and resets the counter every time the window rolls over. It is simple to implement and needs only a single counter per client, but it has a structural problem at the window boundary: a client can exhaust the full limit just before the window ends and again just after, briefly achieving double the intended rate. The sliding window counter mitigates this by blending the current and previous window, weighted by elapsed time, a good approximation of true sliding window behavior at minimal memory cost.
The token bucket algorithm continuously fills a virtual bucket with tokens at a fixed rate, up to a defined capacity. Every request consumes a token, and once the bucket is empty the request is rejected. The key advantage: token bucket allows controlled bursts as long as enough tokens have accumulated, which makes it ideal for APIs with naturally fluctuating traffic. The sliding log algorithm, by contrast, stores the exact timestamp of every single request and therefore delivers the most accurate rate calculation, but it consumes significantly more memory per client since the entire request history within the window has to be retained.
<?php
declare(strict_types=1);
namespace Mironsoft\RateLimit\Model;
use Redis;
/**
* Redis-backed token bucket rate limiter.
* Atomic refill and consume via a Lua script to avoid race conditions
* under concurrent requests.
*/
final class TokenBucketLimiter
{
private const LUA_SCRIPT = <<<LUA
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'timestamp')
local tokens = tonumber(bucket[1]) or capacity
local last = tonumber(bucket[2]) or now
local elapsed = math.max(0, now - last)
tokens = math.min(capacity, tokens + elapsed * refill_rate)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'timestamp', now)
redis.call('EXPIRE', key, 3600)
return {allowed, tokens}
LUA;
public function __construct(private readonly Redis $redis)
{
}
/**
* Try to consume tokens for a client. Returns true when the
* request is allowed within the current bucket capacity.
*
* @param string $clientKey Unique identifier, e.g. api-key or ip hash
* @param int $capacity Maximum burst size (bucket size)
* @param float $refillRate Tokens added per second
*/
public function allow(string $clientKey, int $capacity, float $refillRate): bool
{
$result = $this->redis->eval(
self::LUA_SCRIPT,
["ratelimit:bucket:{$clientKey}", (string) $capacity, (string) $refillRate, (string) microtime(true), '1'],
1
);
return (bool) $result[0];
}
}
3. Burst traffic vs. abuse: recognizing legitimate load spikes
Not every traffic spike is an attack. A newsletter send, a flash sale campaign, or a viral social media post can generate a multiple of normal traffic within seconds without any malicious intent. An overly strict fixed window limit blocks real customers in exactly these moments, causing the very revenue loss that rate limiting is supposed to prevent. The solution lies in distinguishing between allowed burst and sustained abuse: a token bucket with sufficient capacity tolerates short load spikes while the refill rate caps the long-term average usage.
Genuine abuse patterns differ from legitimate bursts through their regularity and the absence of typical user signals: identical user agent strings across thousands of requests, missing referer headers, precisely timed request intervals in the millisecond range, or sequential iteration over product IDs or coupon code patterns. A multi-tier limiting design with soft warning thresholds and hard blocking thresholds allows suspicious behavior to be throttled first instead of blocked outright, escalating to harder measures such as temporary IP bans or CAPTCHA challenges only once the excess persists.
4. Per-IP vs. per-API-key: choosing the right limiting strategy
Rate limiting by IP address is the simplest strategy and works well for public, unauthenticated endpoints such as product search or login forms. Its biggest weakness: a single IP address can sit behind NAT, corporate networks, or mobile carrier-grade NAT for thousands of real users, who then get collectively blocked by an overly strict IP limit. Conversely, an attacker running a botnet with thousands of IP addresses can trivially bypass a pure IP limit by spreading the load across many sources.
For authenticated APIs, rate limiting by API key or customer ID is more precise, since it cleanly separates real users independent of IP address. The most robust solution combines both layers: a moderate IP limit as a first line of defense against anonymous mass attacks, plus an individual, plan-based limit per API key for authenticated usage. Magento integrations with third-party systems via the REST or GraphQL API should receive different quotas per integration token, so a misbehaving ERP system does not consume the limit meant for the checkout client.
5. Rate limit headers: X-RateLimit-* for well-behaved clients
A rate limit that isn't communicated to clients forces integration partners into trial and error and produces unnecessary 429 errors. The de facto standard headers X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset report, on every response, including successful ones, the current quota, the remaining requests, and the Unix timestamp of the next reset. Well-implemented API clients read these headers and proactively throttle themselves before the limit is even reached, instead of blindly sending requests and reacting to failures.
On a 429 response, the standardized Retry-After header is additionally decisive, since it tells clients exactly how many seconds to wait before the next allowed attempt, instead of guessing with fixed or exponential backoff. IETF draft standards like RateLimit-Limit, RateLimit-Remaining, and RateLimit-Policy without the X-prefix are gaining adoption, but the X-RateLimit variant remains the pragmatic industry standard in established ecosystems like GitHub, Twitter/X, and Shopify, and most integrations still follow it.
# Inspect rate limit headers on a live API response
curl -sD - -o /dev/null https://api.mironsoft.de/v1/products \
-H "X-Api-Key: demo-key-123"
# Example response headers returned by the API
HTTP/2 200
content-type: application/json
x-ratelimit-limit: 100
x-ratelimit-remaining: 87
x-ratelimit-reset: 1752312600
# Example response headers when the limit is exceeded
HTTP/2 429
content-type: application/json
x-ratelimit-limit: 100
x-ratelimit-remaining: 0
x-ratelimit-reset: 1752312600
retry-after: 42
6. Gateway/CDN layer vs. application layer
Rate limiting at the gateway or CDN, for example via Cloudflare, Fastly, or an API gateway like Kong, stops abuse before the request even reaches the application server. This protects database connections, PHP-FPM workers, and backend resources, and is especially effective against volumetric attacks and simple bot networks. The downside: edge layers rarely know the full application context, such as whether an authenticated customer has a premium quota or whether a specific GraphQL query is particularly expensive due to deeply nested fields. Coarse IP- or path-based limits are the norm here.
Application layer rate limiting, implemented directly in PHP code or as a Magento plugin, knows the full context instead: the logged-in customer, the API key plan, query complexity, and business-logic-specific limits such as a maximum of three coupon code attempts per order. The recommended architecture combines both layers as defense in depth: the gateway blocks coarse, volumetric attacks early and cheaply, while the application layer enforces fine-grained, business-logic-aware limits. This combination prevents attackers from either overwhelming the expensive application layer or being let through by an edge limit too coarse to catch them, while a too-coarse edge limit doesn't throttle legitimate premium customers either.
// Cloudflare Worker: fixed window rate limit at the edge using KV storage
export default {
async fetch(request, env) {
const clientIp = request.headers.get('CF-Connecting-IP') ?? 'unknown';
const windowSeconds = 60;
const limit = 120;
const windowKey = Math.floor(Date.now() / 1000 / windowSeconds);
const key = `rl:${clientIp}:${windowKey}`;
const current = parseInt((await env.RATE_LIMIT_KV.get(key)) ?? '0', 10);
if (current >= limit) {
return new Response('Too Many Requests', {
status: 429,
headers: {
'Retry-After': String(windowSeconds),
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': '0',
},
});
}
// Increment counter with TTL slightly longer than the window
await env.RATE_LIMIT_KV.put(key, String(current + 1), { expirationTtl: windowSeconds + 5 });
const response = await fetch(request);
response.headers.set('X-RateLimit-Limit', String(limit));
response.headers.set('X-RateLimit-Remaining', String(Math.max(0, limit - current - 1)));
return response;
},
};
7. Redis-backed rate limiting in PHP and Magento
Magento ships without native, production-ready API rate limiting for REST or GraphQL endpoints, which is why a custom plugin solution that hooks in before the actual controller is the practical approach. An around plugin on Magento\Webapi\Controller\Rest or a preference for the GraphQL dispatcher checks the quota before expensive resolvers or repository calls even run. Redis is a great fit as the central counter store, since Magento already runs a Redis instance for session and cache storage in production setups, and the latency for a counter access sits in the sub-millisecond range.
An important implementation detail: the counter increment and the limit check must happen atomically, otherwise concurrent requests create race conditions that let the limit be bypassed. Lua scripts, passed to Redis via EVAL, guarantee this atomicity, since Redis executes scripts as a single unit without other clients interleaving in between. For Magento stores with multiple web server instances behind a load balancer, a central Redis counter is also the only reliable method, since local in-memory counters per server would silently multiply the overall limit.
<?php
declare(strict_types=1);
namespace Mironsoft\RateLimit\Plugin;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Webapi\Rest\Request;
use Magento\Webapi\Controller\Rest\InputParamsResolver;
use Redis;
/**
* Enforces a Redis-backed sliding window limit on REST API requests
* before the resolved controller action is dispatched.
*/
final class SlidingWindowRestLimiterPlugin
{
private const WINDOW_SECONDS = 60;
private const MAX_REQUESTS = 120;
public function __construct(
private readonly Redis $redis,
private readonly Request $request
) {
}
/**
* Rejects the request with HTTP 429 once the client exceeds
* the configured sliding window quota.
*
* @throws LocalizedException
*/
public function beforeResolve(InputParamsResolver $subject): void
{
$apiKey = $this->request->getHeader('X-Api-Key') ?: $this->request->getClientIp();
$key = "ratelimit:sliding:{$apiKey}";
$now = microtime(true);
$windowStart = $now - self::WINDOW_SECONDS;
// Drop timestamps outside the current window, then count and record
$this->redis->zRemRangeByScore($key, '-inf', (string) $windowStart);
$count = $this->redis->zCard($key);
if ($count >= self::MAX_REQUESTS) {
throw new LocalizedException(__('Rate limit exceeded, please retry later.'), null, 429);
}
$this->redis->zAdd($key, $now, (string) $now);
$this->redis->expire($key, self::WINDOW_SECONDS);
}
}
8. Monitoring and alerting for rate limiting systems
Rate limiting without monitoring is flying blind: without visibility into rejected requests, affected endpoints, and their distribution across client IDs, it stays unclear whether limits are too strict, too lax, or exactly right. Key metrics are the 429 rate per endpoint, the top-N clients by rejection rate, and the latency of the rate limit check itself, since an overloaded Redis cluster can paradoxically turn the rate limiter into the bottleneck. These metrics can be exposed via Prometheus exporters from PHP code or the gateway and visualized in Grafana.
Alerting rules should distinguish between two scenarios: a sudden spike in the 429 rate on a single endpoint, which points to an active attack or a misbehaving integration client, and a uniformly elevated rejection rate across many clients, which more likely indicates a global limit that's too strict after organic traffic growth. Structured logging of every rejection with client identifier, endpoint, timestamp, and current counter value enables after-the-fact forensic analysis and the distinction between one-off outliers and systematic abuse that justifies a permanent block list.
{
"alert": "HighRateLimitRejectionRate",
"expr": "sum(rate(ratelimit_rejected_total[5m])) by (endpoint) > 50",
"for": "2m",
"labels": { "severity": "warning", "team": "platform" },
"annotations": {
"summary": "Elevated 429 rate on {{ $labels.endpoint }}",
"description": "More than 50 rejected requests per second over 5 minutes, check for abuse or misbehaving client."
},
"sample_log_entry": {
"timestamp": "2026-07-12T09:14:32Z",
"event": "rate_limit_rejected",
"client_key": "api-key:integration-erp-01",
"endpoint": "/rest/V1/products",
"limit": 120,
"window_seconds": 60,
"current_count": 121,
"client_ip": "203.0.113.42"
}
}
9. Rate limiting algorithms compared side by side
Every algorithm solves the request-counting boundary problem with different trade-offs between accuracy, memory cost, and implementation effort. The table below summarizes which algorithm is the right choice for which use case.
| Algorithm | Burst handling | Memory cost | Accuracy | Recommendation |
|---|---|---|---|---|
| Fixed Window | Double rate possible at window boundary | Minimal, 1 counter | Inaccurate at window edges | Only for simple, non-critical endpoints |
| Sliding Window Counter | Smooths boundary effect reliably | Minimal, 2 counters | Very good approximation | Recommended default for most APIs |
| Token Bucket | Excellent, native burst support | Low, 1 bucket per client | Very accurate | Recommended for APIs with variable traffic |
| Sliding Log | Excellent, exact history | High, timestamp per request | Exact, no approximation | Only practical at low volume |
In practice, many production systems combine token bucket at the application layer for granular, business-logic-aware limits with a simpler sliding window counter at the gateway for coarse, volumetric protection. Despite its exactness, sliding log usually stays reserved for niche use cases with low traffic volume, while fixed window is increasingly being replaced by sliding window variants due to its boundary problem.
Mironsoft
API security, rate limiting, and Magento hardening for production stores
Ready for resilient API rate limiting?
We analyze your REST and GraphQL endpoints, identify unprotected resources, and implement a Redis-backed rate limiting system that stops abuse without slowing down legitimate customers.
Rate limiting audit
Analysis of your endpoints, prioritized by abuse risk
Redis implementation
Token bucket and sliding window limiters as a Magento plugin
Monitoring & alerting
Prometheus metrics and alert rules for rejection rates
10. Summary
API rate limiting solves a fundamental problem of every public interface: without quotas, a single client, whether malicious or simply misconfigured, can slow down the entire system for every other user. Fixed window is simple but inaccurate at window boundaries. Sliding window counter and token bucket deliver the best balance of accuracy, memory cost, and burst tolerance for most APIs. The choice between per-IP and per-API-key limiting should follow the authentication status of the endpoint, with a combination of both layers as the most robust solution.
Rate limit headers like X-RateLimit-Remaining and Retry-After turn a hard limit into cooperative communication with well-implemented clients. The combination of a gateway layer for coarse, volumetric protection and an application layer for business-logic-aware limits, implemented through a Redis-backed, atomic counter in Magento, covers both simple mass attacks and targeted abuse. Continuous monitoring with clear alerting thresholds ensures limits neither cause customer loss through over-blocking nor leave the system exposed to unprotected overload.
API Rate Limiting Against Abuse and Overload - The Essentials at a Glance
The right algorithm
Sliding window counter or token bucket instead of fixed window, better accuracy and controlled burst tolerance.
Layered defense
Coarse gateway limit against volumetric attacks, fine-grained application limit for business logic.
Cooperative clients
X-RateLimit-* and Retry-After headers reduce unnecessary 429 errors for well-implemented integrations.
Continuous monitoring
Track 429 rates per endpoint and client, separate alerts for attack patterns from overly strict limits.