[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build a Rate Limiter with Sliding Window

Build a Rate Limiter with Sliding Window

Build a production rate limiter using Redis sliding window — supporting per-user, per-IP, and per-API-key limits with tiered plans, retry headers, and DDoS protection.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 4 skills

Avg quality 95/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
$

The Problem

Olga runs API infrastructure at a 30-person developer tools company. One customer ran a script that sent 50,000 requests in 10 minutes and slowed the API for everyone else. The fixed-window limiter they had has a burst problem: a client can send 100 requests at 11:59:59 and another 100 at 12:00:01, which is 200 requests in two seconds against a "100 per minute" limit. Olga needs a limiter that counts the last 60 seconds from now, gives each plan its own limit (free 60/min, pro 600/min, enterprise 6,000/min), and returns headers so clients can slow down on their own.

The Solution

Use the sliding window log algorithm on a Redis sorted set: every allowed request is stored with its timestamp as the score, old entries are trimmed on each call, and the set size is the request count for the last window. The whole check runs as one Lua script, so two app instances cannot both read "59 of 60" and both let a request through. Hono serves the API, ioredis talks to Redis, and Zod checks the plan name before a limit is picked.

Step-by-Step Walkthrough

Step 1: Set up the project

bash
npm install hono @hono/node-server ioredis zod
npm install -D typescript tsx @types/node
docker run -d --name ratelimit-redis -p 127.0.0.1:6379:6379 redis:7-alpine

This was tested with Hono 4.13, ioredis 6.0, Zod 4.6 and Redis 7. The Redis port is bound to 127.0.0.1 so other machines cannot reach it.

Step 2: Write the limiter

The Lua script trims, counts, checks the limit and adds the request in one step. It reads the time from Redis (TIME), so servers with drifting clocks cannot disagree about the window. A denied request is not recorded, so a client that keeps retrying does not extend its own block. The per-day quota is a plain counter with a 24-hour expiry; keeping a million log entries per enterprise customer would cost far more memory than a counter.

typescript
// src/rate-limiter.ts — sliding-window-log limiter with per-plan limits
import { randomUUID } from "node:crypto";
import type { Context, Next } from "hono";
import { getConnInfo } from "@hono/node-server/conninfo";
import { Redis } from "ioredis";
import { z } from "zod";

export const redis = new Redis(process.env.REDIS_URL ?? "redis://127.0.0.1:6379");

const Plan = z.enum(["free", "pro", "enterprise"]);
type Plan = z.infer<typeof Plan>;

const PLAN_LIMITS: Record<Plan, { perMinute: number; perDay: number }> = {
  free: { perMinute: 60, perDay: 10_000 },
  pro: { perMinute: 600, perDay: 100_000 },
  enterprise: { perMinute: 6000, perDay: 1_000_000 },
};

// One Lua script = one atomic step: no race between "count" and "add".
// KEYS[1] = sorted set (sliding minute window), KEYS[2] = daily counter.
// A denied request is never recorded, so it does not extend the block.
redis.defineCommand("slidingWindow", {
  numberOfKeys: 2,
  lua: `
    local t = redis.call('TIME')
    local now = t[1] * 1000 + math.floor(t[2] / 1000)
    local window, limit, dayLimit, member = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), ARGV[4]

    redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
    local count = redis.call('ZCARD', KEYS[1])
    local day = tonumber(redis.call('GET', KEYS[2]) or '0')

    if day >= dayLimit then
      return {0, count, 0, 2, 0}
    end
    if count >= limit then
      local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
      return {0, count, tonumber(oldest[2]) + window - now, 1, 0}
    end

    redis.call('ZADD', KEYS[1], now, member)
    redis.call('PEXPIRE', KEYS[1], window)
    if redis.call('INCR', KEYS[2]) == 1 then redis.call('EXPIRE', KEYS[2], 86400) end
    return {1, count + 1, 0, 0, now}
  `,
});

declare module "ioredis" {
  interface RedisCommander<Context> {
    slidingWindow(
      minuteKey: string, dayKey: string,
      windowMs: number, limit: number, dayLimit: number, member: string,
    ): Promise<[number, number, number, number, number]>;
  }
}

export function rateLimiter() {
  return async (c: Context, next: Next) => {
    // Identify the caller: API key if sent, otherwise the TCP peer address.
    // X-Forwarded-For is client-controlled unless a proxy you run overwrites it.
    const apiKey = c.req.header("x-api-key");
    const id = apiKey
      ? `key:${apiKey}`
      : `ip:${getConnInfo(c).remote.address ?? "unknown"}`;

    const plan = Plan.catch("free").parse(c.get("userPlan"));
    const { perMinute, perDay } = PLAN_LIMITS[plan];

    const [ok, count, retryMs, reason] = await redis.slidingWindow(
      `rl:min:${id}`, `rl:day:${id}`, 60_000, perMinute, perDay, randomUUID(),
    );

    c.header("RateLimit-Limit", String(perMinute));
    c.header("RateLimit-Remaining", String(ok ? Math.max(0, perMinute - count) : 0));
    if (!ok) {
      const retryAfter = reason === 2 ? 86_400 : Math.ceil(retryMs / 1000);
      c.header("Retry-After", String(retryAfter));
      return c.json({ error: "rate_limit_exceeded", plan, retryAfterSeconds: retryAfter }, 429);
    }
    await next();
  };
}

// Cheap per-second flood guard for unauthenticated traffic (fixed window is fine here).
export function floodGuard(maxPerSecond = 50) {
  return async (c: Context, next: Next) => {
    const ip = getConnInfo(c).remote.address ?? "unknown";
    const key = `flood:${ip}`;
    // INCR and PEXPIRE NX in one transaction: the key can never be left without an expiry.
    const res = await redis.multi().incr(key).pexpire(key, 1000, "NX").exec();
    const n = Number(res?.[0]?.[1] ?? 0);
    if (n > maxPerSecond) return c.text("Too Many Requests", 429);
    await next();
  };
}

Two choices matter here. The caller is identified by API key, or else by the TCP address of the connection. X-Forwarded-For is deliberately not used: any client can send that header and get a fresh limit on every request. If the service sits behind a load balancer you control, use the address that proxy sets and make sure it overwrites the header.

floodGuard is a separate, cheaper fixed-window counter for floods. It runs first, so attack traffic costs one INCR and not a sorted-set update.

Step 3: Mount the middleware

Set c.set("userPlan", ...) in your auth middleware before the limiter runs. An unknown or missing plan falls back to free because of Plan.catch("free").

typescript
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { floodGuard, rateLimiter } from "./rate-limiter.ts";

const app = new Hono();
app.use(floodGuard(50));
app.use(rateLimiter());
app.get("/", (c) => c.json({ ok: true }));

serve({ fetch: app.fetch, hostname: "127.0.0.1", port: Number(process.env.PORT ?? 3000) });

Step 4: Check it

Send more requests than the free limit allows and look at the response. The sleep keeps the loop under the 50 requests per second that floodGuard allows; without it the flood guard answers first and you see its plain-text 429 instead.

bash
for i in $(seq 1 62); do curl -s -o /dev/null -w "%{http_code}\n" -H "x-api-key: demo-key" http://127.0.0.1:3000/; sleep 0.05; done | sort | uniq -c
curl -si -H "x-api-key: demo-key" http://127.0.0.1:3000/ | head -6
console
     60 200
      2 429
HTTP/1.1 429 Too Many Requests
content-type: application/json
ratelimit-limit: 60
ratelimit-remaining: 0
retry-after: 58

The headers use the RateLimit-* names from the IETF draft; Retry-After is standard HTTP. If existing clients read X-RateLimit-*, set those names instead.

Real-World Example

Olga deploys the limiter on the public API and gives the customer who ran the 50,000-request script a pro key. Their script now gets 600 responses a minute and a 429 with Retry-After for the rest, while other customers see no change in latency. Support points customers who hit the free limit at the response body, which names the plan and the seconds to wait.

Related Skills

  • typescript - type-check the middleware and the Redis command declaration
  • redis - sorted sets, Lua scripts and key expiry
  • hono - the web framework and middleware chain
  • zod - validates the plan name before a limit is chosen

Related use cases