Post

Design a URL Shortener: The Gotchas Hiding Inside the Easiest System Design Question

The URL shortener question looks trivial, which is exactly why interviewers use it. Here is what they are really probing, the gotchas that separate senior candidates from junior ones, and a structure for an answer that holds up under follow-ups.

Design a URL Shortener: The Gotchas Hiding Inside the Easiest System Design Question

“Design a URL shortener” is the question everyone has read about, which is why so many candidates answer it badly. They recite a memorized solution, the interviewer asks one follow-up that was not in the article, and the answer falls apart. The question is not really about shortening URLs. It is a compact vehicle for testing whether you can estimate, choose between ID generation strategies, reason about a read-heavy workload, and know your HTTP semantics.

By the end of this post you will have a structure for the answer that survives follow-ups, and you will know which of the “obvious” choices are traps.

The Question

The direct forms:

  • “Design a URL shortener like bit.ly or TinyURL.”
  • “Design a service that takes a long URL and returns a short one that redirects to it.”
  • “How would you generate the short codes?” (a narrowed version that skips straight to the hard part)

The cousins: “design Pastebin” is the same problem with a bigger payload, and “design a link-in-bio service” is the same problem with a UI. If you can answer this one, you can answer those.

What They Are Really Asking

Interviewers pick this question because it is small enough to finish in 45 minutes, but every part of it has a trade-off. They are probing:

  1. Can you do napkin math and let it drive the design? The numbers tell you this is a read-heavy system where the database is not the bottleneck. A candidate who never estimates usually over-builds the write path and under-builds the read path.
  2. Do you understand ID generation? This is the core of the question. There are at least five ways to generate a short code, and each fails in a different way. The interviewer wants to hear you compare them, not pick one.
  3. Do you know what an HTTP redirect actually does? The 301 vs. 302 choice is a small detail with large consequences for analytics and caching. Senior candidates know it; junior candidates do not.
  4. Can you stay proportionate? This is a simple system. Reaching for Kafka, a service mesh, and a graph database to shorten URLs signals that you do not size solutions to problems.

A weak answer is a single diagram with a load balancer, an app server, and a database, delivered in five minutes with no numbers and no trade-offs. A strong answer spends most of its time on the two or three decisions that matter.

The Gotchas

Gotcha 1: Hashing the URL and truncating. The reflexive answer is “MD5 the long URL, base62 encode it, take the first seven characters.” That truncation throws away most of the hash, so collisions become likely well before the keyspace fills up. You then need a collision check on every write, which means a database read before every insert, which means the write path is now a read-modify-write with a race condition. If you go this route, say all of that out loud and explain how you handle the collision (append a salt and rehash, or fall back to a counter).

Gotcha 2: Using the database’s auto-increment ID. Encode a sequential integer in base62 and you get short, unique, collision-free codes with no lookup. It is a legitimate answer. The gotchas are that it makes codes predictable (anyone can enumerate every link you have ever created by counting), and it puts all writes through a single sequence, which is a scaling ceiling and a single point of failure. Mention both, and mention the fix for the first one: bijective obfuscation of the integer before encoding, or a random salt in the low bits.

Gotcha 3: Ignoring the 301 vs. 302 decision. A 301 (Moved Permanently) tells browsers to cache the redirect, so repeat visits never hit your servers. That is great for load and terrible for analytics, because you never see the second click. A 302 (Found) or 307 (Temporary Redirect) sends every click through you. If the business wants click counts, which is the entire business model of most shorteners, you want 302. If you pick 301 without saying why, the interviewer will assume you did not know the difference.

Gotcha 4: Skipping capacity estimation. Without numbers you cannot justify anything. Do the math early. It takes two minutes and it makes every later decision defensible.

Gotcha 5: Designing for writes when the workload is reads. A typical shortener sees somewhere between 10 and 100 reads per write. The read path is a single key lookup that should almost never touch the primary database. If your design has no cache, or the cache is an afterthought, you have optimized the wrong half of the system.

Gotcha 6: Forgetting the unglamorous requirements. Expiration, deletion, custom aliases, rate limiting on the create endpoint, and abuse. Shorteners are a favorite tool for phishing, and a real service needs a way to block a link and a way to check destinations against a threat list. You do not have to design all of it, but you should name it.

Gotcha 7: Answering “should the same long URL always return the same short code?” without asking who is asking. For an anonymous public service, deduplicating saves storage. For a service with user accounts and per-link analytics, two users shortening the same URL must get different codes, because they each want their own click counts. This is a product question disguised as a technical one. Ask.

How to Answer

Step 1: Clarify the requirements

Ask these before drawing anything:

Question Why it matters
What scale? New URLs per day, redirects per day? Drives the ID strategy and whether you need sharding at all
Do users need custom aliases? Adds a uniqueness check and a reserved-word list to the write path
Do links expire? Can they be deleted? Adds a TTL (time to live) and a cleanup job, and affects cache invalidation
Do we need click analytics? Decides 301 vs. 302 and adds an async logging path
Is it multi-tenant with accounts, or anonymous? Decides whether duplicate long URLs get the same code
How short is short? Is 7 characters acceptable? Sets the keyspace

If the interviewer says “you decide,” pick reasonable defaults and state them: 100 million new URLs per month, 10:1 read-to-write ratio, 7-character codes, links live for 5 years by default, click analytics required.

Step 2: Do the math

Say the numbers out loud so the interviewer can follow.

Quantity Estimate
Writes 100M / month ≈ 40 per second, plan for 10x peaks → 400/s
Reads 10:1 ratio → 400/s average, 4,000/s peak
Keyspace Base62, 7 chars → 62^7 ≈ 3.5 trillion codes. Plenty.
Storage per row ~500 bytes (code, long URL, owner, timestamps)
Storage over 5 years 100M × 12 × 5 × 500 B ≈ 3 TB. Fits on one well-provisioned database, but you will shard for write throughput and availability before you shard for size
Cache Follow the 80/20 rule: 20% of daily reads are hot. 400/s × 86,400 × 0.2 × 500 B ≈ 3.5 GB per day. A single cache node holds it

The conclusion you draw from this, out loud: the database is not the bottleneck. The design is about the ID strategy and the read path.

Step 3: Define the API

Two endpoints. Keep it short.

1
2
3
4
5
6
7
POST /api/v1/links
  { "long_url": "...", "custom_alias": "optional", "expires_at": "optional" }
  → 201 { "short_url": "https://sho.rt/aB3xY9k", "code": "aB3xY9k" }

GET /{code}
  → 302 Location: <long_url>
  → 404 if unknown, 410 if expired or deleted

Mention that the create endpoint needs authentication and rate limiting, and that the redirect endpoint is anonymous and must be fast.

Step 4: Compare ID generation strategies

This is where you spend your time. Put the options on the board and compare them.

Strategy How it works Collision-free? Predictable? Scaling concern
Hash and truncate MD5/SHA of URL, base62, take 7 chars No, needs a check-and-retry No Read before every write
Counter + base62 Auto-increment integer, base62 encode Yes Yes, enumerable Single sequence is a write bottleneck
Range-allocated counters Each app server leases a block of IDs (e.g. 10,000) from a coordinator Yes Mostly, within a block Coordinator must be highly available; a crashed server wastes its block
Pre-generated key service (KGS) A background service fills a table of unused random keys; app servers take one Yes No KGS is a dependency; keys must be handed out exactly once
Random with retry Generate 7 random base62 chars, insert, retry on unique-constraint violation Yes, after retry No Retry rate grows as the keyspace fills, but at 3.5 trillion it stays negligible for years

A defensible recommendation for the stated scale: random with retry, backed by a unique constraint on the code column. It has no coordinator, no single sequence, no pre-computation, and the retry probability at 1 billion used codes out of 3.5 trillion is about 0.03 percent. Say that number. It shows the option was chosen with math, not by habit.

Then say what would change your mind. At much larger scale, or if you needed strictly non-repeating keys across regions, you would move to range-allocated counters or a Snowflake-style ID (timestamp, worker ID, sequence) with obfuscation.

Step 5: Draw the read path

flowchart LR
    U[Client] -->|GET /aB3xY9k| CDN[CDN / edge]
    CDN -->|miss| LB[Load balancer]
    LB --> API[Redirect service]
    API -->|1. get code| C[(Cache)]
    C -->|hit| API
    C -.->|miss| DB[(Database)]
    DB -.->|fill| C
    API -->|302 Location| U
    API -.->|async click event| Q[Queue]
    Q --> AN[(Analytics store)]

Talk through it:

  • Cache-aside on the code. Look up the code in an in-memory cache. On a miss, read the database and populate the cache. Redirect data is immutable except for delete and expiry, so a long TTL is safe.
  • Negative caching. Cache 404s briefly. Bots scanning random codes will otherwise turn every miss into a database read.
  • Analytics off the hot path. Emit the click event to a queue and return the redirect immediately. Never make the user wait for a write to the analytics store.
  • The 302 decision. State it and justify it with the analytics requirement. If the interviewer says analytics are not needed, switch to 301 and note that your read load just dropped substantially because browsers cache it.

Step 6: Draw the write path

sequenceDiagram
    participant C as Client
    participant A as API
    participant D as Database
    C->>A: POST /links {long_url}
    A->>A: validate URL, check threat list
    A->>A: generate random 7-char code
    A->>D: INSERT (code, long_url, ...) 
    alt unique violation
        D-->>A: conflict
        A->>A: regenerate, retry
        A->>D: INSERT
    end
    D-->>A: ok
    A-->>C: 201 {short_url}

Mention custom aliases here: they use the same insert-and-catch-conflict path, plus a reserved-word list so nobody claims /admin or /api.

Step 7: Scale it, proportionately

Only after the basics are on the board:

  • Database. Start with a single primary and read replicas. Reads mostly hit the cache anyway. If you must shard, shard by code (hash-based) since every read is a point lookup by code. A key-value store fits this access pattern naturally; a relational database is fine too. Say that either works and why you would pick one.
  • Cache. A cluster with consistent hashing. On cache failure, the database absorbs 4,000 reads per second, which a replica set handles.
  • Expiration. Store an expiry timestamp and check it on read. Run a lazy cleanup job to reclaim rows. Do not build a real-time deletion system for something that can be a nightly batch.
  • Deletion and cache invalidation. On delete, remove the cache entry and mark the row. This is the one write that must reach the cache synchronously.
  • Abuse. Rate limit link creation per user and per IP. Check destinations against a threat feed at create time and periodically afterward, since a destination can turn malicious after it is shortened.

Follow-Up Questions to Expect

  • “What happens when one link goes viral?” A hot key. The cache handles it; if a single cache node is saturated, replicate that key across nodes or serve it from the CDN. Name the problem before they ask.
  • “How do you make the code unpredictable if you use a counter?” Bijective obfuscation (multiply by a large odd constant modulo the keyspace, or a Feistel cipher) before base62 encoding. Same uniqueness, no enumeration.
  • “How would you do this multi-region?” Reads are easy: replicate the cache and database globally, and route via GeoDNS. Writes are the question: random codes with a per-region unique constraint can conflict across regions, so either partition the keyspace by region (a region prefix character) or accept a global unique index with cross-region latency on writes.
  • “How do you count clicks without slowing redirects?” Async events to a queue, aggregated by a consumer. Accept eventual consistency in the dashboard.
  • “Should two users shortening the same URL get the same code?” Ask what the product needs. Multi-tenant with analytics: no. Anonymous public tool: yes, deduplicate by URL hash.
  • “What is your single biggest risk?” The unique constraint on the code column is load-bearing. If you shard and lose the global uniqueness guarantee, you can hand out the same code twice. Say how you preserve it (shard by code, so uniqueness is per shard and the shard is chosen by the code).

Key Takeaways

  • The question is small on purpose. Depth on two or three decisions beats breadth across ten boxes.
  • Do the math first. It shows the system is read-heavy and that a single database can hold years of data.
  • ID generation is the core. Compare at least three strategies and pick one with a number attached.
  • 301 caches at the browser and kills analytics. 302 sends every click through you. Choose deliberately.
  • Cache-aside on the read path, async analytics, negative caching for misses.
  • Name expiration, deletion, custom aliases, rate limiting, and abuse, even if you only sketch them.
  • Stay proportionate. A message queue for click events is justified. A message queue for link creation is not.

Further Reading

This post is licensed under CC BY 4.0 by the author.