Design a URL Shortener

Mediumhashing · caching · key generation · databases · analytics⏱ 7 min readAsked at Google, Amazon, Microsoft, Meta, UberUpdated

Design a TinyURL-style service that turns long links into short keys and redirects billions of clicks with low latency.

Requirements

A URL shortener maps a long URL such as https://example.com/blog/2026/10/a-very-long-path?ref=newsletter to a short link like https://sho.rt/aZ3kP9q. Visiting the short link redirects to the original.

Functional

  • Create a short link for a long URL (optionally with a custom alias).
  • Redirect a short link to its original URL.
  • Optional expiry time per link.
  • Basic click analytics (count, referrer, country, device).
  • Owners can delete their links.

Non-functional

  • Read-heavy: redirects vastly outnumber creations (assume 100:1).
  • Low latency: redirect p99 under ~50 ms server-side.
  • High availability: a broken redirect breaks every page that embeds the link.
  • Durability: a created link must never silently disappear.
  • Unpredictable keys (nice to have): keys should not be trivially enumerable.
Tip

Spend the first few minutes agreeing on scale, custom aliases, expiry and analytics. Analytics alone can double the size of the design, so confirm whether it is in scope.

Capacity Estimates

Assume 100 M new links per day, a 100:1 read-to-write ratio, links kept for 5 years, and ~500 bytes per record.

Metric Math Value
Write QPS 100 M / 86,400 s ~1,200 /s (peak ~2,500 /s)
Read QPS 1,200 × 100 ~120 K /s (peak ~250 K /s)
Records over 5 years 100 M × 365 × 5 ~183 B
Storage 183 B × 500 B ~91 TB (before replication)
Write bandwidth 1,200 × 500 B ~0.6 MB/s
Read bandwidth 120 K × 500 B ~60 MB/s
Key space needed > 183 B keys 62^7 ≈ 3.5 trillion, so 7 base62 chars suffice
Cache (hot 20%) 20% of daily reads × 500 B upper bound ~1 TB; far less after de-duplication

The numbers tell you two things: storage is large but simple (key-value), and the read path must be served mostly from cache.

API Design

text
POST /api/v1/urls
  Body:     { "longUrl": "https://...", "customAlias": "optional", "expiresAt": "optional ISO-8601" }
  201:      { "key": "aZ3kP9q", "shortUrl": "https://sho.rt/aZ3kP9q" }
  409:      custom alias already taken

GET /{key}
  302:      Location: <longUrl>
  404:      unknown key
  410:      link expired or deleted

DELETE /api/v1/urls/{key}          (owner only, 204 No Content)

GET /api/v1/urls/{key}/stats?from=...&to=...
  200:      { "clicks": 1234, "byCountry": {...}, "byReferrer": {...} }
  • Authenticate create/delete/stats with an API key or OAuth token; redirects are public.
  • Rate limit POST per user and per IP to stop spam and key-space exhaustion.

Data Model

The access pattern is a single-key lookup, so a horizontally scalable key-value or wide-column store (DynamoDB, Cassandra, Bigtable) fits well. A sharded relational database also works.

sql
CREATE TABLE urls (
  short_key   VARCHAR(10)  PRIMARY KEY,
  long_url    TEXT         NOT NULL,
  user_id     BIGINT,
  created_at  TIMESTAMP    NOT NULL,
  expires_at  TIMESTAMP,
  is_deleted  BOOLEAN      DEFAULT FALSE
);

CREATE TABLE users (
  user_id     BIGINT       PRIMARY KEY,
  email       VARCHAR(255) UNIQUE,
  api_key     VARCHAR(64)  UNIQUE,
  created_at  TIMESTAMP
);
Store Contents Partition key
URL store key → long URL, owner, expiry short_key
Cache (Redis) key → long URL short_key
Analytics store (ClickHouse, Druid) click events, rollups short_key + time bucket

High-Level Design

flowchart LR
  Client --> LB[Load Balancer]
  LB --> W[Write Service]
  LB --> R[Redirect Service]
  W --> KGS[Key Generation Service]
  W --> DB[(URL Store)]
  R --> C[(Redis Cache)]
  R --> DB
  R --> Q[[Click Event Queue]]
  Q --> A[Stream Aggregator]
  A --> OLAP[(Analytics Store)]

Write path: validate the URL → get a unique key → persist the record → return the short URL.

Read path: look up the key in cache → on miss read the DB and populate the cache → emit a click event asynchronously → return the redirect.

Separating read and write services lets each scale independently; the redirect fleet is roughly 100× larger.

Deep Dives

Key generation

This is the core of the interview. Three common approaches:

1. Counter + base62 encoding

  • A global, increasing integer ID is encoded into base62 (0-9a-zA-Z).
  • Guaranteed unique, no collision checks.
  • A single counter is a bottleneck, so hand out ranges: each app server leases a block (e.g. 1 M IDs) from ZooKeeper or a "ticket" table and allocates locally.
  • Downside: sequential keys are guessable. Mitigate by shuffling the ID through a reversible bijection (e.g. a Feistel cipher) before encoding.

2. Hash the long URL

  • Compute MD5/SHA-256 of the URL, base62 encode, take the first 7 characters.
  • The same URL naturally maps to the same key (free de-duplication).
  • Truncation causes collisions: check the DB and, on conflict, append a salt and re-hash. This adds a read per write and complicates concurrency (use a conditional insert such as INSERT ... IF NOT EXISTS).

3. Pre-generated Key Generation Service (KGS)

  • Offline, generate random unique 7-char keys and store them in an "unused keys" table.
  • App servers fetch keys in batches into memory and hand them out with no coordination.
  • Moving a batch to "used" atomically prevents two servers from getting the same key.
  • If a server crashes, its in-memory keys are lost; with 3.5 trillion keys, that waste is acceptable.
  • The KGS must be replicated so it is not a single point of failure.
Approach Uniqueness Predictable? Write cost Main risk
Counter + base62 Guaranteed Yes (unless shuffled) Very low Counter coordination
Hash + truncate Collisions possible No Extra read, retries Collision handling
Pre-generated KGS Guaranteed No Very low Extra service to operate
Insight

Hashing gives de-duplication for free, but most products do not actually want it: two users shortening the same URL usually expect separate links with separate analytics. Clarify this before choosing hashing.

Caching

  • Cache-aside with Redis/Memcached, LRU eviction. Links follow a power-law: a small fraction gets most clicks.
  • Long TTLs are safe because mappings are effectively immutable; invalidate on delete or expiry.
  • Negative caching: cache "not found" briefly so scanners hitting random keys do not hammer the DB. A Bloom filter of existing keys is an alternative.
  • Edge/CDN caching of redirects for viral links pushes latency below 10 ms.

Redirects: 301 vs 302

Code Meaning Browser caches? Analytics accuracy Server load
301 Moved Permanently Permanent Yes, aggressively Poor (repeat clicks never reach you) Lowest
302 Found / 307 Temporary Temporary No (by default) Accurate Higher

Use 302 (or 307) when analytics, expiry or editable destinations matter, which is most products. Use 301 when the link is permanent and you only care about minimizing load.

Warning

A 301 cannot be undone in practice. Browsers may cache it indefinitely, so a deleted or edited link keeps redirecting to the old destination for those users.

Analytics

  • Never write analytics synchronously on the redirect path.
  • The redirect service publishes a small event (key, timestamp, IP-derived country, user agent, referrer) to Kafka or Kinesis.
  • A stream processor (Flink, Spark Streaming) aggregates per key per minute/hour and writes rollups to an OLAP store.
  • Raw events land in object storage for reprocessing and ad-hoc queries.
  • If the queue is unavailable, buffer locally and drop rather than fail the redirect: analytics is best-effort, redirects are not.

Scaling & Reliability

  • Stateless services behind load balancers; autoscale the redirect tier on QPS.
  • Shard the URL store by short_key using consistent hashing; random keys spread load evenly.
  • Replicate each shard (e.g. 3 replicas, quorum reads/writes) and across regions for disaster recovery.
  • Multi-region reads: links are write-once, so asynchronous cross-region replication with a short lag is acceptable; read-your-write for the creator can be handled by routing them to the home region briefly.
  • Expiry cleanup: check expires_at lazily on read (return 410) and run a low-priority background job to purge expired rows and return keys to the pool if desired.
  • Abuse protection: rate limit creation, scan destinations against malware/phishing lists, and support takedowns.
  • Hot keys: a viral link can overload one cache shard; replicate hot keys across nodes or keep a small in-process cache on redirect servers.

Trade-offs

Decision Option A Option B Typical choice
Datastore SQL (strong consistency, familiar) NoSQL KV (easy horizontal scale) NoSQL for scale, SQL if small
Key generation Hashing (dedupe) Counter / KGS (no collisions) KGS or range-based counter
Redirect code 301 (less load) 302 (analytics) 302
Analytics Synchronous (simple) Async stream (fast redirects) Async
Key length 6 chars (~57 B keys) 7 chars (~3.5 T keys) 7 chars
Custom aliases Same table (simple) Separate namespace (no clashes) Same table with conditional insert

Interview Tips

  • Start with numbers; they justify the cache-heavy read path and the 7-character key.
  • Present all three key-generation options, then pick one and defend it.
  • Explicitly discuss 301 vs 302; interviewers often probe it.
  • Keep analytics off the critical path and say why.
  • Mention collision handling and concurrency for custom aliases (conditional writes).
  • Call out failure modes: KGS down, cache stampede on a viral link, region outage.
  • Do not over-engineer the write path: ~1,200 writes/s is modest. The challenge is reads.
My notes saved in this browser