SYSTEM DESIGN:Lesson 7: How Short Links Actually Work

Mastering lesson 7: how short links actually work concepts and implementation.

The WhatsApp link problem

Someone drops a product link in a family WhatsApp group. It is 187 characters long — tracking params, affiliate tags, the works. Half the message is URL. Nobody clicks it because it looks suspicious.

bit.ly, TinyURL, and your own schoolabe.com/x/K7mPq exist for one job: turn a long URL into a short code that redirects to the original. Simple idea. The design decisions start the moment you ask "how do I generate that code?"

This chapter covers the core of a URL shortener — requirements, ID generation, collision handling, and API shape. No CDN, no Redis cache, no million-QPS math. That comes next.

Nobody clicks a link that looks like a legal document

Nobody clicks a link that looks like a legal document

Your aunt did not click it. Your aunt will never click it. Shorten the link.

Requirements — functional and non-functional

Functional

  • Shorten — given a long URL, return a short code (e.g., K7mPq)
  • Redirect — given a short code, HTTP 301/302 to the original URL
  • Optional: custom aliases (schoolabe.com/x/my-resume)
  • Optional: expiration, click analytics, password protection

Non-functional (state these in interviews)

  • Low latency on redirect — users expect < 100 ms; this is the hot path
  • High availability on redirect — a broken short link is worse than a slow one
  • Uniqueness — two different long URLs must not get the same short code
  • Scale hint: read-heavy (100:1 redirect-to-create ratio is typical)

Clarify with the interviewer: do we need user accounts? Custom aliases? Analytics? Scope creep kills you in 45 minutes. I usually commit to shorten + redirect + basic click count, skip auth unless asked.

Clarifying with the interviewer (say this out loud)

Do not draw boxes until you have answers. A script that works in most rooms:

You: "Can you walk me through an example? Long URL in, short link out,
     click short link, browser lands on the original page?"
     -> Interviewer: "Yes, like bit.ly."

You: "What scale are we designing for? New links per day, and how
     many redirects compared to creates?"
     -> Interviewer: "100 million new URLs per day, reads dominate."

You: "How short should the code be? Alphanumeric only?"
     -> Interviewer: "As short as practical — letters and numbers."

You: "Can users delete or edit links, or is create-once enough?"
     -> Interviewer: "Keep it simple — no delete for now."

You: "I will cover shorten + redirect, 7-character codes, high
     availability on redirect. I will mention analytics and rate
     limiting if we have time."

That five-minute exchange sets scope. You are not guessing requirements — you are negotiating them. Interviewers notice.

Napkin math before you design (state these numbers)

Assume the interviewer gives you 100 million new URLs per day and a 100:1 read-to-write ratio (redirects vs creates). Round aggressively — within 2x is fine.

Writes (create short link):

100M / day / 86,400 sec ~ 1,200 writes/sec average
Peak (3x lunch + evening): ~3,600 writes/sec

A single Postgres primary handles that. Writes are not the scary part.

Reads (redirect):

1,200 x 100 = 120,000 reads/sec average
Peak (3x): ~360,000 reads/sec
Viral IPL link (10x on one code): CDN + Redis absorb most of this

Storage (10-year horizon, optional but impressive):

100M/day x 365 x 10 ~ 365 billion rows
~600 bytes per row (code + URL + metadata)
365B x 600 B ~ 220 TB raw (before replicas)

You do not need a calculator — you need the shape: writes are modest, reads are huge, disk grows linearly with links created. That shape drives cache and CDN in Lesson 8.

High-level flow

URL shortening and redirect flow

URL shortening and redirect flow

Two paths:

Create (write path): Client POSTs long URL → server generates short code → stores mapping in DB → returns short URL.

Redirect (read path): Browser GETs /x/K7mPq → server looks up code → returns 301/302 to long URL.

Redirect is called 100× more often than create. Keep that ratio in your head — it drives every scaling decision in the next chapter.

How to generate the short code

Three approaches. Each has trade-offs interviewers expect you to compare.

Option 1: Hash the long URL (MD5 / SHA-256)

import hashlib
import base64

def short_code(long_url: str, length: int = 7) -> str:
    digest = hashlib.md5(long_url.encode()).digest()
    # base64 gives A-Z, a-z, 0-9, +, / — trim to alphanumeric
    b64 = base64.urlsafe_b64encode(digest).decode()
    return b64[:length]

Pros: deterministic — same URL always gets same code (dedup for free). No central counter.

Cons: collisions. MD5 truncated to 7 chars is not unique across billions of URLs. Two different URLs can hash to the same prefix.

Fix: on collision, append a salt and re-hash, or fall back to counter. Pure hash-only is risky at scale.

I would not lead with hash in an interview unless dedup is a stated requirement ("do not create duplicate short links for the same URL").

Option 2: Auto-increment counter

-- Single row counter, incremented atomically
UPDATE counters SET value = value + 1 WHERE name = 'url_id' RETURNING value;
-- id = 912847263 → encode to base62 → "kF3xP"

Pros: guaranteed unique IDs, simple logic, easy to reason about.

Cons: single counter is a write bottleneck at extreme scale; IDs are predictable (competitor can guess your next short code).

Predictable IDs matter if short links are private. schoolabe.com/x/100001, 100002 — someone can scrape your sequential links. Encode the counter in base62 to obscure length, but order is still guessable.

Option 3: Base62 encode a unique ID (recommended default)

Take a unique numeric ID (from counter, Snowflake, UUID stripped to int) and encode in base62:

Base62 encode example: numeric ID to 7-character short code

Base62 encode example: numeric ID to 7-character short code

BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

def encode_base62(num: int) -> str:
    if num == 0: return BASE62[0]
    chars = []
    while num > 0:
        chars.append(BASE62[num % 62])
        num //= 62
    return "".join(reversed(chars))

# 912847263 → "kF3xP" (example)

Why base62? Uses [A-Za-z0-9] — URL-safe, no encoding needed. Base64 has + and / which need escaping in URLs.

Capacity math:

Code lengthBase62 combinations
6 chars62^6 ≈ 56 billion
7 chars62^7 ≈ 3.5 trillion
8 chars62^8 ≈ 218 trillion

7 characters is the sweet spot — short enough for WhatsApp, long enough for any realistic URL count. bit.ly uses 7. TinyURL uses 6–7.

My interview pick: auto-increment ID (or Snowflake at scale) + base62 encode. Clear, collision-free, easy to explain in 2 minutes.

The six-step shorten flow (counter + base62)

This is the write path interviewers want you to narrate. Not "we hash it" — a concrete sequence you can draw in 90 seconds.

Six-step URL shortening flow

Six-step URL shortening flow

  1. Client POSTs the long URL.
  2. Dedup check — if this exact long URL was shortened before, return the existing short code (product choice; analytics products often create a new code per user instead).
  3. Generate unique ID — counter nextval() on one DB, or Snowflake/UUID-based ID at scale (Lesson 14).
  4. Base62-encode the ID to a 7-character code.
  5. Insert (id, short_code, long_url); unique index on short_code.
  6. Return 201 with the full short URL.
def shorten(long_url: str) -> str:
    existing = db.find_by_long_url(long_url)
    if existing:
        return existing.short_url
    new_id = id_generator.next()
    code = encode_base62(new_id)
    db.insert(id=new_id, short_code=code, long_url=long_url)
    return f"https://schoolabe.com/x/{code}"

Collision handling

With counter + base62, collisions do not happen — IDs are unique by construction.

With hash-based generation, you need a strategy:

def shorten_with_hash(long_url: str) -> str:
    for attempt in range(5):
        salt = "" if attempt == 0 else str(attempt)
        code = short_code(long_url + salt)
        if not db.exists(code):
            db.insert(code, long_url)
            return code
    raise Exception("Could not generate unique code")

Also handle dedup: if the same long URL is submitted twice, do you return the existing short code or create a new one? Product decision. Analytics-heavy products create new codes per user; dedup-friendly products return existing.

Bloom filter (hash-based paths only)

If you use truncated hash codes instead of counters, every new code might collide. Checking Postgres SELECT 1 FROM urls WHERE short_code = ? on every attempt is slow at high create volume.

A Bloom filter is a compact in-memory structure that answers "might this short code exist?" with possible false positives but no false negatives. Flow:

Bloom filter decision flow for hash-based short codes

Bloom filter decision flow for hash-based short codes

  1. Hash candidate code → Bloom filter says definitely not in DB → insert without query.
  2. Bloom filter says maybe exists → confirm with DB; on collision, re-hash with salt and retry.

With counter + base62 you skip Bloom filters entirely — IDs are unique by construction. Mention Bloom filters only if the interviewer pushes on hash-based shortening.

Database schema (minimal)

CREATE TABLE urls (
  id          BIGSERIAL PRIMARY KEY,
  short_code  VARCHAR(8) NOT NULL UNIQUE,
  long_url    TEXT NOT NULL,
  created_at  TIMESTAMP DEFAULT NOW(),
  expires_at  TIMESTAMP NULL,
  click_count BIGINT DEFAULT 0
);

CREATE UNIQUE INDEX idx_short_code ON urls(short_code);

Redirect path queries by short_code — that index is non-negotiable. long_url index only if you dedup on lookup.

For a single-server MVP, Postgres handles this fine. 10,000 redirects/sec is where you start thinking about cache — next chapter.

API design

Create short URL

POST /api/v1/urls
Content-Type: application/json

{ "long_url": "https://amazon.in/dp/B0CX12345?ref=affiliate_very_long_tag" }
HTTP/1.1 201 Created

{
  "short_url": "https://schoolabe.com/x/kF3xP",
  "short_code": "kF3xP",
  "long_url": "https://amazon.in/dp/B0CX12345?ref=affiliate_very_long_tag",
  "created_at": "2026-08-28T10:30:00Z"
}

Redirect

GET /x/kF3xP
HTTP/1.1 301 Moved Permanently
Location: https://amazon.in/dp/B0CX12345?ref=affiliate_very_long_tag

301 vs 302:

  • 301 Permanent — browsers and CDNs cache the redirect. Good for stable links, reduces server load.
  • 302 Temporary — no caching. Use when URLs expire or you need accurate click tracking through your server.

Most shorteners use 301 for public links and 302 when analytics must count every click. Mention both; pick based on requirements.

Error cases

  • 404 — short code not found
  • 410 Gone — link expired
  • 400 — invalid long URL (malformed, not http/https)

Validate long URLs server-side. Do not redirect to javascript:alert(1) — that is an open redirect vulnerability and a security interview trap.

Custom aliases

Product teams always ask for branded links: schoolabe.com/x/rohit-resume instead of kF3xP. Implementation:

POST /api/v1/urls
{ "long_url": "https://...", "custom_alias": "rohit-resume" }
  • Validate alias: [a-zA-Z0-9-_], length 3–20, no reserved words
  • Check uniqueness before insert — race condition on popular words ("sale", "offer")
  • Store in same urls table; lookup path identical to generated codes

Custom aliases are a write-path feature only. Redirect path does not care whether the code was generated or chosen.

Analytics without slowing redirects

If you need click counts, do not UPDATE urls SET click_count = click_count + 1 synchronously on every redirect. That turns a read-heavy system write-heavy.

Better approach:

  1. Return 301 immediately
  2. Fire-and-forget an async event (click_events queue or Kafka topic)
  3. Worker aggregates counts every minute into a dashboard table

Users get fast redirects. Product gets hourly analytics. Exact real-time counts are rarely worth the latency cost.

Security checks (do not skip)

  • Blocklist — phishing sites, malware domains
  • Allowlist scheme — only http:// and https://
  • Rate limit — prevent someone from filling your DB with garbage URLs (ties to rate limiter chapters)
  • Custom alias validation — reserved words (admin, api, login)

What a single-server version looks like

For 100 users and a side project:

  • One Node/Go/Python server
  • Postgres with the schema above
  • Counter + base62 for codes
  • 301 redirects
  • Deploy on Railway or a single EC2 t3.small

Total cost: under ₹2,000/month. Handles thousands of redirects per day easily.

Preview: why the next chapter exists

Everything above runs on one server. Postgres handles ~5,000 simple indexed lookups per second on decent hardware. Redis is not in the picture yet.

Problems that appear past that point:

  • Viral link → single row hot in DB → connection pool exhaustion
  • Global users → redirect latency from distant origin
  • 100:1 read ratio → you are paying DB cost for traffic that never needed a write system

The fix is not a fancier hash function. It is caching, CDN, and read replicas — the standard toolkit from the scaling chapters.

It works for 100 users. IPL final day hits. Continue here: Scaling a URL Shortener to Millions of Redirects.