Rate Limiting with Redis
Rate limiting caps how many requests a client — a user, an API key, an IP address — can make in a given time window, protecting your application from abuse, runaway retries, and unfair resource consumption. Redis is one of the most popular building blocks for rate limiting because a single command like INCR is atomic and sub-millisecond, and Redis keys can carry their own expiration, so a counter can enforce a limit and then clean up after itself with no extra bookkeeping. This lesson builds two production-grade rate limiting patterns from raw commands — a fixed window counter and a sliding window log — and explains exactly what Redis does internally when you run them.
Overview / How Rate Limiting Works in Redis
Rate limiting exists to answer one question fast: has this client already used up its allowance for the current window? Doing that check safely under concurrency is the hard part, and it is exactly the problem Redis is built to solve. Redis executes commands one at a time on a single thread — no two commands can ever interleave mid-execution — so a command like INCR is inherently atomic: a thousand concurrent requests calling INCR on the same key will each get back a distinct, correct sequential value, with no lost updates and no application-level locking required. Contrast this with a naive scheme where the application runs GET counter, checks the value in its own code, then issues SET counter newValue. Between the GET and the SET, another request can run the same two steps, and both requests end up believing they were under the limit at the same moment. That race condition simply cannot happen when the whole increment-and-check happens inside one Redis command.
There are several standard rate-limiting algorithms, and Redis supports all of them well:
- Fixed window counter — increment a counter for the current time bucket and let it expire when the bucket ends. Cheap (one key, one
INCR), but it allows a burst of up to twice the limit right at a window boundary, since a client can spend its full quota in the last second of one window and its full quota again in the first second of the next. - Sliding window log — store a timestamp for every request as a member of a sorted set, and evict every member older than
now - windowSizebefore counting. Precise, with no boundary burst, but memory use scales with request volume inside the window since every request gets its own entry. - Sliding window counter — a hybrid that keeps two fixed-window counters and weights them by how far the current moment is through the window, approximating a true sliding window at the storage cost of a fixed window.
- Token bucket / leaky bucket — a bucket refills with tokens at a steady rate up to a capacity, and each request consumes a token. Because refilling depends on elapsed time since the last request, a correct atomic implementation usually needs a small server-side Lua script (
EVAL) rather than one built-in command.
This lesson focuses on the two you can build entirely from plain commands: the fixed window counter (INCR + EXPIRE) and the sliding window log (sorted sets).
Under the hood, INCR looks up the key in Redis’s main hash table; if it is missing, Redis creates a new string holding the integer 0 (using Redis’s shared small-integer object pool when possible, avoiding an allocation for common small values), adds 1, and writes it back — all before the command returns. EXPIRE does not touch the value at all; it writes an absolute deletion timestamp into a separate internal dictionary that maps keys to expiration times. Redis checks that dictionary two ways: lazily, the instant any command touches a key, treating an expired-but-not-yet-deleted key as if it does not exist; and actively, via a background cycle that repeatedly samples keys carrying a TTL and deletes any that have already expired, even if nothing ever reads them again. That combination is what makes a rate-limit key self-cleaning: you set the TTL once, and Redis guarantees the key is gone (or at least treated as gone) once the window passes, with zero cleanup code in your application.
Syntax
The fixed-window and sliding-window-log patterns are built entirely from these commands:
| Command | Form | What it does | Time complexity |
|---|---|---|---|
INCR |
INCR key |
Increments the integer stored at key by 1, creating it with value 0 first if it does not exist. Returns the new value. |
O(1) |
EXPIRE |
EXPIRE key seconds [NX|XX|GT|LT] |
Sets a TTL on key. NX only sets it if the key has no existing TTL; XX only if it already has one; GT/LT only if the new TTL is greater/less than the current one. Returns 1 if the timeout was set, 0 otherwise. |
O(1) |
TTL |
TTL key |
Returns remaining seconds until expiration, -1 if the key exists with no TTL, or -2 if the key does not exist. |
O(1) |
ZADD |
ZADD key score member [score member ...] |
Adds members to a sorted set with the given score (here, a timestamp). Returns the number of new members added. | O(log N) per added member |
ZREMRANGEBYSCORE |
ZREMRANGEBYSCORE key min max |
Removes all members whose score falls between min and max inclusive; use -inf/+inf for open-ended ranges. Returns the number removed. |
O(log N + M) |
ZCARD |
ZCARD key |
Returns the number of members currently in the sorted set. | O(1) |
ZRANGE |
ZRANGE key start stop |
Returns members between index start and stop (0-based, inclusive), ordered by score. |
O(log N + M) |
Examples
Example 1: A basic fixed-window counter
Every request for a client increments one counter key. The very first request also attaches a 60-second TTL using EXPIRE ... NX, so the window resets 60 seconds after the first request in it — and later requests in the same window cannot accidentally push that expiration further out, because NX refuses to overwrite an existing TTL.
INCR ratelimit:fixed:user1001
EXPIRE ratelimit:fixed:user1001 60 NX
TTL ratelimit:fixed:user1001
INCR ratelimit:fixed:user1001
EXPIRE ratelimit:fixed:user1001 60 NX
TTL ratelimit:fixed:user1001
Output:
(integer) 1
(integer) 1
(integer) 60
(integer) 2
(integer) 0
(integer) 60
The first INCR creates the key at 1. The first EXPIRE ... NX succeeds (returns 1) because the key had no TTL yet, and TTL confirms 60 seconds remain. The second request increments to 2, but its EXPIRE ... NX returns 0 — the TTL was already set, so NX refused to touch it — and TTL still shows the original window.
Example 2: Enforcing the limit across a burst of requests
Suppose the application enforces a maximum of 5 requests per 10-second window. It calls INCR on every request and compares the returned value against the limit; once the value exceeds the limit, the request is rejected (the key itself is left alone — no need to decrement anything).
INCR ratelimit:fixed:user77
EXPIRE ratelimit:fixed:user77 10 NX
INCR ratelimit:fixed:user77
INCR ratelimit:fixed:user77
INCR ratelimit:fixed:user77
INCR ratelimit:fixed:user77
INCR ratelimit:fixed:user77
Output:
(integer) 1
(integer) 1
(integer) 2
(integer) 3
(integer) 4
(integer) 5
(integer) 6
Six requests arrived; the counter climbed from 1 to 6. With a limit of 5, the application allows requests while the returned value is <= 5 and rejects the one that returns 6 — all without a second round trip to check the value first, because INCR both updates and returns the count in one atomic step.
Example 3: A precise sliding-window log with sorted sets
A sorted set stores one member per request, scored by the request’s timestamp in milliseconds. To count requests in the trailing window, first evict anything older than the window, then read the size. Here the window is 1000ms and the current moment is simulated as timestamp 2100, so anything with a score at or before 1100 (2100 – 1000 + a small buffer) falls out of the window.
ZADD ratelimit:sliding:user42 1000 "1000-req1"
ZADD ratelimit:sliding:user42 1200 "1200-req2"
ZADD ratelimit:sliding:user42 1900 "1900-req3"
ZCARD ratelimit:sliding:user42
ZREMRANGEBYSCORE ratelimit:sliding:user42 -inf 1100
ZCARD ratelimit:sliding:user42
ZRANGE ratelimit:sliding:user42 0 -1
Output:
(integer) 1
(integer) 1
(integer) 1
(integer) 3
(integer) 1
(integer) 2
1) "1200-req2"
2) "1900-req3"
Three requests were logged, so ZCARD first reports 3. ZREMRANGEBYSCORE then removes the one entry with a score at or below 1100 (the request timestamped 1000), and the second ZCARD shows only 2 requests remain inside the sliding window. ZRANGE confirms exactly which two survived. A real client would run this same evict-then-count pair on every incoming request, using the actual current time in milliseconds as the cutoff.
How It Works Step by Step
Walking through Example 1’s first two commands shows exactly what the server does:
- The client sends
INCR ratelimit:fixed:user1001. Because Redis is single-threaded, this command runs to completion before the server looks at any other pending command — so two concurrent clients incrementing the same key can never both read the pre-increment value. - Redis looks up the key in its main dictionary. It is absent, so Redis creates a new string object holding integer
0, increments it to1, stores it back, and returns(integer) 1to the client. - The client sends
EXPIRE ratelimit:fixed:user1001 60 NX. Redis checks the separate expires dictionary for an existing entry under this key. - No entry exists, so the
NXcondition is satisfied: Redis writes an absolute deletion timestamp (now + 60 seconds) into the expires dictionary and returns(integer) 1. - On the next request’s
EXPIRE ... NX, an entry already exists, so the condition fails, nothing is written, and Redis returns(integer) 0— the original window is preserved. - Sixty seconds later, the key becomes eligible for deletion. If a command happens to touch it first, Redis notices the timestamp has passed and treats the key as absent (lazy expiration), deleting it on the spot. If nothing ever touches it again, a background cycle that samples keys carrying a TTL several times per second will find and delete it anyway (active expiration) — so the counter never lingers in memory past its window.
The sorted-set commands in Example 3 follow the same single-threaded guarantee: ZADD, ZREMRANGEBYSCORE, and ZCARD each run to completion atomically, so a client evicting old entries and counting the remainder never sees another client’s write appear halfway through. Internally, a small sorted set is stored in a compact listpack encoding; once it grows past configured size thresholds, Redis converts it to a skiplist plus hash table, which is what gives ZADD and ZREMRANGEBYSCORE their O(log N) cost.
Common Mistakes
Reusing a key across two different rate-limit algorithms
If a sliding-window log and a fixed-window counter ever share a key name, the second algorithm’s command fails outright, because every Redis key has exactly one type.
ZADD ratelimit:mixed:user3 1000 "1000-a"
INCR ratelimit:mixed:user3
Output:
(integer) 1
(error) WRONGTYPE Operation against a key holding the wrong kind of value
The key already holds a sorted set, so INCR — which expects a string — refuses to run. Fix it by namespacing keys per algorithm, e.g. ratelimit:fixed:* versus ratelimit:sliding:*, so the two schemes never collide.
Forgetting to set a TTL at all
A counter with no expiration never cleans itself up. Every distinct client you have ever rate-limited stays in memory forever.
INCR ratelimit:leaky:user5
TTL ratelimit:leaky:user5
Output:
(integer) 1
(integer) -1
TTL returning -1 means the key exists but has no expiration — it will sit in Redis’s memory indefinitely. Always pair the first INCR of a window with an EXPIRE ... NX call, as in Example 1.
Resetting a counter with a plain SET
It is tempting to “reset” a rate-limit key by writing a fresh value with SET, but a plain SET on an existing key clears any TTL that was attached to it, unless you add KEEPTTL.
INCR ratelimit:reset:user9
EXPIRE ratelimit:reset:user9 60 NX
TTL ratelimit:reset:user9
SET ratelimit:reset:user9 0
TTL ratelimit:reset:user9
Output:
(integer) 1
(integer) 1
(integer) 60
OK
(integer) -1
After the plain SET, TTL drops back to -1 — the window’s expiration silently vanished, turning a self-cleaning key into a permanent one. If you must overwrite the value in place, use SET ratelimit:reset:user9 0 KEEPTTL instead.
Assuming EXPIRE on a nonexistent key does something
EXPIRE ratelimit:ghost:user404 60
TTL ratelimit:ghost:user404
Output:
(integer) 0
(integer) -2
EXPIRE returns 0 because there was no key to attach a TTL to, and the follow-up TTL returns -2, confirming the key does not exist. Code that fires EXPIRE without first checking that the corresponding INCR actually created the key can silently no-op.
Checking and updating with two separate commands
Some implementations run GET to read the current count, compare it to the limit in application code, and only then call INCR or SET if the request is allowed. Even though each individual Redis command is atomic, the two round trips are not atomic together — another request from the same client can run the identical check in the gap between them, and both can conclude they are under the limit. The fix is always to fold the check into a single atomic command, as INCR does in Examples 1 and 2: increment first, then compare the returned value, never the other way around.
Best Practices
- Always attach a TTL to every rate-limit key so abandoned windows self-clean; never leave a bare
INCRwithout a matchingEXPIRE. - Prefer
EXPIRE key seconds NXover a plainEXPIREso later requests in the same window cannot keep pushing the deadline forward. - Namespace keys clearly, e.g.
ratelimit:<algorithm>:<identifier>, to avoid accidental type collisions between counters and sorted sets. - For strict limits that must not allow a boundary burst, use the sorted-set sliding-window-log pattern instead of a fixed window.
- Trim a sliding-window log on every request with
ZREMRANGEBYSCORE; skipping this lets the sorted set grow without bound. - For very high traffic, wrap a multi-command sequence (like evict-then-count) in a Lua script via
EVALor aMULTI/EXECtransaction so it executes as one atomic unit even under heavy concurrency. - Surface the remaining quota and reset time (via
TTL) back to the client so well-behaved callers can back off instead of retrying blindly. - Never overwrite a rate-limit key with a bare
SETonce it has a TTL — useKEEPTTL, or better, stick toINCR/EXPIREso you never need to overwrite it at all.
Practice Exercises
- Build a fixed-window limiter allowing at most 3 requests per 10 seconds for
ratelimit:practice:ip:203-0-113-5, using onlyINCRandEXPIRE ... NX. Work out which numbered request your application should start rejecting, and confirm withTTLthat the window does not reset on every call. - Using the sliding-window-log pattern, add members to
ratelimit:practice:apikey:demowith scores500,900,1400, and2600. If the window is 1000ms and the current time is simulated as2600, which members shouldZREMRANGEBYSCOREremove, and what shouldZCARDreport afterward? - A teammate’s rate limiter runs
GET counter, checks the value in application code, then conditionally runsSET counter newValue. Explain why this is unsafe under concurrent requests, and describe how you would rewrite it around a single atomic command instead.
Summary
- Redis’s single-threaded execution makes individual commands like
INCRatomic, which eliminates the check-then-act race condition that plagues naiveGET-then-SETrate limiters. - A fixed-window counter is just
INCRplusEXPIRE ... NXon the first request of each window; it is cheap but allows a boundary burst. - A sliding-window log uses a sorted set scored by request timestamp, trimmed with
ZREMRANGEBYSCOREand measured withZCARD; it is precise but costs one member per request. - A plain
SETon an existing key clears its TTL unless you addKEEPTTL— a common way to accidentally make a rate-limit key permanent. EXPIREon a key that does not exist is a silent no-op (returns0), andTTLreturns-2for a missing key versus-1for a key with no TTL set.- Keys of different types cannot share a name — mixing a counter and a sorted set under the same key produces a
WRONGTYPEerror. - Redis’s lazy plus active expiration together guarantee a rate-limit key with a TTL is eventually removed even if nothing ever reads it again.
