Module P-5·12 min read

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.

Written by Jatin Jain Saraf · Architect of SupraScan

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 & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.