typescript lua 127 lines · 4 tabs

Rate limiting by IP + user (Express)

Shared by codesnips Jan 2026
4 tabs
import { readFileSync } from 'fs';
import { join } from 'path';
import type { Redis } from 'ioredis';
import { randomUUID } from 'crypto';

export interface LimitResult {
  allowed: boolean;
  remaining: number;
  retryAfter: number;
}

export class RateLimiter {
  private sha: string | null = null;
  private readonly source = readFileSync(join(__dirname, 'slidingWindow.lua'), 'utf8');

  constructor(private readonly redis: Redis) {}

  async loadScript(): Promise<void> {
    this.sha = (await this.redis.script('LOAD', this.source)) as string;
  }

  async check(key: string, limit: number, windowMs: number): Promise<LimitResult> {
    if (!this.sha) await this.loadScript();
    const now = Date.now();
    const res = (await this.redis.evalsha(
      this.sha as string,
      1,
      key,
      String(now),
      String(windowMs),
      String(limit),
      `${now}:${randomUUID()}`,
    )) as [number, number];

    return {
      allowed: res[0] === 1,
      remaining: res[1],
      retryAfter: res[0] === 1 ? 0 : Math.ceil(windowMs / 1000),
    };
  }
}
4 files · typescript, lua Explain with highlit

Rate limiting protects an API from abuse and accidental hammering, but a naive fixed-window counter (increment a key, expire it every 60 seconds) suffers from burst problems at window boundaries: a client can send the full quota at the end of one window and again at the start of the next, effectively doubling the allowed rate. This snippet implements a true sliding-window limiter backed by a Redis sorted set, keyed by both client IP and authenticated user so that anonymous traffic and logged-in traffic are throttled independently.

The slidingWindow.lua tab holds the core algorithm and runs atomically inside Redis. It uses one ZSET per key where each request is a member scored by timestamp. ZREMRANGEBYSCORE first evicts entries older than now - window, then ZCARD counts what remains. If the count is under the limit the script adds the current request with ZADD and refreshes the key's PEXPIRE; otherwise it rejects without inserting. Running this as a Lua script means the read-modify-write is a single round trip and cannot interleave with concurrent requests, which is what makes the counter correct under load.

The RateLimiter class tab wraps the script. loadScript primes the script with SCRIPT LOAD so subsequent calls use EVALSHA (avoiding resending the source each time), and check invokes it, returning a small result object with allowed, remaining, and a retryAfter hint derived from the window. Storing the SHA once is a common optimization that trades a tiny startup cost for cheaper hot-path calls.

The rateLimit middleware tab is the Express glue. It builds a composite key from req.ip and the optional req.user.id, so the same person behind one IP gets a shared IP budget plus a per-user budget. It sets the standard X-RateLimit-* and Retry-After headers and short-circuits with 429 when the limit is exceeded. keyGenerator and the numeric options are injectable so different routes can apply stricter limits.

The router wiring tab shows the pattern in practice: a permissive global limit plus a much tighter limit on the login route to blunt credential-stuffing. A key trade-off to remember is that trusting req.ip requires app.set('trust proxy', ...) behind a load balancer, otherwise every client collapses to the proxy's address.


Related snips

Share this code

Here's the card — post it anywhere.

Rate limiting by IP + user (Express) — share card
Link copied