Skip to content
Commitwise
Menu
System Design

Design a URL Shortener

The classic system design interview question, step by step, from requirements and estimates to short codes, caching, redirects and scaling, with code in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

“Design a URL shortener like bit.ly” is the most common first system design question, because it’s small enough to finish in 45 minutes and deep enough to show how you think. The service turns https://shop.example.com/products/winter-sale?campaign=newsletter-11 into https://sho.rt/3fA9xQ and sends everyone who opens the short link to the long one.

New to some of these? Open what you need, or skip ahead.

Basics:What are clients and servers?

A server is a computer (usually many of them) that runs your backend: it stores data and answers requests. A client is whatever sends the requests: your mobile app, a website, another server.

Big systems put many identical servers behind one address, with a load balancer in front that spreads requests between them. Data lives in a database, and frequently used data is often kept in a fast cache (in memory) so the database isn’t asked every time.

Basics:What are databases, tables and SQL?

A database stores an app’s data so it survives restarts and can be searched quickly. A relational database (PostgreSQL, MySQL) keeps data in tables: rows (one per book, user or order) and columns (id, title, year).

You talk to it with SQL: SELECT title FROM books WHERE year > 2000 reads, INSERT, UPDATE and DELETE change data. Rows refer to other tables through foreign keys (book.author_id points to a row in author), and indexes make lookups by a column fast.

Basics:How do back-of-the-envelope estimates work?

System design interviews expect rough numbers, not exact ones. A few facts make them quick:

  • A day has about 86,400 seconds; round to 100,000 for easy division. A month is about 2.6 million seconds.
  • 1 KB ≈ a thousand bytes, 1 MB ≈ a million, 1 GB ≈ a billion, 1 TB ≈ a trillion.
  • “Per day ÷ 100,000” gives “per second”; multiply by 2 to 5 for the peak.

The point is the shape of the answer (is it read-heavy? does it fit on one machine?), not the third digit.

Basics:What is an API?

An API (application programming interface) is the set of operations a system offers to other programs. In app development it usually means a web API: a server with URLs your app calls to read and change data, like GET /api/products to list products.

The API is a contract: the app relies on its URLs, fields and status codes. Changing them carelessly breaks apps that are already installed on people’s phones.

Basics:What is a cache?

A cache keeps a copy of data somewhere faster to reach than its original source. Instead of asking the database for the same popular product 1,000 times a second, the server keeps it in memory (or in a cache server like Redis) and answers from there.

Two questions come with every cache: how long may a copy be used before it might be out of date (its time to live, or TTL), and what happens when the original changes (the copy must be removed or updated).

Basics:How do HTTP requests work?

Apps and servers talk with HTTP. A request has a method that says what to do (GET to read, POST to create, PUT to replace, DELETE to remove), a URL (https://api.example.com/books/42), headers (extra information, like who you are) and sometimes a body (the data, usually JSON).

The server sends back a response with a status code that says how it went (200 OK, 201 created, 404 not found, 500 server error), headers, and usually a body. Codes starting with 4 mean the client asked for something wrong; codes starting with 5 mean the server failed.

Basics:What is hashing?

A hash is a number computed from a value, like a fingerprint: the same value always gives the same hash, and different values usually give different ones. "Ana".hashCode() (Java, Kotlin) or Swift’s Hashable produce it; JavaScript engines hash keys internally for Map and Set.

Hash maps and hash sets use the hash to decide where to store each key, so they can jump straight to it later instead of searching: that’s why looking up a key takes about one step. Two different keys can occasionally have the same hash (a collision); the map handles it, just a little slower.

Part Not started

Step 1: Ask before you draw

Don’t start drawing boxes. Interviewers leave the question vague on purpose, to see whether you ask what matters. Spend the first few minutes agreeing on what you’re building:

  • What must it do? Create a short link from a long URL. Redirect a short link to the long one. Maybe: custom aliases (sho.rt/winter-sale), links that expire, click counts.
  • How big? How many new links per day? How many clicks? The numbers decide the design.
  • What matters most? Redirects must be fast and always work. Creating links can be a bit slower.

Write the answers down where the interviewer can see them. Every decision later points back to this list.

Pin down functional scope (create, redirect, custom alias, expiry, analytics, edit or delete?) and the non-functional shape: read/write ratio, latency target for redirects (p99 under ~50 ms server-side), availability (redirects are the product; creation can degrade), durability (a link must never point somewhere else), and abuse (it’s a phishing tool if you’re careless). Then state explicit non-goals so you can spend the time on the interesting parts.

For this walkthrough we’ll agree on: create and redirect, optional custom alias and expiry, click counts; 100 million new links per month, 100 reads for every write, links kept for 5 years.

Step 2: Estimate

WhatCalculationResult
Writes100M per month ÷ ~2.6M seconds~40 per second (peaks ~200)
Reads40 × 100~4,000 per second (peaks ~20,000)
Links stored100M × 12 months × 5 years6 billion
Storage6B × ~500 bytes (URL, code, dates, owner)~3 TB

These are rough on purpose. The point isn’t the exact number; it’s the shape: this system reads far more than it writes, so the design should make reads cheap (hello, cache), and 3 TB doesn’t fit comfortably on one small database forever.

The conclusions to say out loud: read-heavy, so cache redirects aggressively; the hot set is small (a few percent of links get most clicks), so a modest Redis cluster absorbs most reads; ~3 TB plus indexes and replicas is past one comfortable node, so plan the partition key now (the short code) even if you start on one; writes are trivial, so the hard parts are ID generation, cache behavior and abuse.

Step 3: The API

POST /api/links
{ "url": "https://shop.example.com/products/winter-sale?campaign=newsletter-11", "alias": null, "expiresAt": null }

→ 201 Created
{ "code": "3fA9xQ", "shortUrl": "https://sho.rt/3fA9xQ" }

GET /3fA9xQ
→ 302 Found
Location: https://shop.example.com/products/winter-sale?campaign=newsletter-11

How to talk through it in the interview

  1. Requirements (about 5 minutes): functional, scale, what matters most. Write them down.
  2. Estimates (about 3 minutes): reads, writes, storage. Name the conclusion: “read-heavy, so caching matters”.
  3. API (about 3 minutes): the two endpoints and their status codes.
  4. High-level design (about 10 minutes): draw the boxes and walk one request through them.
  5. Deep dives (the rest): short codes, caching, scaling the database. Let the interviewer pick, and always compare two options before choosing one.
Exerciseeasy

Estimate a photo-sharing app

Practice the estimate step on a new system. A photo-sharing app has 10 million daily active users. Each user uploads 2 photos a day on average and views 50. A stored photo (with its smaller preview sizes) takes about 2 MB. Estimate:

  1. Uploads per second
  2. Views per second
  3. New storage per day, and for 5 years

Round freely: there are about 100,000 seconds in a day (86,400, rounded up).

›

Start from the day, then divide: 10M users × 2 photos = 20M uploads a day. Now divide by the seconds in a day.

›

For storage, multiply uploads per day by the size of one photo, then by about 365 × 5 ≈ 1,800 days. Write the units next to each number (MB, TB, PB) so you don’t lose a factor of 1,000.

SolutionTry it yourself first
WhatCalculationResult
Uploads20M per day ÷ 100,000 s~200 per second (peaks ~1,000)
Views10M × 50 = 500M per day ÷ 100,000 s~5,000 per second (peaks ~25,000)
Storage per day20M × 2 MB40 TB
Storage for 5 years40 TB × 1,800 days~72 PB

The conclusions are what the interviewer wants to hear:

  • Views are 25× uploads, so it’s read-heavy: serve photos from a CDN, not from your servers.
  • 72 PB is not a database job. Photos go to object storage (like S3); the database only keeps metadata: who uploaded what, when, and where the file lives.
  • Peaks are about 5× the average (everyone posts on Saturday evening), so size for peaks, not averages.
  • Numbers: 40 writes/s, 4,000 reads/s (100:1, peaks ~5×), 6B links, ~3 TB over 5 years.
  • Say the conclusions: read-heavy, so cache redirects; a small hot set; past one comfortable node, so choose the partition key (the code) now. Name non-goals to save time for the hard parts.

Check this part

  1. What should you do in the first few minutes of a system design interview?

  2. The system has about 100 reads for every write. What does that suggest first?

  3. Writes average 40 per second. What do you size the system for?

  4. The estimate gives ~3 TB over 5 years. What design decision does that force early?

Agree on scope and scale before drawing anything, estimate reads, writes and storage, and say the conclusion: read-heavy, so make reads cheap. Then pin down the two endpoints.

Part Not started

Step 4: The big picture

URL shortener

HTTPSreadmiss / writelease rangeasyncClientbrowser or appLoad balancerAPI serversstatelessCacheRedisDatabasecode → URLID generatorranges of IDsClick eventsqueue

Tap a box to see what it does.

Follow one click through the picture:

  1. The browser asks the load balancer for /3fA9xQ, which passes it to any API server.
  2. The server checks the cache. Popular links are almost always there.
  3. If not, it reads the database and puts the answer in the cache for next time.
  4. It answers 302 with the long URL, and the browser goes there.
  5. Without waiting, it drops a “clicked” message in the queue for the statistics.

Follow one click through the system

Interviewers love “walk me through a request”, because it shows whether the boxes on your diagram really connect. Here’s what happens when someone taps https://sho.rt/3fA9xQ:

  1. DNS turns sho.rt into the address of your load balancer.
  2. The load balancer picks one of the app servers. They’re all the same, so any of them can answer.
  3. The app server reads the code 3fA9xQ from the path and asks the cache: “do you have 3fA9xQ?”
  4. Cache hit (the usual case): the cache returns the long URL in about a millisecond. Skip to step 6.
  5. Cache miss: the server looks the code up in the database, then stores the answer in the cache so the next click is fast. If the code doesn’t exist, it returns 404.
  6. The server answers 302 Found with a Location header, and the browser opens the long URL. Separately, it sends a “clicked” event to a queue for analytics, so counting never slows down the redirect.

Saying “cache hit” and “cache miss” out loud matters, because the next question is usually about numbers:

Predict the output

Peak traffic is 20,000 redirects per second, and 90% of them are found in the cache. How many reads per second reach the database?

Scaling reads

  • Cache: read-through, keyed by code, LRU, TTL capped by the link’s expiry. With a skewed click distribution, hit rates above 90% are typical. Cache negative results briefly too, or bots scanning random codes go straight to the database.
  • Hot keys: one viral link can overload a single cache shard. Replicate hot keys or keep a small in-process cache (a few seconds) on API servers.
  • Edge: for global latency, serve redirects from edge locations or a CDN worker with a regional cache, with the origin as fallback.

Storage and partitioning

The access pattern is a key-value lookup by code, so either a key-value store (DynamoDB, Cassandra) or a relational database with the code as primary key works. To scale out, shardShardingSplitting one big database into several smaller ones, each holding part of the data.Open in glossary → by a hash of the code (consistent hashing so adding nodes moves little data). Custom aliases need a uniqueness check across shards; routing by the alias itself keeps that a single-shard operation. Replicate for availability: redirects should survive losing a node or a zone.

Sharding doesn’t spread one key

Predict the output

Links are stored in 16 shards, partitioned by hash(code). One link goes viral at 50,000 clicks per second. How many shards serve it?

Owners can change where a link points. Here’s the first version of that endpoint:

Spot the bugEdited links that point to the old page

Tap the line with the bug.

suspend fun updateDestination(code: String, newUrl: String) {
    cache.set(code, newUrl, ttl = 1.days)
    db.updateUrl(code, newUrl)
}
void updateDestination(String code, String newUrl) {
    cache.set(code, newUrl, Duration.ofDays(1));
    db.updateUrl(code, newUrl);
}
async function updateDestination(code: string, newUrl: string) {
  await cache.set(code, newUrl, { ttlSeconds: 86_400 });
  await db.updateUrl(code, newUrl);
}

Deep dives

Deep diveConsistent hashingInterview favorite

With shard = hash(code) % N, adding one shard changes N, and almost every key moves to a different shard: a cache cluster loses nearly all its hits, a database must move nearly all its data.

Consistent hashing places both shards and keys on a ring of hash values; each key belongs to the next shard clockwise. Adding a shard takes over only the keys between it and its neighbor, about 1/N of them. Each physical shard gets many virtual nodes on the ring so the load evens out. Cassandra and many cache clients use it, Amazon’s Dynamo paper made it famous, and it’s one of the most frequently asked system design follow-ups.

  • Reads: read-through cache keyed by code, negative caching for unknown codes, single flight and jittered TTLs against stampedes, in-process or edge caches for hot keys.
  • Storage: key-value by code, shard by hash(code) with consistent hashing, replicate across zones. Sharding spreads many keys, never one hot key.
  • Edits: write the database, then delete the cache entry (plus a short TTL or versioning for the remaining race).
suspend fun redirect(code: String): ResponseEntity<Unit> {
    val url = cache.get(code) ?: singleFlight(code) {          // one database read per key
        val found = db.findUrl(code) ?: NOT_FOUND
        cache.set(code, found, ttl = if (found == NOT_FOUND) 1.minutes else 1.days)
        found
    }
    if (url == NOT_FOUND) return ResponseEntity.notFound().build()
    clicks.publish(ClickEvent(code))                           // async, off the hot path
    return ResponseEntity.status(HttpStatus.FOUND).location(URI(url)).build()
}
ResponseEntity<Void> redirect(String code) {
    String url = cache.get(code);
    if (url == null) {
        url = singleFlight.load(code, () -> {                  // one database read per key
            String found = db.findUrl(code).orElse(NOT_FOUND);
            cache.set(code, found, found.equals(NOT_FOUND) ? Duration.ofMinutes(1) : Duration.ofDays(1));
            return found;
        });
    }
    if (url.equals(NOT_FOUND)) return ResponseEntity.notFound().build();
    clicks.publish(new ClickEvent(code));                      // async, off the hot path
    return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(url)).build();
}
app.get("/:code", async (req, res) => {
  const code = req.params.code;
  const url = (await cache.get(code)) ?? (await singleFlight(code, async () => {   // one database read per key
    const found = (await db.findUrl(code)) ?? NOT_FOUND;
    await cache.set(code, found, { ttlSeconds: found === NOT_FOUND ? 60 : 86_400 });
    return found;
  }));
  if (url === NOT_FOUND) return res.sendStatus(404);
  clicks.publish({ code });                                  // async, off the hot path
  res.redirect(302, url);
});

Check this part

  1. Peak traffic is 20,000 redirects per second, and 90% are found in the cache. How many reads per second reach the database?

  2. Why are the API servers "stateless"?

  3. A viral link's cache entry expires, and 10,000 requests miss the cache in the same second. What is this, and what helps?

  4. An owner edits a link. Why "update the database, then delete the cache entry" rather than setting the cache first?

Stateless API servers behind a load balancer, a cache in front of a key-value database, an ID generator and a click queue. Only cache misses reach the database, sharding doesn't help one hot key, and edits should delete the cache entry, not set it.

Part Not started

Step 5: The short code

A code made of 0-9, a-z and A-Z has 62 possible characters per position, so it’s called base62Base62Writing numbers with the 62 characters 0-9, a-z and A-Z, which makes short, URL-safe codes.Open in glossary →. Seven characters give 62⁷ ≈ 3.5 trillion codes, far more than the 6 billion we need.

The simplest reliable approach: give every link a unique number (an ID), then write that number in base62.

let alphabet = Array("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")

func base62(_ id: UInt64) -> String {
    if id == 0 { return "0" }
    var n = id
    var out: [Character] = []
    while n > 0 {
        out.append(alphabet[Int(n % 62)])
        n /= 62
    }
    return String(out.reversed())
}
const val ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

fun base62(id: Long): String {
    if (id == 0L) return "0"
    var n = id
    val out = StringBuilder()
    while (n > 0) {
        out.append(ALPHABET[(n % 62).toInt()])
        n /= 62
    }
    return out.reverse().toString()
}
static final String ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

static String base62(long id) {
    if (id == 0) return "0";
    StringBuilder out = new StringBuilder();
    while (id > 0) {
        out.append(ALPHABET.charAt((int) (id % 62)));
        id /= 62;
    }
    return out.reverse().toString();
}
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

function base62(id: number): string {          // exact for IDs up to 2^53
  if (id === 0) return "0";
  let n = id;
  let out = "";
  while (n > 0) {
    out = ALPHABET[n % 62] + out;
    n = Math.floor(n / 62);
  }
  return out;
}

Predict the output

What is base62(125)?

print(base62(125))
println(base62(125))
System.out.println(base62(125));
console.log(base62(125));

There are three common ways to produce the codes:

ApproachHowGoodWatch out
Counter + base62Unique ID per link, encodedShort, no collisionsCodes are guessable; needs a distributed counter
Hash of the URLFirst 7 chars of base62(SHA-256(url))Same URL gets same codeCollisions: check and retry
Random7 random base62 charsNot guessableCheck the DB for collisions on insert

Step 6: 301 or 302?

  • 301 Moved Permanently: browsers and proxies may cache it and skip your server next time. Less traffic, but you lose click counts, and you can never change or expire the link for those users.
  • 302 Found (or 307): every click comes to you. Slightly more load, and you keep analytics and control.

Most shorteners choose 302, and say why.

Practice: random codes

Your teammate suggests skipping the ID counter and using random 7-character codes instead. Here’s their first version:

Spot the bugRandom short codes

Tap the line with the bug.

func shorten(_ longURL: String) async throws -> String {
    let code = randomBase62(length: 7)
    try await db.insert(code: code, url: longURL)
    return "https://sho.rt/" + code
}
suspend fun shorten(longUrl: String): String {
    val code = randomBase62(length = 7)
    db.insert(code = code, url = longUrl)
    return "https://sho.rt/" + code
}
String shorten(String longUrl) {
    String code = randomBase62(7);
    db.insert(code, longUrl);
    return "https://sho.rt/" + code;
}
async function shorten(longUrl: string): Promise<string> {
  const code = randomBase62(7);
  await db.insert({ code, url: longUrl });
  return "https://sho.rt/" + code;
}

ID generation at scale

  • Range leasing. A small coordinator (a DB row with UPDATE … SET next = next + 10000 RETURNING, or ZooKeeper/etcd) leases blocks of IDs to API servers. Each server encodes locally; a crash wastes the rest of its block, which is fine. No per-request coordination, no collisions.
  • Snowflake-style IDs (timestamp + machine + sequence) avoid the coordinator but produce 64-bit IDs, which means 11-character codes.
  • Guessability. Sequential codes let anyone enumerate links, a privacy problem when people shorten private documents. Permute the ID before encoding with a bijection over the code space (multiply by a constant coprime to 62⁷ modulo 62⁷, or a small format-preserving cipher): still unique, no longer sequential.

Abuse, expiry and analytics

  • Abuse: check new URLs against malware and phishing lists (for example Google Safe Browsing), rate-limit creation per IP and API key, and be ready to disable a code instantly (which is another reason for 302).
  • Expiry: check expiresAt on read (lazy) and delete in a background job; don’t scan 6 billion rows on the request path.
  • Analytics: publish click events to a queue on every redirect, fire-and-forget, and aggregate in a separate consumer. Analytics outages must never block redirects.
Exercisehard

Click analytics

Owners want a dashboard: clicks per link per day, split by country and device, updated within a minute. Peak traffic is 20,000 redirects per second, and the redirect itself must stay under 10 ms at p99.

Design the analytics pipeline: what happens on each click, where data is stored, how the dashboard reads it, and how you handle bots, duplicates and a broken analytics system.

›

The redirect path can afford a fire-and-forget publish, nothing more. Everything else happens asynchronously, and the redirect must keep working when analytics is down.

›

Nobody needs individual clicks on a dashboard. Pre-aggregate per minute per (code, country, device) close to the stream, and store raw events cheaply for reprocessing.

SolutionTry it yourself first
StageChoiceWhy
On clickPublish {code, ts, ip-derived country, device, user-agent hash} to a local buffer, flushed in batches to Kafka; drop on overflowRedirect latency stays independent of analytics; losing a few clicks under failure is acceptable, a slow redirect isn’t
TransportKafka topic partitioned by codeOrdered per link, horizontally scalable; 20k events/s × ~200 bytes ≈ 4 MB/s, small for Kafka
ProcessingStream job (Flink, Kafka Streams) with 1-minute tumbling windows, keyed by (code, country, device)Turns 20k events/s into far fewer counter updates; handles late events with a grace period
Serving storeColumnar OLAP store (ClickHouse, Druid, BigQuery) with per-minute rows, rolled up to hours and daysFast “group by country for this link over 30 days” queries
Raw archiveRaw events in object storage (Parquet), 30–90 daysReprocess when the logic changes or a bug is found
DashboardReads aggregates only; caches finished daysPast days never change, so only today is queried live

Points that make the answer senior:

  • Failure isolation: the redirect never waits for Kafka. If the broker is down, the local buffer drops events and a metric alerts; clicks are lost, redirects are not.
  • Bots and duplicates: filter known bots by user agent and IP reputation in the stream job, and deduplicate rapid repeats from the same client within a short window. Report “clicks” and “unique visitors” separately and say how each is defined; unique counts at scale use HyperLogLog sketches.
  • Privacy: store the derived country and a salted hash, not raw IP addresses, and have a retention policy (GDPR).
  • Exactly once is expensive: at-least-once delivery plus idempotent window writes (upsert by window key) gives correct dashboard numbers without distributed transactions.
  • Hot links: one viral code concentrates in one Kafka partition. Pre-aggregate on the app servers (count per code per second locally, publish counts) if a single partition can’t keep up.
  • Codes: base62, 7 chars. IDs from leased ranges (or Snowflake-style), permuted before encoding so codes can’t be enumerated; random codes need a unique key and retry.
  • Redirect: 302 keeps analytics, expiry and the kill switch; browsers cache 301s beyond your control.
  • Async and abuse: click events through a queue off the redirect path; lazy expiry plus background cleanup; malware checks, rate limits.

Check this part

  1. How many different codes do 7 base62 characters give?

  2. Why do most URL shorteners redirect with 302 instead of 301?

  3. What is the main downside of encoding a simple counter as the short code, and the fix?

  4. A single database sequence hands out IDs for every new link. How do you remove it as a bottleneck?

Seven base62 characters give 3.5 trillion codes. Counters are short but guessable, hashes collide, random codes need a unique key and a retry. Redirect with 302 to keep control and analytics.

Key takeaways

  • Start with requirements and estimates; they decide the design.
  • A URL shortener is read-heavy: cache redirects, keep the database a simple key-value lookup by code.
  • Short codes: unique IDs written in base62; 7 characters are plenty. Lease ID ranges and permute them.
  • Redirect with 302, count clicks asynchronously, and plan for abuse and expiry.
  • Read path: read-through cache, negative caching for unknown codes, and stampede protection (single flight, jittered TTLs) for hot links.
  • IDs: leased ranges or Snowflake-style IDs, permuted before encoding so codes can’t be enumerated.
  • Storage: a key-value lookup by code, sharded by hash(code) and replicated across zones.
  • Clicks go through a queue, off the redirect path; abuse checks, rate limits and a kill switch are part of the design.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.