The problem
A marketing team wants links like https://sho.rt/aZ3kP9x that redirect to long campaign URLs, and they want to see how many people clicked. The service sounds trivial, which is why it is a popular design exercise: the core is tiny, so the design discussion is about the choices around it. Reads vastly outnumber writes, latency of the redirect is visible to every user, and abuse (spam, phishing) is a real concern.
Requirements
Start by writing down what is in and out of scope.
Functional
- Create a short link for a long URL, optionally with a custom alias and an expiry date.
- Redirect a short link to its target.
- Basic click analytics: count per link, rough breakdown by day.
Non-functional
- Redirects must be fast and highly available; creating links may tolerate a little more latency.
- Short codes must not be guessable in sequence (to avoid enumeration of private links).
- Links must not silently change target.
Out of scope for this version: user accounts, billing, per-click personalization.
Back-of-the-envelope estimates
These numbers are assumptions to show the method, not facts about any real service. Assume 10 million new links per month and a read-to-write ratio of 100:1.
- Writes: 10,000,000 / (30 x 86,400 s) is roughly 4 per second on average.
- Reads: 100 x that, about 400 per second on average. Assume peaks of 10x, so about 4,000 per second.
- Storage: 10 million links per month, around 500 bytes per row including indexes, is about 5 GB per month, or 60 GB per year. That fits comfortably on a single PostgreSQL primary for the first years.
The conclusion is useful: this is a read-heavy system whose data volume is modest. A cache and read replicas solve the load; sharding is not needed on day one.
API
POST /api/v1/links
{ "targetUrl": "https://shop.test/very/long?utm=spring",
"alias": "spring-sale", (optional)
"expiresAt": "2027-01-01T00:00:00Z" } (optional)
-> 201 { "code": "aZ3kP9x", "shortUrl": "https://sho.rt/aZ3kP9x" }
GET /{code}
-> 302 Location: https://shop.test/very/long?utm=spring
-> 404 if unknown, 410 if expired or disabled
GET /api/v1/links/{code}/stats?from=2026-09-01&to=2026-09-30
-> 200 { "total": 1234,
"byDay": [ { "date": "2026-09-01", "clicks": 40 } ] }Use 302 (temporary) when you want every click to reach your service for analytics and to keep the ability to change the target. A 301 is cached aggressively by browsers, which reduces load but hides clicks and makes later changes hard.
Data model
CREATE TABLE short_link ( code varchar(12) PRIMARY KEY, target_url text NOT NULL, owner_id bigint, created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz, disabled boolean NOT NULL DEFAULT false);The primary key on code serves the hot path, a lookup by exact key. Click data is a different shape (append-only, high volume), so keep it separate, for example in a click_daily(code, day, clicks) table fed asynchronously.
Generating codes
A 7-character code using 62 characters (a-z, A-Z, 0-9) gives 62^7, roughly 3.5 trillion combinations, far above the assumed volume. Three common approaches:
| Approach | Strength | Weakness |
|---|---|---|
| Random code, insert, retry on collision | Simple, not guessable | Collision retries grow as table fills; need unique constraint |
| Counter encoded in base62 | No collisions, short | Sequential and guessable unless the number is scrambled |
| Pre-generated key pool handed out in blocks | Fast, no hot spot | Extra component to run and monitor |
For this volume, random codes with a unique constraint are enough: generate, INSERT, and on a unique-violation retry with a new code. If codes must not be guessable, avoid plain counters; if you use one, pass it through a reversible scrambling step.
The read path and caching
The redirect flow is a lookup by key, so it suits a cache. Use cache-aside as described in caching strategies:
Browser -> CDN/edge -> Redirect service -> Redis -> PostgreSQL
| miss: read DB, populate Redis (TTL)
| hit: respond 302 immediately
v
Click event -> Kafka -> aggregator
-> click_dailyCache negative results (unknown codes) briefly so scanners do not hammer the database. Choose TTLs shorter than the time a disabled link may remain reachable, or evict the key explicitly when a link is disabled.
Analytics without slowing the redirect
Do not write to the database synchronously on every click. Publish a small click event (code, timestamp, coarse metadata) to a broker such as Kafka, and let an aggregator update daily counters. The redirect stays fast; analytics become eventually consistent, which is acceptable for dashboards. Be deliberate about personal data: store only what the product needs, and decide on IP and user agent handling before collecting them.
Scaling reads and availability
- Run several stateless redirect instances behind a load balancer.
- Add Redis in front of the database; consider read replicas for cache misses.
- Put a CDN or edge cache in front only if you accept slower propagation of disabled links.
- Keep creation and redirect as separate deployable units if their load profiles diverge.
Sharding by code hash becomes relevant only if the estimates are badly wrong; the hash-based key makes that later move straightforward.
Abuse prevention
Short links hide destinations, so they are attractive for phishing. Reasonable controls:
- rate-limit creation per account and per IP
- validate URL syntax and allow only
httpandhttpsschemes - block known-bad domains using a reputation list the team maintains or subscribes to
- provide a way to report and disable a link quickly, with the cache eviction mentioned above
- prevent redirect loops by rejecting targets that point to the service itself
Trade-offs to state aloud
- 301 versus 302: performance versus control and analytics.
- Random versus counter codes: simplicity and secrecy versus collision handling.
- Synchronous versus async analytics: accuracy at the instant versus redirect latency.
- Custom aliases: friendlier links but a namespace that can be squatted and that collides with generated codes; reserve a different length or prefix.
- Expiry: needs a clean-up job or a lazy check on read; both are cheap here.
Failure scenarios
Also write down what can break. If Redis is unavailable, the redirect service falls back to PostgreSQL directly; to let the database carry that load you need a short timeout and a rate limit. If the database primary fails over, link creation may fail for a while, but reads continue from replicas and the cache. If Kafka is unavailable, click events must be buffered locally or dropped by agreement; the redirect must never wait for them. The guiding rule is that only analytics should degrade, never the redirect.
Common mistakes
- Designing sharding and multi-region replication before estimating the load.
- Using the database as the click counter, creating a write hotspot on popular links.
- Forgetting that creation is not idempotent; a client retry can create two codes. Accept an idempotency key as in idempotency patterns.
- Ignoring abuse until after launch.
Checklist
- Requirements and out-of-scope items written down
- Estimates labelled as assumptions, with the conclusion drawn
- API with status codes for unknown and expired links
- Code generation strategy and collision handling chosen
- Cache strategy with invalidation for disabled links
- Async analytics path
- Abuse controls and a takedown path
Where to go next
Learn the repeatable method behind this walkthrough in the system design interview method, review caching strategies, or follow the Senior Backend Developer roadmap.