INCR/INCRBY as lock-free counters, fixed-window rate limiter with INCR+EXPIRE, sliding window using Sorted Sets with timestamp scores, token bucket algorithm, and the off-by-one race in fixed-window that Lua eliminates.
P-5 — Atomic Counters, Rate Limiters, and Sliding Windows
Who this module is for: You need to limit how often a user can call your API, track how many times an event has occurred, or implement a quota system. The naive approach — read the counter, check it, increment it — has a race condition. This module covers lock-free atomic counters, the three rate limiting algorithms, and how to choose between them.
The Race Condition in Non-Atomic Counters
Before Redis, a typical rate limiter looked like this in pseudocode:
const count = await db.query('SELECT count FROM rate_limits WHERE user_id = $1', [userId]);
if (count >= 100) return rateLimitError();
await db.query('UPDATE rate_limits SET count = count + 1 WHERE user_id = $1', [userId]);
Under concurrent requests, two requests can both read count = 99, both pass the check, and both increment — resulting in count = 101, exceeding the limit. This is a classic TOCTOU (Time Of Check, Time Of Use) race.
Redis's INCR command is atomic. The read and increment happen in a single operation that cannot be interleaved with another client's commands.
Atomic Counters with INCR
INCR key → atomically increment by 1, return new value
INCRBY key n → atomically increment by n
DECR key → atomically decrement by 1
DECRBY key n → atomically decrement by n
INCRBYFLOAT key f → atomically increment by float f
127.0.0.1:6379> SET page:views:homepage 0
OK
127.0.0.1:6379> INCR page:views:homepage
(integer) 1
127.0.0.1:6379> INCR page:views:homepage
(integer) 2
127.0.0.1:6379> INCRBY page:views:homepage 50
(integer) 52
If the key does not exist, INCR creates it with value 0 and increments to 1. This makes initialization implicit — no SET ... 0 required.
Use case: Event counters, page view tracking, API call totals. Safe under any level of concurrency.
Rate Limiter Pattern 1: Fixed Window
The simplest rate limiter: count requests in a fixed time window (per minute, per hour). Allow up to limit requests per window.
async function isAllowed(userId: string, limit: number, windowSeconds: number): Promise<boolean> {
const key = `rate:${userId}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
const count = await redis.incr(key);
if (count === 1) {
// First request in this window — set expiry
await redis.expire(key, windowSeconds);
}
return count <= limit;
}
The key includes the window index (Math.floor(Date.now() / windowSeconds)) — a new key is automatically created for each window.
The INCR + EXPIRE race: If the process crashes between INCR and EXPIRE, the key has no TTL and persists indefinitely. Fix with SET ... NX EX:
async function isAllowed(userId: string, limit: number, windowSeconds: number): Promise<boolean> {
const key = `rate:${userId}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
const pipeline = redis.pipeline();
pipeline.incr(key);
pipeline.expire(key, windowSeconds, 'NX'); // NX: only set if no TTL exists
const [[, count]] = await pipeline.exec() as [[null, number], [null, number]];
return count <= limit;
}
EXPIRE key seconds NX (Redis 7.0+) sets the TTL only if the key does not already have one — safe for the first request, no-op for subsequent ones in the same window.
The boundary problem: At the boundary between windows (e.g., at exactly 12:00:00), a user can make limit requests at 11:59:59 and limit more requests at 12:00:00 — effectively 2×limit requests in 2 seconds. This is the fixed window's fundamental flaw.
Rate Limiter Pattern 2: Sliding Window (Log-Based)
Record a timestamp for every request in a Sorted Set, using the timestamp as the score. The window is the last windowSeconds seconds of timestamps.
async function isAllowed(userId: string, limit: number, windowMs: number): Promise<boolean> {
const key = `rate:log:${userId}`;
const now = Date.now();
const windowStart = now - windowMs;
const pipeline = redis.pipeline();
// Remove requests older than the window
pipeline.zremrangebyscore(key, 0, windowStart);
// Add this request
pipeline.zadd(key, now, `${now}-${Math.random()}`); // unique member
// Count requests in the window
pipeline.zcard(key);
// Reset TTL
pipeline.expire(key, Math.ceil(windowMs / 1000) + 1);
const results = await pipeline.exec() as [null, unknown][];
const count = results[2][1] as number;
return count <= limit;
}Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion