BackendIntermediate~1 hour

Add rate limiting

Stops one client from hammering your endpoints — brute-forcing logins, draining your email/SMS/LLM budget, or knocking the app over. Without it, a single script (or one buggy retry loop) can run up your bill or take everyone else down.

the approach

Don't hand-roll a counter in a `Map`. Rate limiting is a distributed-state problem: on serverless or multi-instance hosting, every instance keeps its own memory, so an in-memory limit of 10 becomes 10-per-instance and enforces almost nothing. The right mental model is: one shared, atomic counter store (Redis) that every instance reads and increments, keyed by whoever you're limiting (authenticated user first, IP as a fallback), checked as early in the request path as you can afford. In 2026 you almost never write the algorithm yourself — you reach for `@upstash/ratelimit` on Upstash Redis for app-level control, or push it to the edge with Arcjet, Vercel Firewall, or Cloudflare so abuse dies before it touches your compute.

Step by step

  1. 1

    Decide what to protect and at which layer

    List the endpoints that are expensive or abusable: login/signup, password reset, email/SMS send, payment, and anything that calls an LLM. Cheap read endpoints usually don't need it. If you can rate limit at the edge (Vercel Firewall, Cloudflare, Arcjet) do that first — it blocks abuse before it costs you compute; use app-level limiting for finer, per-user rules.

  2. 2

    Pick a shared store, not process memory

    On Vercel/serverless or any horizontally-scaled host, a `Map` or module-level variable is per-instance and resets on cold start — useless. Provision Upstash Redis (serverless, HTTP-based, works from edge functions) or any Redis you already run. Managed API-key products like Unkey or Arcjet bundle the store so you skip this.

  3. 3

    Choose the algorithm

    Use a sliding-window limiter for most cases — it avoids the burst-at-the-boundary problem that fixed windows have (100 req allowed at 11:59:59 and another 100 at 12:00:00). `Ratelimit.slidingWindow(10, '10 s')` reads as '10 requests per 10 seconds'. Reserve token-bucket for APIs that need to allow short bursts.

  4. 4

    Choose your key carefully

    Prefer a stable identity: the authenticated user ID (e.g. Clerk `userId`) or API key. Fall back to IP only for unauthenticated traffic. IP is weak — carrier NAT and corporate networks share one IP across many users, and `x-forwarded-for` can be spoofed unless you read the trusted client IP your platform sets. Never trust a raw header blindly behind a proxy.

  5. 5

    Enforce and return 429 correctly

    When `success` is false, respond `429 Too Many Requests` with a `Retry-After` header (seconds until reset) and ideally `X-RateLimit-Remaining`/`X-RateLimit-Reset`. Libraries, browsers, and mobile clients back off automatically when you send these — omit them and clients just keep retrying.

  6. 6

    Fail open, add per-route limits, then test

    Wrap the limit check so that if Redis is unreachable your app allows the request rather than 500ing every user — a rate limiter should never be a single point of failure for the whole app (unless the endpoint is security-critical, then fail closed). Give tight limits to sensitive routes and loose ones to the rest. Verify with a quick loop: `for i in $(seq 1 20); do curl -s -o /dev/null -w '%{http_code}\n' https://localhost:3000/api/send; done` and confirm you see 200s flip to 429s.

Starter snippet

typescript
// lib/ratelimit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

export const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),           // reads UPSTASH_REDIS_REST_URL/TOKEN
  limiter: Ratelimit.slidingWindow(10, "10 s"), // 10 req / 10s
  analytics: true,
  prefix: "@myapp/ratelimit",
});

// app/api/send/route.ts
import { ratelimit } from "@/lib/ratelimit";
import { auth } from "@clerk/nextjs/server";

export async function POST(req: Request) {
  const { userId } = await auth();
  // Prefer a stable identity; fall back to IP for anon traffic.
  const key = userId ?? req.headers.get("x-forwarded-for") ?? "anon";

  const { success, remaining, reset } = await ratelimit.limit(key);
  if (!success) {
    return Response.json(
      { error: "Too many requests" },
      {
        status: 429,
        headers: {
          "Retry-After": String(Math.ceil((reset - Date.now()) / 1000)),
          "X-RateLimit-Remaining": String(remaining),
        },
      }
    );
  }

  // ...do the expensive work (send the email, call the LLM, etc.)
  return Response.json({ ok: true });
}

✕ Watch out for

  • In-memory limiting on serverless/multi-instance hosting. Each instance has its own counter, so a limit of 10 becomes 10-per-instance and cold starts wipe it. Use a shared store (Redis) — this is the single most common mistake.
  • Keying only by IP. Carrier NAT and office networks share one IP across many real users (so you throttle innocents), and `x-forwarded-for` is spoofable unless you read the trusted client IP your platform injects. Use the authenticated user ID when you have one.
  • Failing closed when the store is down. If Redis blips and your limiter throws, every user gets a 500. Wrap the check and fail open for normal endpoints (fail closed only for security-critical ones like login).
  • Returning a bare 429 with no `Retry-After`. Clients and HTTP libraries can't back off intelligently, so they retry immediately and make the storm worse.
  • One global limit for everything. A tight limit that protects your login route will strangle normal browsing; set per-route limits sized to each endpoint's cost.

✓ Pro tips

  • Rate limit at the edge whenever you can. Vercel Firewall, Cloudflare, or Arcjet stop abuse before it reaches (and bills) your compute; keep app-level limiting for per-user rules the edge can't express.
  • Size limits to endpoint cost, not a single number. Login/reset/email-send/LLM routes get tight limits (a handful per minute); read endpoints get generous ones.
  • When you tell an AI agent to add this, be specific: name the store and the target routes, e.g. 'Use @upstash/ratelimit with sliding window on a shared Upstash Redis, key by Clerk userId falling back to IP, only on POST /api/* — do NOT use an in-memory Map.' Agents default to an in-memory Map because it's less setup — reject that.
  • After the agent writes it, verify three things by reading the diff: (1) state lives in Redis, not a module-level variable/Map; (2) it fails open on store errors; (3) the 429 response includes a Retry-After header. Then hit the endpoint in a curl loop and watch 200s turn into 429s.