Skip to main content
PProfileMatch

Design a URL Shortener (Worked Example)

System Design & ArchitectureLesson 6 of 67 min read

What you'll learn

  • Apply the 5-step framework to a complete design from requirements to scaling
  • Estimate traffic, storage and short-code length with stated assumptions
  • Compare ID generation approaches and implement correct base62 encoding
  • Explain caching, scaling and the main trade-offs, such as 301 vs 302 redirects

On this page

  1. Step 1: Requirements
  2. Step 2: Back-of-envelope estimation
  3. Step 3: API and data model
  4. Step 4: Generating short codes
  5. Step 5: Architecture, caching and scaling
  6. Trade-offs and failure modes
  7. Common mistakes
  8. How this shows up in interviews and real projects

A URL shortener turns a long link into a short one like sho.rt/aZ3k9Qx and redirects visitors back. It looks trivial, which is exactly why it is a favourite design question: it is small enough to finish and rich enough to touch every building block from the earlier lessons.

Step 1: Requirements

Start with scope. Here is a reasonable set for this exercise.

  • Functional: create a short URL from a long URL; redirect a short URL to the original; optionally support custom aliases and expiry dates; (out of scope for now: user accounts, detailed analytics dashboards).
  • Availability: redirects must be highly available. A broken short link breaks every page that shared it.
  • Latency: redirects should feel instant, ideally tens of milliseconds at the server.
  • Durability: a created link must not be lost.
  • Correctness: no two long URLs may receive the same short code, and codes should be hard to guess in bulk.
  • Scale: read-heavy, with many more redirects than creations.

Step 2: Back-of-envelope estimation

These numbers are illustrative assumptions for practice, not data about any real service.

QuantityAssumption / calculationResult
New URLsAssume 100M per month100M / 2.6M seconds is about 40 writes/s
Read to write ratioAssume 100 : 110B redirects per month
Average redirects10B / 2.6M secondsabout 3,900 reads/s
Peak redirectsAssume 3x averageabout 12,000 reads/s
Storage per recordShort code, long URL, timestamps, about 500 bytes500 B
Records in 5 years100M x 12 x 56 billion records
Total storage6B x 500 Babout 3 TB
Code length62^6 is about 56.8 billion; 62^7 is about 3.5 trillion7 characters gives ample headroom

What the numbers tell us: writes are tiny (tens per second), reads are moderate (thousands per second, easily cached) and total storage of a few TB is modest. The system is read-dominated, so caching and replicas matter more than clever write scaling. A 7-character base62 code space is far larger than the 6 billion we need, which leaves room to avoid collisions.

Step 3: API and data model

REST API
POST /api/v1/urls
  body:    { "longUrl": "https://example.com/a/b",
             "alias": "my-link",     (optional)
             "expiresAt": "2027-01-01" (optional) }
  returns: 201 { "shortUrl": "https://sho.rt/aZ3k9Qx" }
  errors:  400 invalid URL, 409 alias already taken

GET /{code}
  returns: 301 or 302 with Location: <longUrl>
           404 if unknown, 410 if expired
ColumnTypeNotes
codeVARCHAR(10), primary keyThe short code; the lookup key for every redirect
long_urlTEXTOriginal URL
created_atTIMESTAMPFor auditing and cleanup
expires_atTIMESTAMP, nullableOptional expiry
user_idBIGINT, nullableOwner, if accounts exist

The access pattern is a single key lookup by code, with no joins. That fits a key-value or wide-column store well, but a relational database with a primary-key index also handles it comfortably at this scale, and gives you easy uniqueness guarantees. Either is defensible; state the reason for your pick.

301 or 302?

A 301 (permanent) redirect may be cached by browsers, which reduces your load but means later clicks never reach your server, so you cannot count them or change the target. A 302 (temporary) redirect is not cached by default, so every click hits you. That costs more traffic but enables click analytics, expiry and editing. Pick based on whether analytics and mutability matter.

Step 4: Generating short codes

This is the heart of the problem. There are several approaches, each with trade-offs.

ApproachHowProsCons
Hash and truncateHash the long URL (e.g. MD5 or SHA-256), take the first 7 base62 charactersSame URL maps to same code; no counter neededCollisions: must check and retry; extra DB lookup on write
Random code + checkGenerate a random 7-character code, insert, retry on conflictSimple, codes are unguessableCollision probability rises as the table fills; needs unique constraint
Global counter + base62Increment a counter, encode the number in base62No collisions; short codes at firstCentral counter is a bottleneck and SPOF; sequential codes are guessable
Counter ranges per serverEach app server leases a block of IDs (e.g. 1 million) from a coordinator and encodes locallyNo per-request coordination; scales wellGaps if a server dies; still sequential unless scrambled
Pre-generated key serviceA separate service creates unique keys ahead of time and hands them outFast and collision-free at request timeAnother component to keep reliable

A pragmatic choice here is counter ranges plus base62. A small coordination service (or a database sequence) hands each app server a block of numbers; the server increments locally and converts each number to a base62 string. Writes need no cross-server locking, and uniqueness is guaranteed by construction. If guessability matters, scramble the number before encoding (for example, multiply by a large number coprime to the code space, modulo the space size) or mix in random bits. Here is a correct base62 encoder and decoder in TypeScript.

base62.ts
const ALPHABET =
  "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

// Requires a target of ES2020 or later for BigInt literals.
export function encodeBase62(n: bigint): string {
  if (n < 0n) throw new RangeError("n must be non-negative");
  if (n === 0n) return ALPHABET[0];

  let out = "";
  while (n > 0n) {
    out = ALPHABET[Number(n % 62n)] + out; // least significant digit first
    n = n / 62n;
  }
  return out;
}

export function decodeBase62(s: string): bigint {
  let n = 0n;
  for (const ch of s) {
    const digit = ALPHABET.indexOf(ch);
    if (digit === -1) throw new Error("invalid character: " + ch);
    n = n * 62n + BigInt(digit);
  }
  return n;
}

// Optionally pad to a minimum length so all codes look alike
export function toShortCode(id: bigint, minLength = 7): string {
  return encodeBase62(id).padStart(minLength, ALPHABET[0]);
}

Output

encodeBase62(0n)             -> "0"
encodeBase62(61n)            -> "z"
encodeBase62(62n)            -> "10"
encodeBase62(125n)           -> "21"
encodeBase62(1000000000n)    -> "15ftgG"
decodeBase62("15ftgG")       -> 1000000000n
toShortCode(125n)            -> "0000021"

Check one value by hand: 125 = 2 x 62 + 1, so the digits are 2 and 1, which map to "2" and "1", giving "21". Padding with leading zeros keeps codes a uniform length without changing the decoded number.

Step 5: Architecture, caching and scaling

High-level design
   Clients
      |
      v
 +-------------+
 |     CDN /   |  (optional edge caching)
 | Load balancer|
 +-------------+
    |        |
    v        v
 +-------+ +-------+
 | App 1 | | App 2 |   stateless
 +-------+ +-------+
    |   |         \
    |   v          v
    | +-------+  +-------------+
    | | Cache |  | ID / range  |
    | | Redis |  | coordinator |
    | +-------+  +-------------+
    v (miss)
 +------------------+     +-------+
 | Database (shards |     | Queue |--> analytics
 | by hash(code))   |     +-------+
 +------------------+
  • Redirect path (hot): load balancer, app server, cache lookup by code, database on a miss, then fill the cache and return the redirect. Use cache-aside with an LRU policy and a TTL.
  • Cache sizing (illustrative): if the hottest 100M links at 500 bytes each are cached, that is about 50 GB, which fits across a few cache nodes. Real link traffic is heavily skewed, so a modest cache can absorb most reads.
  • Stateless app tier: scale out behind the load balancer, as in Scalability and Load Balancing.
  • Database: reads dominate, so add read replicas. At around 3 TB with a few thousand reads per second, a single well-indexed primary plus replicas may suffice at first; shard by hash(code) when size or write rate demands it, as discussed in the databases lesson.
  • Analytics off the hot path: publish a click event to a queue and let workers aggregate it, so redirects never wait for analytics writes.
  • Expiry: check expires_at on read (return 410) and delete expired rows in a background job.

Trade-offs and failure modes

  • Consistency: a newly created link should be readable immediately. If reads come from a lagging replica, a fresh link can briefly 404. Read from the leader for the first moments, or write through to the cache on creation. Per the CAP lesson, this is a read-your-writes need, while redirects of old links can tolerate staleness.
  • Custom aliases: they can collide with generated codes. Reserve a separate namespace, or check uniqueness on insert and return 409.
  • Abuse: short links hide destinations, so add rate limiting, malicious-URL checks and a way to disable a link.
  • Coordinator failure: servers hold leased ID ranges, so a brief coordinator outage does not stop writes. A crashed server wastes the rest of its range, which is acceptable given the huge code space.
  • Deleting and reusing codes: avoid reusing codes. A reused code can send people who saved the old link to the wrong place.

Common mistakes

  • Starting with the database and hashing before estimating. Symptom: you cannot say how long the code must be, or whether a cache is needed at all.
  • Using an auto-increment ID directly. Symptom: codes are guessable and the single database becomes the ID bottleneck once you scale out.
  • Truncating a hash without collision handling. Symptom: two different URLs share one code, and users are redirected to someone else's page.
  • Ignoring the read-heavy shape. Putting effort into write scaling while redirects hit the database every time.
  • Writing analytics in the redirect request. Symptom: redirect latency rises and outages in analytics break the core feature.

How this shows up in interviews and real projects

This question tests structure more than cleverness: do you clarify scope, estimate, justify the ID strategy and talk about caching and failure? Practise saying each step aloud with timing, and use the system design interview prep guide as a checklist. For a larger loop, see how to crack FAANG interviews. The same shape (unique IDs, a read-heavy cache, a partitioned store) reappears in pastebins, file sharing, invite codes and ticketing systems.

Try it yourself

Extend the design with click analytics: count clicks per short link per day and show them to the link owner. Describe how the click event reaches the analytics store, what you would store, and what happens to redirects if the analytics system is down. Also write a function that converts a numeric ID into a short code of at least 6 characters using the base62 encoder above.

Show a hint

Publish a click event to a queue from the app server (fire and forget), and let workers aggregate counts by (code, day). Redirects must not depend on that path. For the code, call encodeBase62 and padStart to length 6.

Key takeaways

  • Requirements and estimation show that a URL shortener is read-heavy with small writes, so caching and replicas matter most.
  • A 7-character base62 code gives about 3.5 trillion combinations, plenty for billions of links.
  • Counter ranges plus base62 give collision-free codes without a per-request bottleneck; scramble or randomise if codes must be hard to guess.
  • Use 302 redirects if you need analytics or edits, and 301 when you prefer browser caching and lower load.
  • Keep analytics off the redirect path and design for failure of the cache, coordinator and replicas.

Test yourself

A trick from this lesson's ride: Compare reads to writes before choosing what to scale. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

How does a URL shortener generate unique short codes?

Common approaches are hashing the long URL and truncating it, generating random codes with a uniqueness check, or using an incrementing counter encoded in base62. Large systems often lease ranges of counter values to each server so codes are unique without coordinating on every request.

How many characters does a short URL need?

It depends on how many links you must support. With base62 (digits plus upper and lower case letters), 6 characters give about 56.8 billion combinations and 7 give about 3.5 trillion, so 7 characters is comfortable for billions of links with room to spare.

Should a URL shortener use a 301 or 302 redirect?

A 301 is permanent and browsers may cache it, which lowers server load but hides repeat clicks and makes changes harder. A 302 is temporary and not cached by default, so every click reaches your servers, which enables analytics, expiry and editing at the cost of more traffic.