Design a URL Shortener: System Design Solution

Solution
easy
hashing
databases
caching
scaling

Want to try it yourself first?

Answer each stage in the practice workspace, sketch the architecture, or run a timed mock, then come back here to compare.

Practice this question

The problem

A URL shortener takes a long URL and returns a short, unique alias. When a user visits the short link, the service looks up the original URL and redirects them. The mechanics fit in a sentence — which is exactly why it's a classic interview problem. The interesting decisions only surface once you push on scale, and a strong candidate is expected to uncover them rather than be handed a checklist.

Functional Requirements

The core features we need to support:

  • Shorten a URL — given a long URL, return a short alias (e.g. https://gdt.io/aZ8kP1).
  • Redirect — given a short alias, redirect to the original long URL.
  • Custom aliases (optional) — let users pick their own alias when available.
  • Expiration (optional) — links can expire after a configurable time.

Example. Given https://example.com/products/2026/summer-sale?ref=email&utm_campaign=q3 the service returns https://gdt.io/15ftgG. Visiting https://gdt.io/15ftgG then 302-redirects back to the original URL. That ~60-character link became 6 characters — the "short code" is the only thing we generate and store as the key.

Out of scope for a first pass: user accounts, analytics dashboards, and link editing. Call these out so the interviewer knows you're scoping deliberately.

Scale Requirements

Always anchor the design with rough numbers. A reasonable set of assumptions:

  • 100M new URLs created per month → ~40 writes/sec on average.
  • Read:write ratio of 100:1 → ~4,000 reads/sec, this is a read-heavy system.
  • 5 years of retention → 100M × 12 × 5 = 6B URLs total.
  • Each record ~500 bytes → 6B × 500B ≈ 3 TB of storage.

The big takeaways: reads dominate (cache aggressively) and storage is modest enough for a single sharded database.

Non-Functional Requirements

We pull these from adjectives in the problem statement:

  • High availability — redirection must work 24/7; a dead short link is a broken promise.
  • Low latency — redirects should feel instant (single-digit milliseconds).
  • High durability — once created, a short URL must never be lost.
  • Uniqueness — every short code maps to exactly one long URL, no collisions.
  • Scalability — handle growth in both stored links and read traffic.

Availability is favored over strong consistency here: it's fine if a freshly created link takes a moment to propagate, but it's not fine if redirects go down.

Data Model

A single table is enough for the core service:

FieldTypeNotes
short_codestring (PK)7-char base62 alias
long_urlstringthe original URL
created_attimestampfor retention / expiration
expires_attimestamp (nullable)optional TTL

We index on short_code (the primary lookup). Analytics (click counts) are best kept in a separate store so high-volume write traffic doesn't slow down redirects.

API Endpoints

Two endpoints cover the core flows:

POST /api/shorten
{ "long_url": "https://example.com/very/long/path", "custom_alias": "optional" }
-> 200 { "short_url": "https://gdt.io/aZ8kP1" }
GET /{short_code}
-> 302 Found
   Location: https://example.com/very/long/path

Use a 302 (temporary) redirect rather than 301 (permanent) so the browser keeps hitting our service — that preserves our ability to expire links and (later) count clicks.

High-Level Design

The request flow:

  1. Write path — client calls POST /api/shorten; the API server generates a unique ID, encodes it to base62, persists {short_code, long_url}, and returns the short URL.
  2. Read path — client requests /{short_code}; the API server checks the cache first, falls back to the database, then issues a 302 redirect.

Because reads outnumber writes 100:1, a cache (e.g. Redis) in front of the database absorbs the vast majority of traffic.

Deep Dive: Generating Unique Short Codes

The heart of the problem. We need codes that are unique, short, and ideally not trivially guessable in sequence. Walk the options and their trade-offs:

  • Hashing (MD5/SHA + truncate) — hash the long URL and take the first few characters. Simple and stateless, but truncation causes collisions you must detect and retry, and the same URL always maps to the same code (which breaks per-user custom links and lets others probe whether a URL was ever shortened).
  • UUID — guaranteed unique with no coordination, but 128 bits → ~22 base62 characters: far too long for a "short" URL.
  • Counter + base62 encode (good default at this scale) — hand out a unique, ever-increasing integer and encode it to base62 ([a-zA-Z0-9]). 7 characters gives 62⁷ ≈ 3.5 trillion codes. Worked example: with the alphabet ordered 0-9, A-Z, a-z, the counter value 1,000,000,000 encodes to 15ftgG (repeated division by 62); the next id 1,000,000,001 becomes 15ftgH.

Where does the counter actually come from? This is the part interviewers push on, so be specific rather than saying "a global counter":

  • Database auto-increment — a monotonic primary key from the database itself (MySQL AUTO_INCREMENT, PostgreSQL IDENTITY/SERIAL). The row insert mints the id for free. At our ~40 writes/sec this is completely fine, and it's where I'd start. The catch is that it couples id generation to a single writer: it becomes a write-throughput ceiling and a single point of failure as you grow, and the ids are enumerable — anyone can walk 15ftgG, 15ftgH, … to scrape every link and infer how many URLs you've created.
  • Key-Generation Service (KGS) — a small dedicated service that pre-allocates ids and hands ranges (say, a block of 10,000) to each app server. Servers then generate codes locally with no per-write round trip, which removes the hot single-writer path while keeping codes compact. A KGS can also pre-generate random-looking keys so codes aren't sequential or guessable.
  • Snowflake / distributed ids — the answer when a single counter genuinely can't keep up; explained next.

If sequential codes are the only problem, you don't need to change the id source — run the counter through a keyed permutation (e.g. a Feistel network or a library like hashids) so the output looks random while staying 1:1 and collision-free.

What is Snowflake? Snowflake (originally from Twitter) generates unique 64-bit ids with no shared counter, so every machine mints ids independently. The 64 bits are partitioned roughly like this:

| 1 bit unused | 41 bits: timestamp (ms) | 10 bits: machine/worker id | 12 bits: per-ms sequence |
  • The timestamp makes ids roughly time-ordered — handy for sortable, index-friendly keys.
  • The worker id guarantees two machines never collide, with zero coordination on the hot path.
  • The sequence allows up to 4,096 ids per machine per millisecond.

Why reach for it: it removes the single-counter bottleneck and SPOF entirely — write throughput scales with the number of machines. What it costs: every machine needs a unique, stable worker id (typically assigned via ZooKeeper/etcd or config), and you must handle clock skew and backwards clock jumps or risk duplicate ids. And a 64-bit id is ~11 base62 characters — longer than a counter-based code — so for a product whose whole point is a short URL, a counter or KGS is often still the better fit. Snowflake earns its place at write volumes far above this problem's ~40/sec (think ids for tweets, messages, or events), so calling it out here is about showing you know the escalation path, not about using it on day one.

Base62 is preferred over base64 because it avoids URL-unfriendly characters (+, /, =) so codes are safe in links without escaping.

Deep Dive: Scaling the Read Path

Redirects are the hot path. To keep them fast:

  • Cache aggressively — keep popular short_code → long_url mappings in Redis. A high hit rate means most redirects never touch the database.
  • Eviction — use LRU; URL access tends to follow a power-law (a few links get most of the traffic).
  • Read replicas — on cache miss, read from replicas to spread load off the primary.
  • CDN / edge — for globally popular links, redirects can be served close to the user.

Deep Dive: Scaling Storage

At ~3 TB over five years, storage is not the bottleneck, but plan for growth:

  • Sharding — partition by short_code (e.g. consistent hashing) so no single node holds everything.
  • NoSQL fit — the access pattern is a simple key lookup, so a key-value or wide-column store (DynamoDB, Cassandra) fits naturally and scales horizontally.
  • Cleanup — a background job purges expired links to reclaim space.

Wrap-Up & Trade-offs

A solid answer hits these points:

  • Counter + base62 for short, collision-free codes — a plain DB sequence is fine at this write volume; reach for a KGS or Snowflake only when writes outgrow a single writer.
  • Cache-first reads because the system is overwhelmingly read-heavy.
  • 302 redirects to retain control over expiration and future analytics.
  • Separate analytics store so click tracking never slows the redirect path.
  • Favor availability over strong consistency for redirects.

Common follow-ups: how to prevent abuse/spam links, how to keep short codes from being enumerable/guessable, how to handle custom-alias collisions, and how to add per-link analytics without hurting latency.

Common follow-up

Traffic grows 10× and a single region can no longer meet the redirect latency target. What breaks first, and how would you evolve the design without interrupting existing links?

FAQ