Keyboard shortcuts

Press ← or β†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Given a long URL, generate a short, unique URL
  2. Given a short URL, redirect to the original long URL
  3. Users can optionally set custom short URLs
  4. Links expire after a configurable time (default: 5 years)
  5. Users can view click analytics (click count, referrers, geography)

Non-Functional Requirements

RequirementTarget
Availability99.99%
Latency (redirect)< 100ms
Throughput100M URLs/day created
Read:Write ratio100:1 (redirects far exceed creation)
DurabilityURLs 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].
CodeMethod preservedBrowser cachesPer-click analyticsTarget editableExpiry enforceable
301No (may flip POST→GET)YesOnly on cache missNo — burned inNo
302No (may flip POST→GET)NoEvery clickYesYes
307YesNoEvery clickYesYes
308YesYesOnly on cache missNo β€” burned inNo

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

CodeMeaningBrowser BehaviorUse Case
301PermanentCaches redirectSEO, permanent links
302TemporaryAlways asks serverAnalytics, temporary links

Recommendation: Use 302 if you need accurate analytics (every request hits your server).

Hash vs Sequential ID

ApproachProsCons
Hash + TruncateNo extra serviceCollision possible
Sequential IDNo collisionPredictable, requires distributed ID
Pre-generated keysNo collision, secureExtra service to maintain

Synchronous vs Async Analytics

ApproachProsCons
Synchronous writeSimple, immediateSlows down redirect
Async (Kafka)Fast redirect, decoupledEventual 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

  1. 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.
  2. 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.
  3. 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.
  4. 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