const now = Date.now();
const key = "rl:" + userId;
const results = await redis
.multi()
.zremrangebyscore(key, 0, now - windowMs)
.zadd(key, now, now + "-" + randomUUID())
.zcard(key)
.pexpire(key, windowMs)
.exec();
const count = results[2][1] as number;
if (count > limit) throw new TooManyRequestsException();
Keep one sorted set per user. Each request is a member of the set, and its score is the request's timestamp. A sorted set is a Redis set where every member has a number (the score) and Redis keeps members ordered by that number.
On every request, do these steps atomically (as one step that nothing can interrupt):
- Remove entries older than the window with
ZREMRANGEBYSCORE. - Add the current request.
- Count the entries with
ZCARD. - Refresh the key's TTL.
If the count is over the limit, reject the request. Give each member a unique suffix. Otherwise two requests in the same millisecond become one member.
This gives an exact rolling window, with no bursts at window boundaries.
Costs and caveats:
- Memory: the set holds one entry per request in the window. A limit of 10,000 per hour stores up to 10,000 entries per user.
- The version above also records rejected requests. So a client that keeps sending requests stays blocked. A Lua script can check the count first and only add the request when it is allowed.
A cheaper approximation is the sliding window counter. It uses fixed-window counters, and gives the previous window's count a weight based on how much of it still overlaps the rolling window.
- Use one sorted set per user, with the timestamp as the score.
- Trim old entries, add, count and set the TTL as one atomic step.
- Make members unique so requests in the same millisecond are not merged.
- The window is exact, but memory grows with the limit.
- Use a Lua script to avoid counting rejected requests.