Design a URL Shortener
Difficulty: ββ | Asked at: Google, Amazon, Meta, Microsoft | Time: 45 minutes
π― Problem Statement
Design a URL shortening service like TinyURL or bit.ly that:
- Shortens long URLs to compact aliases
- Redirects short URLs to original URLs
- Handles high traffic with low latency
Step 1: Requirements
Functional Requirements
- Given a long URL, generate a short, unique URL
- Given a short URL, redirect to the original long URL
- Users can optionally set custom short URLs
- Links expire after a configurable time (default: 5 years)
- Users can view click analytics (click count, referrers, geography)
Non-Functional Requirements
| Requirement | Target |
|---|---|
| Availability | 99.99% |
| Latency (redirect) | < 100ms |
| Throughput | 100M URLs/day created |
| Read:Write ratio | 100:1 (redirects far exceed creation) |
| Durability | URLs never lost |
Capacity Estimation
Write: 100M URLs/day = ~1,160 writes/sec
Read: 100:1 ratio = ~116,000 reads/sec
Peak: 2x average = ~2,320 writes/sec, ~232,000 reads/sec
Storage (5 years):
100M Γ 365 Γ 5 = 182.5B records
182.5B Γ 500 bytes = ~91 TB
Bandwidth:
Write: 1,160 Γ 500B = ~580 KB/s
Read: 116,000 Γ 500B = ~58 MB/s
Cache (80/20 rule - 20% of URLs get 80% of traffic):
Daily active URLs: ~20M unique
Cache size: 20M Γ 500B = ~10 GB
Step 2: High-Level Design
Architecture
ββββββββββββ ββββββββββββββββ βββββββββββββββββ
β Client βββββββ Load Balancerβββββββ API Servers β
ββββββββββββ ββββββββββββββββ βββββββββ¬ββββββββ
β
βββββββββββββββββββββββββββΌβββββββββββββββββ
β β β
βββββββΌβββββββ ββββββββΌββββββββ ββββββΌββββββ
β Cache β β Database β βAnalytics β
β (Redis) β β (PostgreSQL) β β (Kafka) β
ββββββββββββββ ββββββββββββββββ ββββββββββββ
API Design
POST /api/v1/urls
Body: { "long_url": "https://example.com/very/long/path",
"custom_alias": "my-link", // optional
"expires_in_days": 365 } // optional, default 1825
Response: { "short_url": "https://short.ly/abc123",
"expires_at": "2026-08-02T00:00:00Z" }
GET /{short_code}
Response: 301 Redirect β long_url
(Use 301 for permanent, 302 for temporary β affects browser caching)
GET /api/v1/urls/{short_code}/analytics
Response: { "total_clicks": 12345,
"unique_visitors": 8901,
"clicks_by_date": { "2025-01-01": 100, ... },
"top_referrers": { "google.com": 500, ... },
"top_countries": { "US": 3000, "IN": 2000, ... } }
Database Schema
CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(10) UNIQUE NOT NULL,
long_url TEXT NOT NULL,
user_id BIGINT REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
is_custom BOOLEAN DEFAULT FALSE
);
CREATE INDEX idx_short_code ON urls(short_code);
CREATE INDEX idx_expires_at ON urls(expires_at);
CREATE TABLE click_events (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(10) NOT NULL,
clicked_at TIMESTAMP DEFAULT NOW(),
ip_address INET,
user_agent TEXT,
referrer TEXT,
country_code VARCHAR(2)
);
CREATE INDEX idx_click_code ON click_events(short_code);
CREATE INDEX idx_click_time ON click_events(clicked_at);
Step 3: Deep Dive
URL Shortening Algorithm
Approach 1: Hash + Truncate
import hashlib
import base64
def generate_short_code(long_url, length=7):
hash_bytes = hashlib.md5(long_url.encode()).digest()
short_code = base64.urlsafe_b64encode(hash_bytes).decode()[:length]
return short_code
# Problem: Collision possible
# Solution: Check DB, if collision β append counter or use different hash
Approach 2: Base62 Encoding of Auto-Increment ID
CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def id_to_short_code(id):
if id == 0:
return CHARS[0]
code = []
while id > 0:
code.append(CHARS[id % 62])
id //= 62
return ''.join(reversed(code))
# ID 12345 β short code "3d7"
# Pros: No collision, deterministic
# Cons: Predictable (security concern), requires distributed ID generation
Approach 3: Pre-Generated Key Service (Recommended)
βββββββββββββββββββ βββββββββββββββββββ ββββββββββββββββ
β Key Service βββββββ Key Store βββββββ API Server β
β (generates keys β β (available keys)β β (assigns key β
β in batches) β β β β to URL) β
βββββββββββββββββββ βββββββββββββββββββ ββββββββββββββββ
- Pre-generate millions of random 7-char keys (Base62)
- Store in DB table: keys(code, is_used)
- API server fetches batch of 1000 unused keys
- When batch runs low, fetch more
- Pros: No collision, not predictable, fast
Caching Strategy
Cache Design (Redis):
βββ Key: short_code
βββ Value: long_url
βββ TTL: 24 hours (refreshed on access)
βββ Eviction: LRU
βββ Size: ~10 GB for hot URLs
Read Path:
1. Client β GET /abc123
2. API Server β Check Redis cache
3. Cache HIT β Return 301 redirect (fast path)
4. Cache MISS β Query PostgreSQL
5. Store in Redis β Return 301 redirect
Write Path:
1. Client β POST /api/v1/urls
2. API Server β Generate short code
3. Write to PostgreSQL
4. Write to Redis cache
5. Return short URL
Cache Invalidation:
- On URL deletion: Delete from Redis
- On URL update: Delete from Redis (lazy reload)
- TTL expiration: Automatic cleanup
Database Scaling
Read Replicas:
ββββββββββββββββ
β Primary βββββ Writes
β (PostgreSQL)β
ββββββββ¬ββββββββ
β Replication
ββββββΌβββββ¬βββββββββ
β β β β
βββΌβββββΌβββββΌββ ββββΌβββ
βR1 ββR2 ββR3 β β R4 β β Reads (4 replicas)
βββββββββββββββ βββββββ
Sharding Strategy (if needed at scale):
- Shard by short_code hash
- Consistent hashing for even distribution
- Cross-shard queries avoided by design
Handling Expired URLs
Approach 1: Lazy Cleanup (Recommended)
- On read: Check expires_at β If expired, return 404 + delete
- Background job: Periodically scan and delete expired URLs
- Pros: Simple, no extra infrastructure
- Cons: Stale data in DB until accessed
Approach 2: TTL-based (Redis handles expiration)
- Set Redis TTL = expires_at - now()
- Background job cleans PostgreSQL
- Pros: Automatic expiration in cache
- Cons: Two systems to manage
Redirect Semantics: 301, 302, 307, 308 β A Cost-Benefit Table
The Step 4 table below compresses this to two rows; the full answer needs all four codes, because the choice silently decides what gets cached and therefore what your system is allowed to change later. RFC 9110 defines the family: βThe 3xx (Redirection) class of status code indicates that further action needs to be taken by the user agent in order to fulfill the requestβ [1]. The load-bearing sentences:
- 301 β βthe target resource has been assigned a new permanent URI and any future references to this resource ought to use one of the enclosed URIs,β and β the operationally fatal one β βA 301 response is heuristically cacheable; i.e., unless otherwise indicated by the method definition or explicit cache controls (see Section 4.2.2 of [CACHING])β [1]. βHeuristically cacheableβ is why 301 removes you from your own system: browsers and intermediaries are invited to pin the mapping and never return. Per-click analytics survives only on cache misses (computed: at a 90% browser cache-hit rate you observe ~10% of clicks, so 20K logged clicks in an hour implies ~200K real ones); the target can no longer be edited for anyone who already cached it; and the expirations from Step 1 become unenforceable for that audience.
- 302 β temporary by definition: βSince the redirection might be altered on occasion, the client ought to continue to use the target URI for future requestsβ [1]. Not heuristically cacheable β every click comes back. Analytics stay exact; the price is 100% of traffic on your edge.
- 307/308 β the method-preserving pair: 307 says βthe user agent MUST NOT change the request method if it performs an automatic redirection to that URI,β and 308 is its permanent, heuristically-cacheable sibling [1]. For plain GET redirects they behave like 302/301 respectively; they matter when API clients POST through short links β the 301/302 notes explicitly permit a user agent to flip POST to GET βfor historical reasonsβ [1].
| Code | Method preserved | Browser caches | Per-click analytics | Target editable | Expiry enforceable |
|---|---|---|---|---|---|
| 301 | No (may flip POSTβGET) | Yes | Only on cache miss | No β burned in | No |
| 302 | No (may flip POSTβGET) | No | Every click | Yes | Yes |
| 307 | Yes | No | Every click | Yes | Yes |
| 308 | Yes | Yes | Only on cache miss | No β burned in | No |
This is why real shorteners split: 301 for max-cache (destination never changes; re-resolving every click is pure waste) and 302 for analytics/editable/expiring links. Both are defensible; picking 301 per-link and wanting analytics later is not recoverable.
The Referer you never see. RFC 9110 also decides what happens to the clickerβs headers: when automatically following a redirect, the user agent should resend the request after removing βresource-specific header fields, including (but not limited to) Referer, Origin, Authorization, and Cookieβ [1]. Two consequences: destination sites usually see shortener traffic as direct (the original referrer is stripped at the hop), and the shortener itself becomes the last party holding full click context β which is why referrer data is simultaneously a product feature here and a privacy obligation.
The CDN decision. Three escalating architectures: (1) redirect at edge β the CDN caches the 30x response itself, keyed by host+path; browsers wonβt cache a 302, but with explicit Cache-Control your edge can cache it for seconds-to-minutes, absorbing spikes while keeping analytics nearly complete; (2) lookup at edge β an edge worker resolves the code from an edge-resident KV store; a full global push is only affordable for the hot subset; (3) 302-to-resolver β the edge always asks your resolver fleet: maximal control, minimal cache benefit. Step 2βs design is (3) with a Redis L1; production systems typically mix (1) for the hot prefix of links and (3) for the long tail.
Abuse, Safety, and the Operational Underbelly
A shortener is a machine for laundering the appearance of a URL: a trusted domain, an opaque code space (Step 3βs random key pool makes unpredictability a feature), and a redirect that fires before any content is seen. That combination is a magnet for phishing and malware, so safety is a pipeline, not a checklist item.
URL reputation checking. The standard reference is Google Safe Browsing: βa Google service that lets client applications check URLs against Googleβs constantly updated lists of unsafe web resources. Examples of unsafe web resources are social engineering sites (phishing and deceptive sites) and sites that host malware or unwanted softwareβ [2]. Its documented use case β βPrevent users from posting links to known infected pages from your siteβ [2] β is exactly the shortenerβs creation path. One licensing subtlety is interview-relevant: βThe Safe Browsing API is for non-commercial use only. If you need to use APIs to detect malicious URLs for commercial purposes - meaning βfor sale or revenue-generating purposesβ - please refer to the Web Risk APIβ [2] β a commercial shortener buys a reputation product, not the free browser API. Pipeline placement follows the QPS asymmetry: check synchronously at creation (writes are ~100Γ rarer than redirects) and asynchronously at redirect (sampled, or only for codes not recently seen) β a per-redirect vendor lookup would multiply your hottest pathβs latency.
Rate limiting by identity. Creation is limited per API key (quota, tier, and billing hang off the key); anonymous creation gets much lower per-IP ceilings, because anonymous bulk shortening is the classic spam-campaign shape. The redirect path is throttled only coarsely (per-IP at the edge) β it is 100:1 hotter, and its real protection is caching, not rejection. Algorithm selection lives in Rate Limiter.
Spam takedown workflow. Detection sources (user reports, reputation-vendor hits, heuristics on the target URL) feed a review queue; the action on confirmed abuse is deactivate, donβt delete: flip the mappingβs active flag and serve an interstitial (or 410) rather than silently 404ing β deletion also destroys the audit trail an appeal or investigation needs. Every takedown carries a recorded decision, an owner, and an appeal SLA, because a blocked legitimate campaign is a customer-trust incident, and the false-positive rate is the metric that governs how aggressive auto-takedown can be.
Custom domains and certificates. Enterprise links live on brand.co, meaning one TLS-termination fleet presenting tens of thousands of certificates via SNI. Manual cert management cannot survive that; ACME automation can β Letβs Encryptβs objective is to βmake it possible to set up an HTTPS server and have it automatically obtain browser-trusted certificates without any human intervention,β proving control via challenges such as βProvisioning a DNS record under example.com, or Provisioning an HTTP resource under a well-known URIβ [3]; RFC 8555 states the guarantee: βChallenges provide the server with assurance that an account holder is also the entity that controls an identifierβ [4]. Note the convergence: the ACME challenge is your domain-ownership verification at onboarding β a customer who can complete it for brand.co has demonstrated exactly the DNS control the platform needs. The failure mode is silent: a mistyped CNAME makes the challenge fail and kills https://brand.co/launch with a TLS error, so onboarding is a provisioning state machine with retries, per-domain status in the customer dashboard, and alerts on renewal failure.
Analytics Without Killing the Read Path
Step 2 already strands click events in Kafka; the discipline that makes the separation honest is: the redirect path performs exactly one O(1) read (the mapping lookup) and zero writes. Everything else is a side channel that may fall behind or lose data without the redirect noticing β analytics is never inline.
Fire-and-forget click events. The resolver emits (short_code, ts, ip, ua, referrer, geo) to the event stream and returns the redirect immediately. Back-pressure policy is explicit: if the broker is unavailable or overloaded, sample or drop β never queue indefinitely in front of the user-facing path. Click analytics is estimated data; mapping correctness is not. This event β durable log β derived views pattern is stream processing in miniature.
Async aggregation into rollups. Consumers aggregate into per-link daily rollups β (link_id, date) β clicks, plus dimension slices (referrer, country, device). The arithmetic of why: at the case studyβs 43.2B raw events/day (computed: 500K/s Γ 86,400 s), dashboards over raw events pay per-query scan costs; rollups are bounded by links Γ dimensions (computed worst case: 1M active links Γ 200 country codes = 200M rows/day β 216Γ smaller than the raw stream; realistic links have <5 countries each). The real-world case study shows the ClickHouse end of this pipeline.
Unique vs raw clicks. βClick countβ is two different numbers: raw clicks (every event) and unique clicks (distinct users). Uniqueness needs an identity β user id (rarely available on the anonymous redirect path), a cookie, or a salted hash of IP+UA β plus a dedup window, giving the key (link_id, user_or_ip_hash, window). Salt matters: the shortener must not become a re-identifiable record of who clicked what, so hash with a rotated salt and accept that uniqueness becomes approximate across rotations. Exact daily distinct counts over billions of events are unaffordable; cardinality estimators trade ~2% error for constant memory β see Probabilistic Data Structures.
Why zero writes on the read path. Three tempting regressions, all interview red flags: (1) a synchronous INCR clicks:{code} couples redirect latency to the counter storeβs fate; (2) per-click writes turn the viral linkβs key into a single contention point; (3) any analytics outage becomes a redirect outage. State the contract instead: analytics may lag by seconds (the freshness SLO is a product decision β the case study picks 5s); the redirect may not lag at all.
Step 4: Trade-offs
301 vs 302 Redirect
| Code | Meaning | Browser Behavior | Use Case |
|---|---|---|---|
| 301 | Permanent | Caches redirect | SEO, permanent links |
| 302 | Temporary | Always asks server | Analytics, temporary links |
Recommendation: Use 302 if you need accurate analytics (every request hits your server).
Hash vs Sequential ID
| Approach | Pros | Cons |
|---|---|---|
| Hash + Truncate | No extra service | Collision possible |
| Sequential ID | No collision | Predictable, requires distributed ID |
| Pre-generated keys | No collision, secure | Extra service to maintain |
Synchronous vs Async Analytics
| Approach | Pros | Cons |
|---|---|---|
| Synchronous write | Simple, immediate | Slows down redirect |
| Async (Kafka) | Fast redirect, decoupled | Eventual consistency |
Recommendation: Async β redirect should be as fast as possible.
π Monitoring & Reliability
Key Metrics to Monitor:
βββ Redirect latency (p50, p95, p99)
βββ Cache hit ratio (target: > 95%)
βββ URL creation rate
βββ Error rate (4xx, 5xx)
βββ Database connection pool usage
βββ Kafka consumer lag
Alerting:
βββ Redirect latency p99 > 200ms β Warning
βββ Cache hit ratio < 90% β Investigate
βββ Error rate > 1% β Critical
βββ Database replication lag > 5s β Warning
π References
- RFC 9110, HTTP Semantics β Sections 15.4 (Redirection 3xx), 15.4.2 (301), 15.4.3 (302), 15.4.8 (307), 15.4.9 (308) β https://www.rfc-editor.org/rfc/rfc9110 β fetched in full this session; all quoted sentences verbatim.
- Google Safe Browsing documentation β https://developers.google.com/safe-browsing β fetched this session; service description, βPrevent users from posting linksβ¦β use case, and the non-commercial-use clause (with its Web Risk pointer) quoted verbatim from the same page.
- Letβs Encrypt, βHow It Worksβ β https://letsencrypt.org/how-it-works/ β fetched this session; the ACME objective and the two challenge examples quoted verbatim.
- RFC 8555, Automatic Certificate Management Environment (ACME) β Section 8 (Identifier Validation Challenges) β https://www.rfc-editor.org/rfc/rfc8555 β fetched this session; the challenges sentence quoted verbatim.
Note: no academic DOI could be verified for this expansion β Crossref bibliographic queries for URL-shortening measurement studies returned only unrelated entries this session, so none is cited.
π Cross-References
- ID Generation β the birthday-bound collision math behind short-code space sizing (linked, not re-derived here)
- Rate Limiter β Protect the URL shortener from abuse
- Key-Value Store β Deep dive on distributed storage
- URL Shortener Case Study β bit.ly-class production numbers for this same design
- Caching Concepts β Caching strategies
- Database Questions β SQL vs NoSQL trade-offs