System DesignIntermediate10 min readPart 12 of 14

URL Shortener

A URL shortener turns long URLs into short, shareable links, like bit.ly, tinyurl or t.co. It is one of the most common system design interview questions because it tests hashing, database design and API design in one small problem.

Can you explain url shortener out loud?

I'm Alex. Read this, then I'll interview you on a design problem and push on the parts you skip.

Clarify the requirementsCore idea

Always start by agreeing the scope with your interviewer. It shows you think before you design.

Functional

Turn a long URL into a short one (https://short.ly/abc123). Redirect a short URL to the original. Support custom aliases (short.ly/my-brand). Expire URLs after a configurable time (default 5 years).

Non-functional

100M new URLs a day and 10 reads per write (1B redirects a day). Redirects under 100ms at p99. 99.99% uptime: redirects must never fail. Once created, a URL must never be lost.

Back-of-the-envelope estimates

Quick mathsText
Writes:  100M / day  ≈ 1,160 writes/sec
Reads:   1B / day    ≈ 11,600 reads/sec
Record:  ~500 bytes (shortCode, longUrl, userId, createdAt, expiresAt)
Storage: 100M × 365 × 5 × 500 B ≈ 91 TB over 5 years

API designDesign

Keep it simple. Two endpoints cover the core features:

EndpointsText
POST /api/shorten
  Request:  { "long_url": "https://...", "custom_alias": "my-link", "expiry": "2027-01-01" }
  Response: { "short_url": "https://short.ly/abc123", "expires_at": "2027-01-01" }

GET /:shortCode
  Response: 302 redirect to the original URL
StatusBrowser behaviourUse when
301 Moved PermanentlyCaches the redirect; later clicks skip your serversYou want the least server load
302 FoundAsks your server every timeYou need to count every click (most production systems)

High-level architectureDesign

%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","actorBkg":"#e7f5ff","actorBorder":"#1e1e1e","noteBkgColor":"#fff9db","noteBorderColor":"#1e1e1e"}}}%%
graph LR
  Client["Client"] -->|"POST /api/shorten"| LB["Load balancer"]
  Client -->|"GET /:code"| LB
  LB --> API["API servers<br/>(stateless)"]
  API -->|"check first"| Cache["Redis cache"]
  API -->|"read / write"| DB[("DynamoDB")]
  API -->|"click event"| Q["Kafka"]
  Q --> An[("Analytics DB")]
  class Client blue
  class LB,API orange
  class Cache red
  class DB violet
  class Q yellow
  class An green
classDef orange fill:#ffd8a8,stroke:#1e1e1e,color:#1e1e1e
classDef blue fill:#a5d8ff,stroke:#1e1e1e,color:#1e1e1e
classDef green fill:#b2f2bb,stroke:#1e1e1e,color:#1e1e1e
classDef red fill:#ffc9c9,stroke:#1e1e1e,color:#1e1e1e
classDef violet fill:#d0bfff,stroke:#1e1e1e,color:#1e1e1e
classDef yellow fill:#ffec99,stroke:#1e1e1e,color:#1e1e1e
classDef grey fill:#e9ecef,stroke:#1e1e1e,color:#1e1e1e
DiagramWrites store a mapping; redirects read it, mostly from cache, and emit a click event.

Load balancer

Spreads requests across API servers. An L7 balancer allows URL-based routing.

API servers

Stateless: create, validate and redirect. Add more to scale.

Redis cache

Holds hot URLs. Even a 20% hit rate saves ~200M database reads a day.

DynamoDB

Main storage: key-value lookups by shortCode, huge write throughput.

Kafka

Keeps analytics off the redirect path: each redirect emits an event asynchronously.

Request flowsHow it works

Creating a short URL

%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","actorBkg":"#e7f5ff","actorBorder":"#1e1e1e","noteBkgColor":"#fff9db","noteBorderColor":"#1e1e1e"}}}%%
sequenceDiagram
  participant C as Client
  participant API as API server
  participant DB as DynamoDB
  participant Cache as Redis
  C->>API: POST /api/shorten {long_url}
  API->>DB: does long_url already exist?
  DB-->>API: existing shortCode, or none
  alt already exists
    API-->>C: existing short_url
  else new URL
    API->>API: new ID → base62 shortCode
    API->>DB: store {shortCode, longUrl, userId, expiresAt}
    API->>Cache: SET shortCode → longUrl
    API-->>C: short_url
  end
SequenceIf the long URL already exists, return the existing code instead of making a new one.

Redirecting

%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","actorBkg":"#e7f5ff","actorBorder":"#1e1e1e","noteBkgColor":"#fff9db","noteBorderColor":"#1e1e1e"}}}%%
sequenceDiagram
  participant C as Client
  participant API as API server
  participant Cache as Redis
  participant DB as DynamoDB
  participant Q as Kafka
  C->>API: GET /abc123
  API->>Cache: GET abc123
  alt cache hit
    Cache-->>API: longUrl
  else cache miss
    API->>DB: query shortCode = abc123
    DB-->>API: {longUrl, expiresAt}
    API->>Cache: SET abc123 → longUrl
  end
  API->>Q: click event {shortCode, time, geo}
  API-->>C: 302 redirect to longUrl
SequenceThe hot path: cache first, database on a miss, analytics fired and forgotten.

Deep dive: generating the short codePattern

This is the core algorithm. Get a unique integer from a distributed ID generator (see ID generation), then write it in base62: the 62 characters a–z, A–Z and 0–9.

Base62 encodingTypeScript
const BASE62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

function generateShortCode(id: number): string {
  if (id === 0) return BASE62[0];
  let code = "";
  while (id > 0) {
    code = BASE62[id % 62] + code;
    id = Math.floor(id / 62);
  }
  return code;
}
// 0             → "a"
// 61            → "9"
// 62            → "ba"
// 3,500,000,000 → "dY1N24"
ApproachLengthCollisionsVerdict
Unique ID → base627 chars = 62⁷ ≈ 3.5 trillion codesNone: each ID maps to one code✓ Simple, predictable, reversible
MD5 / SHA hash32+ chars, must be cut shortCutting it short causes collisions✗ Slower and overkill
Random codeAny lengthPossible: must check the DB first~ Extra latency and complexity

Deep dive: database schemaDesign

DynamoDB table: urlsText
Partition key: shortCode (String)

shortCode   String   "abc123"
longUrl     String   "https://very-long-url.com/..."
userId      String   who created it
createdAt   Number   epoch ms
expiresAt   Number   epoch ms (null = never)
clickCount  Number   denormalised counter
Fast key-value lookupsO(1) by partition key: exactly shortCode → longUrl.
Huge write throughput1,160 writes/sec is easy with on-demand capacity.
Scales by itselfPartitions grow automatically; no sharding code to write.
Built-in TTLExpired items are deleted automatically, so no cron job.

Deep dive: cachingScale

Redis settingsText
Key:        shortCode ("abc123")
Value:      longUrl   ("https://...")
TTL:        match the URL's expiry
Eviction:   LRU (least recently used)
Max memory: 50 GB  (~100M entries at 500 B)

ScalingScale

Writes

DynamoDB auto-scaling absorbs spikes (a short link in a viral post). Snowflake IDs or a Redis counter keep ID assignment off the database.

Reads

A Redis cluster splits the keyspace across nodes. DynamoDB DAX adds an in-memory layer for microsecond reads.

Geography

API servers and Redis in several regions, DNS routing (Route 53) to the nearest one, and DynamoDB global tables shared by all.

Common interview mistakesPitfalls

MD5/SHA for codesBase62 of a unique ID is simpler, faster and collision-free.
Duplicate long URLsIf the same URL is submitted twice, return the existing code. Look it up by longUrl first.
No rate limitingOne user could burn through codes or storage. Limit to about 100 URLs/min per user.
Cron jobs for expiryUse DynamoDB TTL; a cron job does not scale.
Analytics on the redirect pathEmit a Kafka event instead of writing analytics synchronously.

Decisions at a glanceSummary

DecisionOption AOption BRecommendation
Redirect type301 (cached by browser)302 (every hit reaches you)302 for analytics, 301 for performance
ID generationUnique ID + base62Random + collision checkUnique ID + base62
DatabaseDynamoDB (NoSQL)PostgreSQL (SQL)DynamoDB for scale
AnalyticsSynchronousAsync via KafkaAsync via Kafka
ExpiryDynamoDB TTLCron cleanupDynamoDB TTL

Frequently asked questionsFAQ

How do you generate a unique short URL?

Generate a unique 64-bit ID, then encode it as base62 to produce a compact 7-character slug. The base62 encoding turns a large numeric space into short strings, and collisions are impossible as long as the underlying ID generator, such as a snowflake ID, is collision-free.

How do you handle redirect performance at scale?

Store the slug-to-long-URL mapping in a database with the slug as primary key, and front it with a read-heavy cache like Redis to absorb the hottest reads. Add load balancers and geolocated DNS so redirects answer in milliseconds for users around the world.

What happens if a custom short URL is already taken?

Check for the slug at creation time. For custom URLs, return a clear error so the user can choose another. For generated slugs, collisions are practically impossible with 62^7 combinations, and a database retry on the rare conflict is enough.

Now explain url shortener to me out loud

I'll give you a design problem and push on the trade-offs, like a real interviewer.

Practice with Alex

Keep readingKeep reading

Ready to practice?

Reading is step one. Saying it out loud is step two.

Start a mock interview with Alex, get pushed on the parts you skip, and see your hiring signal right after.