Skip to main content

Rate-Limited URL Shortener

Difficulty: Advanced (capstone) · Builds on: routing, middleware, Mongoose, validation, everything so far

You are tasked with building the service behind links like http://localhost:3000/xK9dQ2p. Anyone can submit a long URL and get a short code back; visiting the short link redirects to the original URL and counts the click. Because the create endpoint is a classic abuse target, you will write a rate limiting middleware by hand — no packages — and understand exactly why production systems move that state into Redis.

What You'll Practice

  • Generating unguessable short codes with node:crypto and handling collisions
  • A redirect route with an atomic click counter ($inc, no read-modify-write races)
  • Strict URL validation with the URL class and a protocol allowlist
  • Writing a fixed-window rate limiter as plain Express middleware
  • Route ordering — why the catch-all /:code route must be mounted last

Requirements

  • POST /api/links accepts a URL and returns a unique 7-character code
  • Only http: and https: URLs are accepted — everything else is a 400
  • Codes are generated from cryptographically random bytes, never sequentially
  • A code collision is retried, backed by a unique index on code
  • GET /:code responds 302 to the original URL and increments the click count atomically
  • GET /api/links/:code returns the link's stats without redirecting
  • The create endpoint is limited to 10 requests per IP per minute; over the limit is 429 with a Retry-After header
  • Unknown or malformed codes return 404

API Contract

MethodEndpointRate limitRequestSuccess Response
POST/api/links10 per minuteBody: url201code, short_url, original_url
GET/:code302 redirect to the original URL
GET/api/links/:code200 — stats including clicks
Response of POST /api/links
{
"status": 201,
"response": "Created",
"message": "Short link created successfully",
"data": {
"code": "xK9dQ2p",
"short_url": "http://localhost:3000/xK9dQ2p",
"original_url": "https://expressjs.com/en/guide/routing.html"
}
}

Edge Cases to Handle

ScenarioExpected result
Body with no url, or url is not a string400 — validation message
url is javascript:alert(1) or data: or ftp:400 — protocol not allowed
url is not a url at all400 — fails to parse
Generated code already exists in the databaseSilent retry with a fresh code
GET /nosuchcode404 — link not found
GET /../etc/passwd or any malformed code404 — fails the code pattern check, no DB query
11th create request within a minute from one IP429 with Retry-After and rate limit headers
Two clicks arriving at the same instantBoth counted — the increment is atomic in MongoDB

Build Guide

Step 1 — Project setup

Nothing new: npm install express@4 mongoose dotenv, "type": "module", an express.json() app with a central error handler, and a Mongoose connection — the same skeleton as the Blog API. Add one extra variable to .env: BASE_URL=http://localhost:3000, used to build the short_url in responses.

Step 2 — The model

models/link.js
import mongoose from "mongoose";

const linkSchema = new mongoose.Schema(
{
code: { type: String, required: true, unique: true },
original_url: { type: String, required: true },
clicks: { type: Number, default: 0 },
},
{ timestamps: true },
);

export default mongoose.model("Link", linkSchema);

The unique: true on code creates a unique index. That index is the real collision guard — the retry loop in the controller merely reacts to it. Checking "does this code exist?" before inserting would be a race condition; letting the insert fail and retrying is not.

Step 3 — Code generation

Write a generateCode(length = 7) helper — it's a few lines, and the algorithm is:

  1. Get length random bytes from crypto.randomBytes (import from node:crypto).
  2. Map each byte onto a 62-symbol base62 alphabet (0-9a-zA-Z) with a modulo, and join the characters into the code.

Seven characters from a 62-symbol alphabet gives about 3.5 trillion combinations — collisions stay rare even with millions of links, and crypto.randomBytes means codes cannot be predicted or enumerated the way sequential ids can.

Step 4 — The hand-written rate limiter

A fixed-window limiter: each IP gets a counter that resets when its window expires. State lives in a Map inside the factory — each limiter instance owns its own counters, so two routes with different limits never interfere — and a periodic sweep evicts expired entries so the map cannot grow forever.

middlewares/rateLimit.js
export const rateLimit = ({ windowMs = 60_000, max = 10 } = {}) => {
const buckets = new Map();

setInterval(() => {
const now = Date.now();
for (const [key, bucket] of buckets) {
if (bucket.resetAt <= now) buckets.delete(key);
}
}, windowMs).unref();

return (req, res, next) => {
const now = Date.now();
let bucket = buckets.get(req.ip);

if (!bucket || bucket.resetAt <= now) {
bucket = { count: 0, resetAt: now + windowMs };
buckets.set(req.ip, bucket);
}

bucket.count += 1;
res.set("X-RateLimit-Limit", String(max));
res.set("X-RateLimit-Remaining", String(Math.max(max - bucket.count, 0)));

if (bucket.count > max) {
const retryAfter = Math.ceil((bucket.resetAt - now) / 1000);
res.set("Retry-After", String(retryAfter));
return res.status(429).json({
status: 429,
response: "Too Many Requests",
message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
});
}

next();
};
};

Why production uses a store like Redis instead. This limiter has three honest weaknesses:

  1. It does not survive restarts — every deploy resets all counters to zero.
  2. It does not share state — run the app on two processes (a cluster, two containers, autoscaling), and each process has its own Map. A limit of 10 quietly becomes 10 per process.
  3. Fixed windows have a burst seam — a client can send 10 requests at the end of one window and 10 more at the start of the next, 20 in a few seconds.

Redis fixes the first two with a single shared INCR plus EXPIRE per key, and sliding-window or token-bucket algorithms fix the third. The middleware shape stays identical — only where the counter lives changes, which is exactly why writing it by hand once is worth it.

One deployment note: req.ip is the socket address. Behind a reverse proxy such as nginx every request appears to come from the proxy, so you would set app.set("trust proxy", 1) to read the client IP from X-Forwarded-For — but only when a proxy you control actually sets that header, because clients can forge it otherwise.

Step 5 — Controllers

The three handlers are yours to write, each following an exact sequence.

POST /api/links (shorten):

  1. Validate the URL — it must be a string, non-empty, at most 2048 characters, and parse with new URL(...) (wrap in try/catch — a garbage string throws). Anything failing is a 400.
  2. Enforce the protocol allowlist — accept only http: and https:. Validating "is this a URL" is not enough: javascript:alert(1) parses as a perfectly valid URL, and if you store it and later redirect to it, your shortener becomes an XSS delivery service. This blocks javascript:, data:, ftp: and everything else by default — allowlist, not blocklist.
  3. Insert with a retry loop — call Link.create with a fresh generateCode(). If it throws a duplicate-key error (err.code === 11000), the code collided: loop and try again with a new code, up to about 5 attempts, then give up and throw. Any other error propagates immediately.
  4. Respond 201 with code, short_url built as `${BASE_URL}/${code}`, and original_url — the shape shown in the API contract.

GET /:code (redirect):

  1. Check the code shape first — test against /^[0-9a-zA-Z]{7}$/ and return 404 on failure, before any database query. Malformed input like /../etc/passwd never reaches MongoDB.
  2. Increment and fetch in one operationLink.findOneAndUpdate({ code }, { $inc: { clicks: 1 } }). Reading the document, adding 1 in JavaScript, and saving would lose clicks under concurrency; $inc makes the database do the increment atomically.
  3. Redirect with 302, not 301res.redirect(302, link.original_url) (or 404 if no document matched). Browsers cache a 301 permanently and stop hitting your server, so your click counts freeze; 302 keeps every visit flowing through the redirect route.

GET /api/links/:code (stats): same pattern guard as the redirect, then a plain findOne — no increment, a stats lookup is not a click — returning code, original_url, clicks, and createdAt (404 when missing).

Step 6 — Routes and app

Route order is the trap in this project. /:code matches any single-segment path — mounted first, it would swallow /api too. Wire the app in this order:

  1. express.json(), then POST /api/links with the rate limiter in front: rateLimit({ windowMs: 60_000, max: 10 }), then the shorten handler.
  2. GET /api/links/:code → stats, then the catch-all GET /:code → redirect, last among the routes.
  3. The 404 handler and the central error handler; finally await mongoose.connect(...) and app.listen(...).

Test It with curl

Shorten a URL and capture the code
CODE=$(curl -s -X POST http://localhost:3000/api/links \
-H "Content-Type: application/json" \
-d '{"url":"https://expressjs.com/en/guide/routing.html"}' \
| node -pe "JSON.parse(require('fs').readFileSync(0)).data.code")

echo "Code: $CODE"
Follow the redirect a few times, then check the stats
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://localhost:3000/$CODE
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://localhost:3000/$CODE
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://localhost:3000/$CODE

curl -s http://localhost:3000/api/links/$CODE

The stats response should show "clicks": 3.

Rejected inputs — expect 400 for each
curl -s -X POST http://localhost:3000/api/links \
-H "Content-Type: application/json" -d '{"url":"javascript:alert(1)"}'

curl -s -X POST http://localhost:3000/api/links \
-H "Content-Type: application/json" -d '{"url":"ftp://files.example.com/report.pdf"}'

curl -s -X POST http://localhost:3000/api/links \
-H "Content-Type: application/json" -d '{"url":"zakariya was here"}'
Trip the rate limiter — the last requests return 429
for i in $(seq 1 12); do
curl -s -o /dev/null -w "request $i: %{http_code}\n" \
-X POST http://localhost:3000/api/links \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/page-'$i'"}'
done

Requests 1 through 10 return 201; 11 and 12 return 429. Wait a minute and the window resets.

Unknown code — expect 404
curl -s http://localhost:3000/zzzzzzz

Stretch Goals

  1. Custom aliases — let the body include an optional alias, validated against the same pattern rules and rejected with 409 when taken
  2. Expiring links — an optional expires_at field; expired codes return 410 Gone, and a MongoDB TTL index cleans them up
  3. Redis-backed limiter — swap the Map for Redis INCR and EXPIRE, then run two app processes behind a load balancer and prove the limit holds across both
  4. Ownership and dashboards — plug in the auth API from Problem 3 so each user sees only their own links, with per-day click counts via an aggregation pipeline