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:cryptoand handling collisions - A redirect route with an atomic click counter (
$inc, no read-modify-write races) - Strict URL validation with the
URLclass and a protocol allowlist - Writing a fixed-window rate limiter as plain Express middleware
- Route ordering — why the catch-all
/:coderoute must be mounted last
Requirements
POST /api/linksaccepts a URL and returns a unique 7-character code- Only
http:andhttps:URLs are accepted — everything else is a400 - Codes are generated from cryptographically random bytes, never sequentially
- A code collision is retried, backed by a unique index on
code GET /:coderesponds302to the original URL and increments the click count atomicallyGET /api/links/:codereturns the link's stats without redirecting- The create endpoint is limited to 10 requests per IP per minute; over the limit is
429with aRetry-Afterheader - Unknown or malformed codes return
404
API Contract
| Method | Endpoint | Rate limit | Request | Success Response |
|---|---|---|---|---|
| POST | /api/links | 10 per minute | Body: url | 201 — code, short_url, original_url |
| GET | /:code | — | — | 302 redirect to the original URL |
| GET | /api/links/:code | — | — | 200 — stats including clicks |
{
"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
| Scenario | Expected result |
|---|---|
Body with no url, or url is not a string | 400 — validation message |
url is javascript:alert(1) or data: or ftp: | 400 — protocol not allowed |
url is not a url at all | 400 — fails to parse |
| Generated code already exists in the database | Silent retry with a fresh code |
GET /nosuchcode | 404 — link not found |
GET /../etc/passwd or any malformed code | 404 — fails the code pattern check, no DB query |
| 11th create request within a minute from one IP | 429 with Retry-After and rate limit headers |
| Two clicks arriving at the same instant | Both 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
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:
- Get
lengthrandom bytes fromcrypto.randomBytes(import fromnode:crypto). - 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.
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:
- It does not survive restarts — every deploy resets all counters to zero.
- 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. - 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):
- 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 a400. - Enforce the protocol allowlist — accept only
http:andhttps:. 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 blocksjavascript:,data:,ftp:and everything else by default — allowlist, not blocklist. - Insert with a retry loop — call
Link.createwith a freshgenerateCode(). 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. - Respond
201withcode,short_urlbuilt as`${BASE_URL}/${code}`, andoriginal_url— the shape shown in the API contract.
GET /:code (redirect):
- Check the code shape first — test against
/^[0-9a-zA-Z]{7}$/and return404on failure, before any database query. Malformed input like/../etc/passwdnever reaches MongoDB. - Increment and fetch in one operation —
Link.findOneAndUpdate({ code }, { $inc: { clicks: 1 } }). Reading the document, adding 1 in JavaScript, and saving would lose clicks under concurrency;$incmakes the database do the increment atomically. - Redirect with
302, not301—res.redirect(302, link.original_url)(or404if no document matched). Browsers cache a301permanently and stop hitting your server, so your click counts freeze;302keeps 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:
express.json(), thenPOST /api/linkswith the rate limiter in front:rateLimit({ windowMs: 60_000, max: 10 }), then the shorten handler.GET /api/links/:code→ stats, then the catch-allGET /:code→ redirect, last among the routes.- The 404 handler and the central error handler; finally
await mongoose.connect(...)andapp.listen(...).
Test It with curl
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"
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.
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"}'
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.
curl -s http://localhost:3000/zzzzzzz
Stretch Goals
- Custom aliases — let the body include an optional
alias, validated against the same pattern rules and rejected with409when taken - Expiring links — an optional
expires_atfield; expired codes return410 Gone, and a MongoDB TTL index cleans them up - Redis-backed limiter — swap the
Mapfor RedisINCRandEXPIRE, then run two app processes behind a load balancer and prove the limit holds across both - 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