DEV Community

Timevolt
Timevolt

Posted on

Designing a URL Shortener: Mad Max Style Rate Limiting

The Quest Begins (The "Why")

Ever tried to build a tiny link‑shortener and watched it crumble the moment a handful of friends started spamming it? I remember the first weekend I threw together a naive service in Node.js: a simple hash‑generator, a Redis store for the mapping, and an Express route that blindly accepted every POST. It felt like I’d forged a shiny new sword… until the barbarians (aka my roommate’s cron job) showed up and started hammering the endpoint with 10 k requests per second. The CPU spiked, Redis cried, and the whole thing fell over like a house of cards in a sandstorm.

That moment was my “aha!”—the problem wasn’t the hashing algorithm or the storage layer; it was the lack of a gatekeeper. If I couldn’t control how many requests a single client could fire, the system would always be vulnerable to abuse, accidental traffic spikes, or outright denial‑of‑service. I needed a rate limiter that could stand up to the wasteland of unpredictable traffic, Mad Max‑style.

The Revelation (The Insight)

The critical insight hit me while I was debugging a Redis latency spike: rate limiting isn’t about counting requests per second in a global bucket; it’s about protecting the resource that actually does the work—in our case, the Redis SET/GET pair that stores the short‑code mapping.

If we throttle at the API layer before we touch Redis, we spare the database from needless load and give ourselves a clean point to return a friendly 429 (Too Many Requests). The trade‑off is simple: we add a tiny amount of state (a counter per client) but we gain massive resilience.

Here’s the mental model I settled on:

+----------------+      +----------------+      +----------------+
|   Client IP    | ---> |  Rate Limiter  | ---> |   Redis Store  |
| (or API key)   |      |  (token bucket)|      |   (mapping)    |
+----------------+      +----------------+      +----------------+
Enter fullscreen mode Exit fullscreen mode
  • The token bucket algorithm gives us a smooth, burst‑friendly limit: each client starts with N tokens, refills at a steady rate r tokens/sec, and consumes one token per request. If the bucket is empty, we reject.
  • Compared to a fixed‑window counter (which can allow up to 2× the limit at the edge of a window) or a leaky bucket (which smooths too aggressively and can bury legitimate bursts), the token bucket gives us the best of both worlds: predictable average rate and the ability to handle short spikes—exactly what a URL shortener sees when a link goes viral.

Wielding the Power (Code & Examples)

The Struggle (Before)

// naive endpoint – no protection
app.post('/shorten', async (req, res) => {
  const longUrl = req.body.url;
  const code    = nanoid(7);               // generate short code
  await redis.set(code, longUrl);          // hammer Redis every time
  res.json({ shortUrl: `https://shrt.co/${code}` });
});
Enter fullscreen mode Exit fullscreen mode

When the traffic surged, each request hit Redis, causing latency spikes and occasional OOM errors. The system felt like a car with no brakes—fast until it slammed into a wall.

The Victory (After)

We plug in a token‑bucket middleware that lives in front of the route. I used a tiny in‑memory store for the buckets (you could swap it for Redis if you need multi‑instance safety, but for a single‑node service it’s lightning‑fast).

// token-bucket rate limiter (per IP)
const BUCKET_SIZE = 10;   // max burst
const REFILL_RATE = 5;    // tokens per second

class TokenBucket {
  constructor(size, rate) {
    this.size = size;
    this.rate = rate;
    this.buckets = new Map(); // key => { tokens, lastRefill }
  }

  consume(key) {
    const now = Date.now() / 1000; // seconds
    let bucket = this.buckets.get(key);
    if (!bucket) {
      bucket = { tokens: this.size, lastRefill: now };
      this.buckets.set(key, bucket);
    }

    // refill based on elapsed time
    const elapsed = now - bucket.lastRefill;
    bucket.tokens = Math.min(this.size, bucket.tokens + elapsed * this.rate);
    bucket.lastRefill = now;

    if (bucket.tokens < 1) {
      return false; // not allowed
    }
    bucket.tokens -= 1;
    return true;   // allowed
  }
}

const limiter = new TokenBucket(BUCKET_SIZE, REFILL_RATE);

// middleware
function rateLimit(req, res, next) {
  const key = req.ip; // or req.headers['x-api-key'] if you have auth
  if (!limiter.consume(key)) {
    return res.status(429).json({ error: 'Too many requests, please slow down.' });
  }
  next();
}

// protected route
app.post('/shorten', rateLimit, async (req, res) => {
  const longUrl = req.body.url;
  const code    = nanoid(7);
  await redis.set(code, longUrl);
  res.json({ shortUrl: `https://shrt.co/${code}` });
});
Enter fullscreen mode Exit fullscreen mode

Why this beats the alternatives

Approach Pros Cons (in our context)
Fixed‑window counter Super simple, O(1) per request Allows bursts up to 2× limit at window edge
Leaky bucket Smooths traffic, no burst loss Can reject legitimate bursts (bad for viral links)
Token bucket Handles bursts, guarantees average rate Slightly more state (still trivial)

The token bucket gave us a hard ceiling on average request rate while still letting a client burn through a saved‑up burst when a link suddenly gets shared everywhere—exactly the behavior we wanted.

Common Traps (The “Trapdoors”)

  1. Using the IP as the sole key behind a NAT or CDN – all users behind the same proxy appear as one client, potentially throttling legitimate traffic. Fix: combine IP with an API key or JWT when authentication exists, or use a header like X-Forwarded-For with caution.
  2. Storing buckets in a slow datastore – if each consume hits Redis, you’ve just moved the bottleneck. Fix: keep buckets in-process for a single node, or use a fast local cache (e.g., Caffeine, memcached) and sync periodically if you need distribution.

Why This New Power Matters

With the rate limiter in place, our URL shortener went from “fragile prototype” to “battle‑ready service”. I could now invite friends to stress‑test it, and the system stayed smooth even when we simulated a flash‑crowd of 50 k requests per second. The Redis layer only saw the actual SET/GET calls for successful shortens—no more wasteful churn from abusive clients.

That freedom opened up new features: we could safely add analytics click‑counts, experiment with custom slugs, or even roll out a paid API tier without worrying about overwhelming the core store. In short, the limiter turned a single point of failure into a controllable valve, letting the rest of the system shine.

If you’re building anything that talks to a database, a cache, or an external service, ask yourself: what’s the one resource I must protect? Slap a token‑bucket (or another fitting algorithm) in front of it, and you’ll instantly gain resilience without a massive rewrite.


Your turn: Grab your favorite language, throw together a token‑bucket middleware, and protect the bottleneck in your next project. When you see those 429 responses gracefully throttling a runaway script, you’ll feel like you’ve just survived a sandstorm in Fury Road—victorious, a little battered, but ready for the next adventure.

What’s the first service you’ll protect with a rate limiter? Drop your thoughts (or a snippet of your own code) in the comments—I’d love to see how you’re taming the wild west of traffic! 🚀

Top comments (0)