shrt.design

Auxiliary Systems

Rate Limiting & Abuse Prevention

How do you stop one client from generating millions of spam links or scraping every redirect, without punishing everyone else?

Token BucketRedisAPI GatewayIP + API Key
ClientAPIFixed-WindowCounter

Click a node to see what it does. Switch tiers above to see how the design scales.

Overview

A short-link creation endpoint is an obvious target for spam and phishing campaigns — one bad actor can mint thousands of malicious links a minute unless something stops them. This subsystem protects link creation first and foremost, with a much lighter touch on redirects, since a single popular link can legitimately produce enormous, non-abusive redirect volume.

API Design

Rate limiting isn’t its own resource — it’s middleware in front of POST /api/shorten (and, more loosely, GET /{code}). It shows up in response headers on every request, and as a distinct error once the limit is hit.

POST /api/shorten
// 201 response headers
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1723459200
// 429 Too Many Requests
{
  "error": "rate_limited",
  "retry_after_seconds": 12
}

Returning limiter state on every response — not just the 429 — lets well-behaved clients back off before they’re ever rejected, which is the detail that separates “I added a rate limiter” from “I designed a rate-limited API.”

Database Schema

No durable table — limiter state is short-lived by design, living entirely in Redis, keyed by identity and endpoint:

ratelimit:{api_key}:{endpoint} → Hash { tokens, last_refill_ts }
HGETALL ratelimit:acme_9f2a:shorten
  → { "tokens": "12", "last_refill_ts": "1723459180" }

A Lua script performs the read-refill-decrement-write as one atomic step — without atomicity, two concurrent requests could both read tokens: 1, both decide they’re allowed through, and the limit gets bypassed under exactly the concurrent load it’s meant to protect against.

Basic Approach — Fixed Window Counter Per IP

How it works

Keep a counter per client IP that increments per request and resets on a fixed schedule (e.g., once per minute). Reject once it exceeds the limit for the window.

Client request ──▶ API Gateway ──▶ increment counter[ip][current_minute] ──▶ over limit? 429 : allow

Tradeoffs

  • Pro: Dead simple to implement and reason about — one counter, one comparison.
  • Con: Bursty at window boundaries — a client can send the full limit at 11:00:59 and the full limit again at 11:01:00, doubling the effective rate in two seconds.
  • Con: Limiting by raw IP punishes every user behind a shared IP (corporate NAT, mobile carrier) equally, whether they’re the abuser or not.

Scaled Approach — Token Bucket Per API Key

How it works

Each identity (API key, not raw IP) gets a bucket holding up to N tokens that refills continuously at a steady rate. Every request costs one token; an empty bucket means the request is rejected. Because it refills continuously rather than resetting at fixed boundaries, the window-burst problem disappears entirely.

Client request (with API key) ──▶ Rate Limiter ──▶ bucket has a token? consume it, allow : 429
                                          (bucket refills continuously in the background)

Tradeoffs

  • Pro: Naturally smooths traffic — no window-boundary exploit, while still allowing short legitimate bursts up to the bucket size.
  • Pro: Keying by API key instead of IP means shared-IP users aren’t punished for one bad actor on the same network.
  • Con: Requires every client to authenticate — a real product requirement a purely anonymous shortener doesn’t otherwise need.
  • Con: Bucket state must live somewhere shared (Redis) across multiple API servers, or a client could exhaust a separate bucket per server and get N times the intended limit.

Advanced Approach — Tiered Limits + Anomaly Detection

How it works

Layer multiple limits at once: a per-second burst cap, a per-day quota, and a distinct, much stricter limit specifically on link creation versus the far higher-volume redirect path — since spam is a write-side risk, not a read-side one. On top of the bucket, a sliding-window anomaly detector watches for patterns like “500 links created in 10 seconds, all pointing at similar domains” and can flag or throttle an account before it ever hits a hard numeric limit.

Client request ──▶ Tiered Limiter (per-sec bucket AND per-day quota AND endpoint-specific limit)
               └▶ Anomaly Detector (sliding window over recent creation patterns) ──▶ flag / throttle / allow

Tradeoffs

  • Pro: Separating the shorten endpoint from the redirect endpoint means a legitimately popular link’s high redirect traffic is never penalized by a limit designed to catch spam link creation.
  • Pro: Pattern-based detection catches abuse that stays technically under the numeric rate limit — many links, all just under threshold, all pointing at a known-bad domain.
  • Con: Multiple simultaneous limits plus a pattern detector are meaningfully more infrastructure and tuning surface — false positives on legitimate bulk users become a real support cost.
  • Con: Sliding-window computation over recent history is more expensive per request than a single counter check, so it typically runs asynchronously or sampled rather than synchronously on every request.

Tech Choices

  • Token bucket — smooth, burst-tolerant limiting without fixed-window edge cases.
  • Redis — shared, fast counter/bucket state across API servers.
  • API Gateway — a natural place to enforce limits before a request even reaches application code.

How to Vocalize This in an Interview

Name the two very different things being protected — link creation (spam/phishing risk, low legitimate volume) and redirects (legitimate volume can be enormous for one popular link) — up front. Applying one limit to both is the most common mistake here, and naming the distinction unprompted signals you’ve actually thought about the failure mode.