RE:NODE

Databases11 min read

Rate limiting with Valkey: windows, buckets and Lua

Build rate limits on Valkey: fixed and sliding windows, token buckets, atomic Lua scripts, what to key on, and the libraries that already do it right.

0 readers

A rate limiter counts how often something happens and refuses it past a threshold. The counting has to be shared - an in-process counter multiplies your limit by the number of workers and resets on every deploy - and it has to be atomic, because two requests checking "is this the fifth?" at the same moment must not both get a yes. Valkey is good at exactly that: every command runs one at a time, INCR is atomic, keys can expire on their own, and a Lua script runs as a single uninterruptible step.

There are four algorithms in common use: fixed window, sliding log, sliding window counter and token bucket. Each is a few lines against Valkey, and each fails differently at the edges. This post builds all four, says which to pick, and covers the decisions that matter more than the algorithm - what you key the limit on, what happens when Valkey is unreachable, and what to send back to the client.

Where rate limits belong, and where Valkey fits#

Not every limit needs a store. A limit in the web server or reverse proxy - nginx's limit_req, for instance - stops traffic before it reaches your application at all, needs no Valkey, and is the right place for crude per-address flood protection. Rate limits and abuse covers that layer and what to count.

Valkey earns its place for limits that need application knowledge:

  • Per account or per API key, not per address: "1,000 requests an hour on the free tier".
  • Per action: five login attempts per account per fifteen minutes, three password-reset emails per hour, one signup per address per minute.
  • Across several processes or servers, where each must see the same count.
  • Quotas with business meaning, such as messages per day, that must survive a restart.

The pattern is always the same: build a key from what you are limiting (rl:login:user:4182), do an atomic operation against it, read the result, decide.

Fixed window: INCR and EXPIRE#

The simplest limiter counts requests in a window aligned to the clock - this minute, this hour - and resets when the window rolls over.

code
INCR  rl:api:key_abc:202610081412EXPIRE rl:api:key_abc:202610081412 60 NX

The window number is in the key (here, minute 14:12), so a new window is simply a new key. INCR creates the key at 1 if it does not exist and returns the new count; if the count is above the limit, refuse. EXPIRE ... NX sets the expiry only if none is set, so the key cleans itself up after the window. The NX option on EXPIRE has been available since the 7.0 command set that Valkey inherited; on older servers, set the expiry only when INCR returned 1.

Send both commands in a pipeline or a MULTI/EXEC transaction so that a crash between them cannot leave a counter without an expiry - a key that never expires is a user who is rate limited forever.

The weakness is the boundary. With a limit of 100 per minute, a client can send 100 requests at 14:12:59 and another 100 at 14:13:00: 200 requests in two seconds, all allowed. For login protection that does not matter much. For protecting an expensive endpoint, it can.

AlgorithmMemory per clientBoundary burstsPrecisionComplexity
Fixed windowOne small keyUp to 2x the limitLowTrivial
Sliding logOne entry per requestNoneExactModerate
Sliding window counterTwo small keysSmoothed outApproximateLow
Token bucketOne small hashAllowed up to bucket size, by designExact rateModerate

Sliding log with a sorted set#

A sliding log records the timestamp of every request and counts how many fall within the last N seconds. In Valkey that is a sorted set, with the timestamp as the score.

code
ZREMRANGEBYSCORE rl:api:key_abc 0 (now - 60000)ZADD             rl:api:key_abc now now:randomZCARD            rl:api:key_abcPEXPIRE          rl:api:key_abc 60000

Remove everything older than the window, add this request, count what is left, and refresh the expiry so idle clients' keys disappear. The member must be unique - two requests in the same millisecond with the same member would collapse into one - so append a random suffix or a request id.

This is exact: there is no boundary to exploit. The cost is memory proportional to the limit. A limit of 10 per minute stores at most 10 entries per client; a limit of 10,000 per hour stores up to 10,000. For low limits on sensitive actions - logins, password resets, one-time codes - a sliding log is ideal. For high-volume API limits it is wasteful.

There is also a subtle choice: does a rejected request count? In the sequence above it does, because it was added before counting. That means a client hammering the endpoint stays locked out until it stops, which is usually what you want for brute force. If you would rather count only accepted requests, check the count first and add only if it is under the limit - and then the check and the add must happen atomically, which is what the Lua section below is for.

Sliding window counter#

The sliding window counter keeps the fixed window's two small keys and approximates a sliding window by weighting the previous window.

Suppose the limit is 100 per minute, it is 14:13:15, the previous minute had 80 requests and this minute has 30 so far. Fifteen seconds into the current window, 45 seconds of the previous window still fall inside "the last 60 seconds". The estimate is:

code
estimate = current + previous * (60 - 15) / 60         = 30 + 80 * 0.75         = 90      (allowed, under 100)

It assumes requests in the previous window were spread evenly, so it is not exact, but it removes the double-burst problem of the fixed window and uses two counters regardless of the limit. This is what many large API gateways use for exactly that reason. The Valkey side is two GETs and an INCR with an expiry of twice the window, ideally in one script.

Token bucket in an atomic Lua script#

A token bucket allows a sustained rate with a burst allowance. The bucket holds up to capacity tokens and refills at rate tokens per second; each request takes one. A client that has been quiet can burst up to the capacity, then is held to the refill rate. It is the most natural model for APIs, and the one that most clearly needs atomicity: read the bucket, compute the refill, take a token, write it back, all as one step.

Lua scripts in Valkey run atomically: no other command executes while a script runs.

token_bucket.lua
-- KEYS[1] bucket key; ARGV: capacity, refill per second, now in ms, costlocal capacity = tonumber(ARGV[1])local rate     = tonumber(ARGV[2])local now      = tonumber(ARGV[3])local cost     = tonumber(ARGV[4])local state  = redis.call("HMGET", KEYS[1], "tokens", "ts")local tokens = tonumber(state[1]) or capacitylocal ts     = tonumber(state[2]) or nowlocal elapsed = math.max(0, now - ts) / 1000tokens = math.min(capacity, tokens + elapsed * rate)local allowed = 0if tokens >= cost then  tokens = tokens - cost  allowed = 1endredis.call("HSET", KEYS[1], "tokens", tokens, "ts", now)redis.call("PEXPIRE", KEYS[1], math.ceil(capacity / rate * 1000) + 1000)local retry_ms = 0if allowed == 0 then retry_ms = math.ceil((cost - tokens) / rate * 1000) endreturn { allowed, math.floor(tokens), retry_ms }

Load it once and call it by hash:

bash
$ valkey-cli --askpass -h 203.0.113.20 -p 6380 SCRIPT LOAD "$(cat token_bucket.lua)""3f1a..."$ valkey-cli --askpass -h 203.0.113.20 -p 6380 EVALSHA 3f1a... 1 rl:tb:key_abc 20 5 1791468000000 1

Most client libraries wrap this: they send EVALSHA and fall back to EVAL with the full script if the server answers NOSCRIPT, which happens after a restart because loaded scripts are not persisted. Valkey also accepts server.call as an alias for redis.call inside scripts; redis.call is used here because it runs on both Valkey and Redis.

Notes on the script:

  • The current time is passed in from the client. That keeps the script deterministic, but it means every application server needs an accurate clock. If your servers' clocks disagree by seconds, buckets refill wrongly. Run NTP, or call TIME inside the script instead.
  • The expiry is set to the time a bucket takes to refill completely, plus a margin, so idle clients cost nothing.
  • `retry_ms` is returned so the caller can send a Retry-After header.

The same script shape - read state, decide, write state - is how to make any limiter atomic, including the "count only accepted requests" sliding log.

Testing a limiter before trusting it

A limiter is security code, and it deserves the same tests. Three are worth automating:

  1. Concurrency. Fire twice the limit in parallel from several processes and count how many succeed. If more than the limit get through, something is not atomic - usually a read and a write in separate round trips.
  2. Boundaries. For windows, send the full limit just before a window boundary and again just after. You should see exactly the behaviour you chose: a fixed window allows both bursts, a sliding log allows neither.
  3. Recovery. Exhaust the limit, wait for the advertised Retry-After, and confirm the next request is allowed. An off-by-one in expiry arithmetic shows up here as clients locked out for one window longer than you told them.

Also test the failure path by pointing the limiter at a port where nothing is listening: the request should be allowed or refused according to your fail-open or fail-closed decision, and within your timeout, not after thirty seconds. Run these against a real Valkey, not a mock - the point is to test the atomicity the server provides, which a mock does not have.

Choosing the key: what you are actually limiting#

The algorithm is the easy part. The key decides whether the limit stops abuse or annoys customers.

  • By IP address is the only option for anonymous traffic and the weakest. Behind a reverse proxy every request arrives from the proxy's address, so you must read the client address from X-Forwarded-For - and only trust that header when it was set by your own proxy, or anyone can send a fake one. Many users share an address (offices, mobile carriers with CGNAT), so per-address limits must be generous. What a reverse proxy does explains the header.
  • By account or API key is precise and fair. Use it for everything behind authentication.
  • By target, for logins: limit attempts per username as well as per address, so a distributed attack on one account is caught even when each address only tries once.
  • By action: separate keys for separate costs. A search endpoint and a profile read should not share a budget.

Put a version and a purpose in every key - rl:v1:login:user:4182 - so a change of algorithm is a new prefix rather than a migration, and so --scan --pattern 'rl:*' finds them all.

Failing open or closed, and telling the client#

Decide what happens when Valkey cannot be reached, because it will happen eventually.

  • Fail open (allow the request) for general API limits. A rate limiter that takes the whole site down when it is unavailable has turned a protection into an outage.
  • Fail closed (refuse) for limits that protect something valuable: login attempts, password resets, one-time code verification, expensive paid API calls.

Either way, set a short client timeout - tens of milliseconds - so a slow Valkey cannot add seconds to every request.

When you refuse, return HTTP 429 Too Many Requests with a Retry-After header in seconds. Well-behaved clients and SDKs back off automatically when they see it. Many APIs also send the remaining budget on every response, commonly as X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, or the standardised RateLimit headers that are being drafted at the IETF. Whatever you choose, document it.

Libraries that already do this#

Writing a limiter is instructive. Shipping one you wrote is usually unnecessary.

RuntimeLibraryNotes
Node.jsrate-limiter-flexibleFixed window and token-bucket style limiters, Redis-protocol store
Node.jsexpress-rate-limit with rate-limit-redisExpress middleware with a shared store
Pythonlimits, used by Flask-Limiter and slowapiFixed and moving windows on a Redis-protocol backend
Djangodjango-ratelimitUses the configured cache, so point the cache at Valkey
LaravelBuilt-in RateLimiter and throttle middlewareUses the cache store; set the cache driver to redis
ASP.NET CoreBuilt-in rate limiting middlewareIn-memory per process; a shared store needs a third-party package

The ASP.NET Core row is the trap: AddRateLimiter from .NET 7 onwards is per process, so two instances each enforce the full limit. That is fine for protecting one instance from overload and wrong for enforcing a customer's quota.

Valkey's memory and persistence settings matter less here than for sessions or queues. Rate-limit keys are tiny and short-lived; losing them on a restart simply resets everyone's counters. An evicting policy is acceptable for a Valkey that only holds limits. If the same instance holds sessions or jobs, see Valkey memory and eviction policies before choosing.

FAQ#

Which algorithm should I use?

Sliding log for low limits on sensitive actions such as logins, because it is exact. Token bucket for APIs, because it allows reasonable bursts and holds a steady rate. Fixed window when you need something today and boundary bursts do not matter. The sliding window counter is a good general default when memory per client matters.

Is a MULTI/EXEC transaction enough instead of Lua?

For fixed windows, yes: INCR and EXPIRE do not depend on each other's results. For anything that reads a value and decides what to write, no - MULTI queues commands but cannot branch on what they return. That is what a Lua script is for.

How much memory do rate-limit keys use?

Very little. A fixed-window counter or a token-bucket hash is well under a hundred bytes plus key overhead, and expires on its own. A hundred thousand active clients fit in a few tens of megabytes. Sliding logs are the exception: they store one entry per request in the window.

Can I rate limit by IP behind Cloudflare or another proxy?

Yes, using the client address the proxy forwards, and only after confirming the header came from your proxy. Read the address from the header your proxy documents, and ignore it on requests that did not come through the proxy.

Does a restart of Valkey reset all limits?

Without persistence, yes. With AOF enabled, counters survive a restart, losing at most about a second of increments. For rate limits that is usually fine either way; for daily quotas with business meaning, keep persistence on.


Comments

Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.

0/2000