skills/rate-limiting-strategy/SKILL.md
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.
npx skillsauth add curiositech/windags-skills rate-limiting-strategyInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Three decisions before any code: which algorithm, where in the stack, what key. Get those right and the implementation is 30 lines of Lua. Get any of them wrong and you'll either let an attacker through or wreck legitimate clients on traffic spikes.
The widely-recommended starting position, repeated across Redis's own tutorial, API7's algorithm guide, and Cloudflare's deployment data, is sliding window counter as a general-purpose default — it has the accuracy of a sliding window log without the O(n) memory (Redis — Build 5 Rate Limiters, API7 — Rate Limiting Guide). Cloudflare's published numbers: only 0.003% misclassification across 400M requests with sliding window counter (API7).
algorithm → sliding window counter (general); token bucket (allow bursts); leaky bucket (smooth output)
where → edge (Cloudflare WAF / Upstash) for unauth; origin (Redis) for per-user
key → authenticated user ID > IP+UA > IP > global
Jump to your fire:
/login, /register, /forgot-password) need brute-force protection.| Algorithm | Memory | Accuracy | Bursts | Best for | |---|---|---|---|---| | Fixed window counter | O(1) per key | Approximate; 2× at boundaries | Yes (the bug) | Simple internal limits where 2× isn't catastrophic | | Sliding window log | O(n) per key (one entry per request) | Exact | No | High-value APIs where the cost is acceptable | | Sliding window counter | O(1) per key (two windows) | Near-exact | Smoothed | General-purpose default (api7-ratelimit) | | Token bucket | O(1) per key | Exact | Controlled bursts up to capacity | Bursty workloads (CI runs, batch imports) | | Leaky bucket — policing | O(1) per key | Exact | None | Strict steady-rate enforcement | | Leaky bucket — shaping | O(1) per key | Exact, but adds latency | None | Outbound to a downstream with strict pacing |
The Redis tutorial's compressed comparison: (redis-ratelimit)
"Start with Sliding Window Counter — it balances accuracy, simplicity, and memory efficiency for most production APIs."
The simplest algorithm has a real bug worth understanding before you ship it:
limit = 10 requests per 10 seconds, fixed windows aligned to wall clock.
Window A: [00:00:00 – 00:00:09]
Window B: [00:00:10 – 00:00:19]
Client sends 10 requests at 00:00:09.5 → all in window A, all allowed.
Client sends 10 requests at 00:00:10.5 → all in window B, all allowed.
Net: 20 requests in 1 second within a "10 per 10 seconds" limit.
That's the boundary burst. Sliding-window counter fixes it by interpolating across windows. Sliding-window log fixes it exactly at O(n) memory cost.
| Where | Pros | Cons | Use for |
|---|---|---|---|
| CDN / WAF edge (Cloudflare Rate Limiting, AWS WAF, Fastly) | Cheapest — drops before origin pays. Globally distributed. | Coarse keys (IP, header, JWT subject). | Unauthenticated traffic, abuse, DDoS-adjacent. |
| API gateway (Kong, Envoy, Traefik) | Per-route, easy to standardize. | Single point if it's a single instance. | Per-tier quotas, internal mesh. |
| Application + Redis | Full context (user_id, plan, endpoint cost). | Each request hits origin. | Per-user quota, plan-based limits, billing. |
| In-process (@nestjs/throttler without Redis) | Zero infrastructure. | Per-instance only — N pods = N× the limit. | Single-replica internal tools. Rarely correct in prod. |
The right architecture is usually edge + origin in series: edge drops obvious abuse cheaply; origin enforces per-user quotas with full context. Don't try to do per-user quota at the edge with imperfect identification, and don't try to do DDoS at the origin.
Cloudflare offers WAF Rate Limiting rules that key on common headers and Bot Score; Upstash offers @upstash/ratelimit for Workers/edge runtimes — both are sliding-window-counter-based. (Upstash docs)
The key determines who shares the limit. Bad keys are how you DOS your own users.
| Tier | Key | Notes | |---|---|---| | 1 | Authenticated user ID | Best for product limits. Requires auth to be settled before the limiter. | | 2 | API key / token hash | For B2B; pair with plan tier. | | 3 | IP + user-agent + path | For unauthenticated; UA reduces NAT collisions. | | 4 | IP only | Coarsest; corporate NATs and CGNAT collide hundreds of users. | | 5 | Global (per-endpoint) | Use only as a backstop ("never more than 5k/sec to /search"). |
For login: rate-limit by (IP, username) AND (IP) AND (username) simultaneously. An attacker brute-forcing one user's password rotates IPs; an attacker spraying weak passwords across many users uses one IP; both must be blocked. Limit each tuple separately.
The single common requirement: atomic read-modify-write. That means a Lua script via EVAL (or EVALSHA cached). Without atomicity you have race-induced over-allows under contention.
The Redis tutorial's pattern: (redis-ratelimit)
-- KEYS[1] = bucket key, ARGV = now (ms), windowMs, limit, member
local now = tonumber(ARGV[1])
local windowMs = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local member = ARGV[4]
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - windowMs)
local count = redis.call('ZCARD', KEYS[1])
if count < limit then
redis.call('ZADD', KEYS[1], now, member)
redis.call('PEXPIRE', KEYS[1], windowMs + 1000)
return {1, limit - count - 1} -- allowed, remaining
end
return {0, 0} -- denied
member should be now .. ':' .. randomBytes so duplicates don't collide. Keep limit ≤ ~10k; sorted set memory grows linearly with traffic.
Approximate sliding window using two adjacent fixed windows and weighted interpolation:
-- ARGV: now_ms, windowMs, limit
local now = tonumber(ARGV[1])
local windowMs = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local currentWindow = math.floor(now / windowMs)
local currentKey = KEYS[1] .. ':' .. currentWindow
local prevKey = KEYS[1] .. ':' .. (currentWindow - 1)
local current = tonumber(redis.call('GET', currentKey) or '0')
local previous = tonumber(redis.call('GET', prevKey) or '0')
local elapsedInCurrent = (now % windowMs) / windowMs
local weighted = previous * (1 - elapsedInCurrent) + current
if weighted + 1 <= limit then
redis.call('INCR', currentKey)
redis.call('PEXPIRE', currentKey, windowMs * 2)
return {1, math.floor(limit - weighted - 1)}
end
return {0, 0}
O(1) memory per principal. Cloudflare's deployment shows ~0.003% error on 400M requests. (api7-ratelimit)
-- ARGV: now_ms, capacity, refill_per_sec, cost
local key = KEYS[1]
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local refill = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local s = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(s[1]) or capacity
local lastTs = tonumber(s[2]) or now
local elapsedSec = (now - lastTs) / 1000
tokens = math.min(capacity, tokens + elapsedSec * refill)
if tokens >= cost then
tokens = tokens - cost
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill) + 60)
return {1, math.floor(tokens)}
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
return {0, 0}
cost per request lets you charge expensive endpoints more (a vector embedding might cost 10 tokens, a metadata read 1).
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))
end
return count
Cheap, simple, has the boundary-burst bug. Acceptable for "no more than N writes per minute to a low-stakes internal endpoint" but not for anything customer-facing.
The full surface, per the modern conventions (MDN — 429 Too Many Requests):
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1714592400
{
"error": "rate_limit_exceeded",
"message": "100 requests per minute exceeded.",
"retry_after": 30
}
Retry-After: integer seconds (or HTTP date). Required for the client to back off correctly.RateLimit-* headers (IETF draft): Limit, Remaining, Reset (epoch seconds). Some implementations still use the legacy X-RateLimit-*; emit both during transition."4 attempts left" becomes a brute-force aid. Keep the message generic.{user:42} as part of the key so the surrounding key components don't break atomicity.// Hono / Express middleware sketch.
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const slidingWindowLua = await redis.script('LOAD', SCRIPT);
export const rateLimit = (opts: { windowMs: number; limit: number; keyFn: (c) => string }) =>
async (c, next) => {
const key = `rl:${opts.keyFn(c)}`;
const member = `${Date.now()}:${crypto.randomBytes(4).toString('hex')}`;
const [allowed, remaining] = await redis.evalsha(
slidingWindowLua, 1, key, Date.now(), opts.windowMs, opts.limit, member
) as [number, number];
c.header('RateLimit-Limit', String(opts.limit));
c.header('RateLimit-Remaining', String(remaining));
c.header('RateLimit-Reset', String(Math.ceil((Date.now() + opts.windowMs) / 1000)));
if (!allowed) {
c.header('Retry-After', String(Math.ceil(opts.windowMs / 1000)));
return c.json({ error: 'rate_limit_exceeded' }, 429);
}
return next();
};
OTel span attributes per request: ratelimit.key, ratelimit.allowed, ratelimit.remaining (see opentelemetry-instrumentation).
Symptom: Limit is "100/min" but production allows 100×N (N pods) per minute. Scaling out silently raises the effective limit. Diagnosis: No shared state. Fix: Redis (or equivalent) backing.
Symptom: Reports of "I'm getting 200 requests through in two seconds" despite the 100/min limit. Diagnosis: Boundary burst. Fix: Sliding window counter (or sliding window log if exactness required).
Symptom: All requests appear to come from the load balancer's internal IP; one user's spam blocks everyone.
Diagnosis: App reads req.connection.remoteAddress, not X-Forwarded-For.
Fix: Read the trusted forwarded IP. Validate the proxy chain. Better yet, key on the authenticated user where possible.
Retry-After headerSymptom: Clients hammer the API in tight loops after 429s; outages look worse than they are.
Diagnosis: No explicit backoff signal.
Fix: Always emit Retry-After. Document the header. Pair with RateLimit-*.
Symptom: Brute-force succeeds because a 1000/min limit on /login is way too generous, OR legitimate clients get blocked because a 5/min limit also applies to /health.
Diagnosis: One global limit policy.
Fix: Differentiate by endpoint cost / risk: auth ~5–10/min per IP+username; reads ~1000/min per user; expensive (LLM, search) by token-bucket cost.
Symptom: Counter occasionally allows a few above the limit under concurrency.
Diagnosis: Sequence of GET/SET that isn't atomic.
Fix: All limiter logic in a single EVAL (Lua). Use EVALSHA after the script is loaded.
Symptom: MOVED errors, slot mismatch, or atomicity broken.
Diagnosis: Lua script touches multiple keys that hash to different slots.
Fix: Use {tag} syntax: rl:{user:42}:current and rl:{user:42}:prev hash to the same slot.
Symptom: Abuse goes through during a Redis blip; nobody notices. Diagnosis: Limiter caught the error, allowed the request, didn't log/alert. Fix: Emit a metric on every fail-open; alert if rate > N per minute.
Symptom: A scraper learns the exact limit and stays just under it.
Diagnosis: "You have 4/5 attempts remaining" is reconnaissance.
Fix: Generic 429 message. Quota status only on authenticated, scoped endpoints.
EVAL/EVALSHA Lua script — no race in the read-modify-write.{tag} hash co-location) if using Redis Cluster.Retry-After AND RateLimit-Limit/Remaining/Reset headers on every 429.grafana-dashboard-builder).ratelimit.key, ratelimit.allowed, ratelimit.remaining) — see opentelemetry-instrumentation.background-job-queue-design.@upstash/ratelimit (sliding-window counter for edge runtimes). upstash.com/docs/oss/sdks/ts/ratelimit/overviewRateLimit-Limit, RateLimit-Remaining, RateLimit-Reset). datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/data-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
tools
Use when designing or fixing the retrieval side of a RAG system, choosing chunking strategy (fixed-size / recursive / semantic), implementing hybrid search (BM25 + dense) with RRF fusion, adding a cross-encoder reranker, evaluating with RAGAS, or running an index-freshness pipeline. Triggers: "RAG keeps citing the wrong doc", chunk size 512 tokens with overlap, RRF reciprocal rank fusion, dense+sparse hybrid, cross-encoder rerank top-5, RAGAS faithfulness / context precision, voyage-3-large vs text-embedding-3-large, daily / hourly reindex cadence. NOT for fine-tuning vs RAG decision (separate skill), agentic tool-use designs, vector DB operational tuning, or general LLM prompt engineering.