Skip to main content

Walkthrough: Designing a URL Shortener

A full candidate's-eye walkthrough of the URL shortener system design question — from clarifying questions through ID generation, caching, analytics, and failure modes.

The problem

You’re asked to design a URL shortener — TinyURL, bit.ly, t.co. The interviewer says something like: “Design a service that takes a long URL and returns a shorter one. When someone visits the short URL, they’re redirected to the original.”

Sounds simple. It isn’t. This is the canonical “read-heavy at internet scale” problem, and the traps are in what you choose to defend — ID generation, caching strategy, and whether you treat analytics as part of the core system or an afterthought.

Below is how I’d walk through this, start to finish, roughly in the order I’d speak the words.

1. Clarify before you design

First 3–5 minutes. Resist the urge to start drawing.

Questions I’d ask:

  • Scale. How many URLs created per day? How many reads per create? A typical answer is ~100M creates/day with a ~100:1 read-to-write ratio — the system design primer is a good anchor for these ballpark numbers.
  • Length. Is there a target length for the short URL? Seven characters of base62 gives ~3.5 trillion combinations — probably enough.
  • Custom aliases. Can users pick their own short code?
  • Expiry. Do URLs expire? After how long?
  • Analytics. Do we track clicks? At what granularity?
  • Read consistency. Is it okay if a freshly-created URL takes a second or two to resolve everywhere?

The analytics and expiry questions matter because they change the storage model. The consistency question matters because it unlocks a lot of caching.

Say the interviewer confirms: 100M/day creates, ~10B/day reads, custom aliases yes, 5-year default expiry, basic click analytics, eventually consistent reads acceptable.

2. Capacity estimate

Brief. Don’t over-engineer — the point is to check your scale intuition.

  • 100M/day ≈ 1,200 writes/sec average, ~10K/sec peak
  • 10B/day ≈ 115K reads/sec average, ~1M/sec peak
  • Storage: 100M/day × 365 × 5 years ≈ 180B URLs total. At ~500 bytes per record (URL + metadata), that’s ~90 TB over the full retention window.

I’d say out loud: “This tells me two things. One — the read path needs heavy caching. Two — I probably want a KV store, not a relational database, given the record count and access pattern.”

3. API design

Two endpoints do the work:

POST /api/urls
  body:    { long_url, custom_alias?, expiry? }
  returns: { short_url, short_code }

GET /:short_code
  returns: 301/302 redirect to long_url

Small but important decision: 301 or 302? 301 is permanent and cacheable by browsers — which means analytics under-count. 302 is non-permanent, so every request hits your server. Most URL shorteners use 302 for this exact reason. Worth saying out loud.

4. Core design: ID generation

This is the question. It’s where SDE II and SDE III answers diverge.

Three common approaches:

(a) Hash the long URL. Take MD5 or SHA-1, base62-encode the first seven characters.

  • Pros: Stateless, no coordination needed.
  • Cons: Collisions. You have to check the DB before writing; on collision, probe (append a char, rehash, etc.). This becomes a correctness problem under concurrent writes.

(b) Random base62. Generate seven random base62 chars, check the DB for collision, retry on conflict.

  • Pros: Simple. Write path is one read + one write.
  • Cons: Collision rate rises as the keyspace fills. Manageable at low fill, painful later.

(c) Distributed counter. A monotonically increasing integer, base62-encoded.

  • Pros: No collisions by construction. Clean writes.
  • Cons: Need a coordinator. A single counter in a single DB is a bottleneck and a SPOF.

The SDE III answer is: counter-based, with range allocation. Each application server requests a batch of 1,000 IDs from a central allocator (ZooKeeper, a dedicated service, or a DB with SELECT ... FOR UPDATE). The server burns through its batch locally with zero coordination, then asks for another. This is essentially Twitter’s Snowflake approach without the embedded timestamp.

Out-of-order IDs are fine here — short codes don’t need to be sequential, they need to be unique. Range allocation gives you 10K+ writes/sec per app server with trivial load on the coordinator.

For custom aliases: they’re a separate write path that checks the existing index, with a reserved-namespace convention to avoid collisions with the generated codes (for example: custom aliases must be ≥8 chars, or must start with a capital letter).

5. Data model and storage

Two tables conceptually:

urls:
  short_code (PK)
  long_url
  created_at
  expires_at
  created_by

clicks:
  short_code
  timestamp
  referer
  country
  user_agent

Storage choices:

  • urls table — a KV store like DynamoDB or Cassandra is a natural fit. Point lookups by short_code, no joins, no cross-record transactions. The primary access pattern — “given short_code, return long_url” — is the one thing these stores do best. The foundational reference here is the Dynamo paper; the trade-offs it describes are exactly the ones you’re inheriting.
  • clicks table — write-heavy and append-only. Cassandra handles this well, or you stream to a warehouse via Kafka (more on this below).

I’d say explicitly: “I’m not using a relational database for the main table. I don’t need joins, I don’t need transactions across records, and this access pattern is the pathological best case for a KV store.”

6. The read path: caching is the system

At 1M reads/sec peak, no database serves this directly. Caching isn’t an optimization — it’s the architecture.

The read flow:

flowchart LR
  Client[Client] --> CDN[CDN edge]
  CDN --> App[App server]
  App --> Redis[(Redis cache)]
  Redis -->|miss| DB[(KV store)]

A few layers worth naming:

  • CDN at the edge. If you cache the 302 redirect response itself at the CDN, the app server never sees the request. For popular links (think: viral tweet), this is 99%+ of traffic.
  • Redis in front of the DB. Use the cache-aside pattern: on miss, read DB, write cache, return. TTL of a few hours.
  • DB as fallback. Only cold or recently-created URLs hit it.

Cache size estimate. Hot working set is roughly 1% of total URLs — call it 2B records. At ~200 bytes per cached entry (just short_code → long_url, no metadata), that’s ~400 GB of Redis. Multi-node cluster, consistent-hashed.

Cache invalidation. URLs are mostly immutable after creation. The only mutation is deletion or expiry. A TTL slightly shorter than the actual expiry handles this cleanly — no clever invalidation protocol needed. (One of the few times the cache-invalidation problem vanishes instead of dominating.)

7. Sharding and multi-region

The urls table is sharded by short_code. With DynamoDB this is automatic; with Cassandra you partition on short_code. Hot-partition risk is low because short codes are effectively random.

The Redis cluster is sharded via consistent hashing on short_code — standard. Any decent Redis client library does this.

Multi-region. If the service is global, I’d add regional read replicas of the DB and per-region Redis clusters. Writes can go to a primary region (simpler) or be replicated async (more failure modes, but lower write latency). For a URL shortener where reads can be eventually consistent, the trade-off heavily favors async regional replication.

Extending the architecture:

flowchart LR
  Client[Client] --> CDN[CDN edge]
  CDN --> App[App server]
  App --> Redis[(Redis · us-east)]
  Redis -->|miss| DB[(KV store · us-east)]

  DB -.->|async replication| DB2[(KV store · us-west)]
  Redis2[(Redis · us-west)] -->|miss| DB2
  CDN --> App2[App server · us-west]
  App2 --> Redis2

  classDef new fill:#eef2f1,stroke:#2c5f5d,stroke-width:2px,color:#1f2937;
  class DB2,Redis2,App2 new

The CDN routes each client to its nearest region. Writes land in us-east (primary); replicas in us-west are eventually consistent — acceptable given the product already tolerates a short consistency delay on create.

8. The analytics pipeline

The click path is the second-largest system hidden inside this problem. An interviewer noticing that you treat it as a peer to the core redirect path — rather than an afterthought — is a strong SDE III signal.

Naive design: every click writes a row to the DB. At 1M writes/sec peak, this is catastrophic for the main system.

Better: every click emits a message to Kafka. A downstream consumer batches and writes to the analytics store (Cassandra, or a columnar warehouse). The redirect itself doesn’t wait on any of this.

Extending the architecture one more time:

flowchart LR
  Client[Client] --> CDN[CDN edge]
  CDN --> App[App server]
  App --> Redis[(Redis · us-east)]
  Redis -->|miss| DB[(KV store · us-east)]

  DB -.->|async replication| DB2[(KV store · us-west)]
  Redis2[(Redis · us-west)] -->|miss| DB2
  CDN --> App2[App server · us-west]
  App2 --> Redis2

  App -.->|click event| Kafka[(Kafka)]
  App2 -.->|click event| Kafka
  Kafka --> Consumer[Click consumer]
  Consumer --> Analytics[(Analytics DB)]

  classDef new fill:#eef2f1,stroke:#2c5f5d,stroke-width:2px,color:#1f2937;
  class Kafka,Consumer,Analytics new

The redirect response is returned to the client immediately — the click event is fire-and-forget from the app server’s perspective.

Two wins from this shape:

  • Redirect latency is decoupled from analytics ingestion.
  • You can reprocess the stream (e.g., for backfilling a new aggregation) by replaying from Kafka.

For querying: if the product shows “clicks in the last 24 hours” on the creator’s dashboard, maintain a precomputed aggregate (Redis counter or a ClickHouse rollup). Don’t scan the raw clicks table on every dashboard load.

9. Rate limiting

For the create endpoint: a token bucket per API key or per IP, Redis-backed, keyed on user_id. This belongs to an API gateway layer, not the URL service itself — worth saying out loud so the interviewer knows you understand separation of concerns.

For the redirect endpoint: usually not rate-limited per user, but globally protected at the CDN and edge (DDoS protection).

10. Failure modes

I’d proactively walk through what breaks, because this is one of the clearest differentiation signals. Don’t wait for the interviewer to ask.

  • ID allocator is down. App servers keep serving from their in-memory batch (~1,000 IDs) until exhaustion. If the allocator stays down long enough, writes fail. Mitigations: multiple allocator instances, fallback to hash-based IDs with collision retry.
  • Redis is down. Thundering herd onto the DB. Mitigations: request coalescing (only one miss per key triggers a DB read at a time), circuit breakers — see Martin Fowler on the circuit breaker pattern — and gradual cache warming from a secondary tier.
  • DB partition lost. If using DynamoDB or Cassandra with quorum replication, a minority-partition loss is survived automatically. Say this explicitly: it’s where the KV choice earns its keep.
  • Kafka is down. Redirects still work — we drop click events or buffer them locally and retry. The product degrades gracefully: losing some analytics is strictly better than losing redirects.

The pattern to notice: name what fails, name what degrades gracefully, name what doesn’t.

11. What I’d skip, and say I’m skipping

Time check: five minutes left. Things I’d explicitly defer:

  • Link previews and phishing detection. A real shortener needs this, but it’s a separate system — URL classification pipeline, probably ML-based. Worth a sentence, not a slide.
  • User management. Standard OAuth/JWT, not interesting here.
  • Billing and quotas beyond rate limiting. Same.
  • A/B redirect rules or geo-routing per link. Features, not infrastructure.

Saying “I’d skip this, and here’s why” is a strong SDE III signal. It shows you know the full surface and are making deliberate scoping choices, not just running out of things to talk about.

12. Wrap-up

One crisp sentence before the interviewer’s next question:

This design optimizes for read throughput and availability at the cost of a short consistency delay on create. That trade-off matches the product — nobody cares if their new short URL takes two seconds to become globally visible.

That’s the kind of articulation that lands.

What separates SDE II from SDE III on this question

  • SDE II usually lands the API, picks a reasonable ID scheme, names Redis and a KV store, and sketches the read path.
  • SDE III drives scoping in the first five minutes, picks ID allocation with a specific rationale, walks through the analytics pipeline as a peer system, names three or more failure modes with mitigations, and explicitly defers features that don’t belong in the core design.

The difference isn’t knowledge. It’s which levers you pull and how you justify pulling them.

Further reading