System Design · Interview Guide

Design a URL Shortener: System Design Interview Guide

Simple to describe, hard to scale. A complete walkthrough from requirements to production-grade architecture, with the reasoning behind every decision.

L4 · SDE II L5–L6 · Senior / Staff L7–L8 · Principal / Distinguished
Architecture diagram of a URL shortener system showing client, load balancer, stateless API servers, Redis cache, PostgreSQL database, and async analytics pipeline
01

What the interviewer is testing

The URL shortener is deceptively simple to describe and genuinely hard to scale. That gap is exactly why it's the canonical system design question. It forces you to reason about capacity, caching, encoding, sharding, and analytics pipelines all at once, in a system everyone already understands.

What changes across levels isn't the system, it's the frame. At L4 the question is whether you can arrive at a working design independently. At L5/L6 it's whether you understand the tradeoffs in your own choices before being asked. At L7/L8 it's whether you can challenge the problem definition itself and drive the conversation rather than respond to it.

💬

The probes that separate levels. The same four questions appear at every level, but the expected answer is completely different each time:

  • "What if the same URL is submitted twice?"
  • "How would you handle a URL that goes viral?"
  • "How do you expire links without a full table scan?"
  • "Walk me through a cache miss."

See Section 11 for exactly how the expected answer shifts from L4 through L7/L8.

🎙️

Want to see it answered live? Read the full 50-minute mock interview transcript — an L6 candidate takes this exact question turn by turn, whiteboard sketches and all, with the interviewer's notes on what scored and the two mistakes that cost them.

02

Requirements clarification

The first few minutes of a system design interview are the highest-leverage time you have. The requirements you establish here, and the ones you explicitly defer, shape every tradeoff you'll make for the rest of the conversation.

Functional requirements

These are the things the system does. Two are non-negotiable; the rest are worth raising as optional to signal breadth.

RequirementNotes
Shorten a long URL to a 7–8 character codeCore write path
Redirect a short URL to the originalCore read path, must be fast
Custom aliasesOptional; conflict handling is where the signal is
URL expirationOptional; important for cleanup design
Analytics (click counts, geo, referrer)Drives the 301 vs 302 decision later

Non-functional requirements

PropertyTarget
Availability99.99% redirect; 99.9% creation
Redirect latency<10 ms p99 on cache hit; <50 ms p99 on cache miss
DurabilityNo silent loss of URL mappings
Analytics consistencyEventually consistent. An async pipeline is fine.
Scale1 M new URLs/day; 100:1 read/write ratio

NFRs are where most of the interesting design tension lives. Each target above is a deliberate choice. Expand any of them to understand the reasoning behind it, and what it directly drives in the architecture.

Availability: 99.99% redirect / 99.9% creation Asymmetric across read and write ›

99.99% on the redirect path, about 4 minutes per rolling 28-day window. Creation gets a looser target: 99.9%.

Two reasons for the gap. Failure costs more on reads: a broken redirect hits someone who isn’t your customer, with no context and no retry beyond a refresh, and they blame the destination brand. A clean creation failure surfaces to someone sitting in front of a form. Reliability costs less on reads: a redirect is a stateless lookup any replica can serve, while a write isn’t done until it’s durably stored.

This distinction matters for the architecture. It means the read path needs more redundancy than the write path. Multiple read replicas, a warm cache, and a load balancer with fast health checks are all direct responses to this requirement. The write path, which at 11.5 QPS is barely a whisper, can tolerate a brief primary failover without meaningful user impact.

What this drives Read replicas on the DB · Multi-AZ Redis · Load balancer health checks · Cache as a fallback buffer during DB degradation
Redirect latency: <10 ms p99 on cache hit The number that justifies the entire cache layer ›

10 ms is aggressive. A round-trip to a database in the same data centre is typically 1–5 ms under ideal conditions, but p99 latency accounts for the worst 1% of requests: slow queries, lock contention, garbage collection pauses. Hitting the DB on every redirect makes it very hard to stay under 10 ms at the 99th percentile.

Redis, by contrast, responds in under 1 ms for a simple key lookup. That headroom (1 ms for the cache lookup, plus network overhead) is why the <10 ms target effectively mandates a cache. Without it, the target is aspirational rather than achievable.

The <50 ms target on a cache miss is more relaxed by design. A miss means a DB read, which is slower but still expected to be rare (80%+ of requests should hit the cache). Setting a separate, looser target for misses is honest engineering, you're acknowledging the two paths have different cost profiles rather than pretending they're the same.

What this drives Redis as a mandatory layer, not optional · L1 local cache on API servers for ultra-hot keys · Cache-aside read pattern (check cache first, always)
Durability: no silent loss of URL mappings Asymmetric with analytics ›

A lost URL mapping is catastrophic in a way that a lost click event is not. If short.ly/abc123 disappears from the database, every future user who clicks that link gets a 404. There is no recovery. The original long URL is gone. This is why URL mappings must be stored in a durable, replicated database with write-ahead logging, not in Redis (which is a cache, not a database, despite being able to persist data).

The framing "no silent loss" is deliberate. It means a write must not return success unless the data is safely persisted. An acknowledged write that later disappears due to async replication lag on a primary failure is the failure mode to design against. This points toward synchronous replication or quorum writes for the URL mapping table.

What this drives Relational DB (not Redis) as the source of truth · synchronous_commit against a standby, not async-by-default streaming replication · WAL-enabled storage · URLs ≠ cache entries
Analytics consistency: eventually consistent is fine A deliberate relaxation ›

This is an explicit relaxation, and stating it out loud is just as important as stating the strict requirements. Not every part of the system needs the same guarantees. If a dashboard shows 9,842 clicks instead of 9,847 because five recent events are still in the Kafka pipeline, no user is harmed. The number will catch up within seconds.

This relaxation is what makes the async analytics architecture acceptable. If click counts needed to be exact and real-time, you'd need synchronous writes on the redirect path, which would blow the latency budget. By establishing eventual consistency as acceptable here, you unlock the entire async pipeline design: Kafka → Flink → ClickHouse, with the redirect path never waiting for any of it.

The key skill is knowing which parts of a system can tolerate relaxed consistency. URL mappings cannot. Click counts can. Stating that distinction explicitly and tracing it back to user impact is what separates a senior answer from a junior one.

What this drives Kafka publish on redirect (fire-and-forget) · Async consumer pipeline · ClickHouse as the analytics store, not the URL store · Dashboard lag of seconds is acceptable
Scale: 1M URLs/day, 100:1 read:write An anchor, not a ceiling ›

Treating scale as a fixed assumption rather than a range is a common mistake. 1M URLs/day and 100:1 is a reasonable starting anchor. It's the kind of number you'd get from a mid-size product. But the interviewer may nudge you toward 100M URLs/day, or toward a single viral event generating 50,000 QPS for 10 minutes. The architecture should be defensible across that range, not brittle at the edges.

The read:write ratio is the more structurally important number. At 10:1, you'd still care about performance but might question whether a cache layer is worth its complexity. At 100:1, the cache is obvious. At 1000:1 (a single viral URL), the cache becomes the only thing standing between you and a DB meltdown. Naming this ratio early anchors every caching discussion that follows.

A useful pattern: establish the baseline (1M/day, 100:1), then proactively mention how the design degrades and recovers at 10× scale. This shows you've thought beyond the happy path without needing the interviewer to push you there.

What this drives Cache as a primary, not auxiliary, component · Stateless API servers for horizontal scaling · Read replicas before sharding · Hot-key protection (L1 local cache) for viral spikes
✓

Out of scope, say it clearly: user authentication, billing, abuse detection, malware scanning. Mention you'd address these given more time. Not raising them at all is a signal that you haven't thought about the system's full surface area.

03

Capacity estimation

The point of capacity estimation isn't to get a precise number. It's to understand the character of the system before you design it. A few rough calculations will tell you whether you're dealing with a read-heavy or write-heavy workload, whether storage fits on one machine or needs sharding, and how big the cache needs to be to actually help. Those answers shape every decision that follows.

There are four dimensions worth thinking through: traffic, storage, bandwidth, and cache. Expand each one to see the reasoning behind it.

Traffic: QPS and the read/write split The most revealing number ›

Start with what you can anchor to: how many new URLs are created per day? 1 million is a reasonable starting assumption for a mid-scale system. Converting to per-second is a one-step calculation: 1M ÷ 86,400 ≈ 11.5 writes/sec. That's modest. A single DB node handles this without breaking a sweat.

The more interesting number is reads: for every URL created, how many times is it clicked? For a general-purpose shortener, 100:1 is reasonable, so 11.5 × 100 ≈ 1,150 reads/sec on average. Traffic is never flat, so apply a 5× peak multiplier for spikes (a tweet goes viral, a campaign launches), putting peak reads at ~5,750/sec. The ratio itself varies enormously by use case — a marketing campaign URL can run 10,000:1.

Why this matters: at 100:1, this system is almost entirely reads. That single insight justifies the entire caching strategy. If reads were only 2:1, you'd think about the design very differently.

Numbers to remember 1M URLs/day → 11.5 write QPS · 1,150 read QPS avg · 5,750 read QPS peak
Storage: does this fit on one machine? Usually less scary than it sounds ›

Each URL row needs: the short code (~8 bytes), the long URL (average ~200 bytes for a real URL, but allow 500 bytes including metadata columns). At 1M rows/day for 5 years: 1M × 365 × 5 = 1.8 billion rows, totalling roughly 900 GB.

900 GB fits comfortably on a single modern database server. This is an important realisation: you don't shard for storage capacity here, you shard for availability and read throughput. Sharding a database that doesn't need it for capacity adds operational complexity for no gain; knowing this lets you justify starting with a simpler single-primary and read replica setup.

The clicks table is a different story. At 100:1, you're storing 100M click events per day, about 36 billion rows per year at just the base URL creation rate. At even 100 bytes per click row, that's 3.6 TB/year. This is why clicks needs a separate, write-optimised store from day one of the scaled design.

Numbers to remember URLs: ~900 GB over 5 years, fits on one node · Clicks: ~3.6 TB/year, needs a separate store
Bandwidth: usually not the bottleneck Worth a quick check ›

For the write path: 11.5 writes/sec × 500 bytes ≈ 6 KB/sec inbound. Negligible. For the read path: a redirect response is tiny, just an HTTP 302 with a Location header, roughly 200 bytes. At 5,750 peak reads/sec that's about 1.1 MB/sec outbound. Again, not a concern.

Bandwidth becomes interesting only if you serve the destination page through the shortener (you don't), or if you add analytics pixel tracking with embedded images. For a pure redirect service, bandwidth is never the constraint. Saying so in an interview shows you've thought about it and ruled it out rather than just ignoring it.

Cache sizing: how much memory do you actually need? Smaller than you'd expect ›

URL access follows a power-law distribution. A small fraction of URLs receive the vast majority of traffic. Caching the hot 20% of the working set typically yields an 80%+ cache hit rate. The question is: what does 20% actually cost in RAM?

The daily active working set is roughly 1M new URLs × 5 years × 365 days = 1.8B total URLs, but on any given day only a slice of those are active. A reasonable model: cache the top 20% of daily active URLs. If 10M URLs are active on a given day, caching 2M × 500 bytes = 1 GB of Redis memory covers the hot tail. Even at 10× that scale, you're talking 10 GB. A single Redis node comfortably holds this.

Why this matters for the design: knowing the cache fits entirely in memory on one or two nodes means you can justify a simple Redis setup rather than immediately reaching for Redis Cluster. Scale the architecture to what the numbers actually demand.

Numbers to remember Caching 20% of hot URLs ≈ 1–10 GB RAM · Single Redis node is sufficient at moderate scale

Play with the numbers

Use the estimator below to see how these figures shift at different scales: a single viral campaign versus a global platform.

Adjust to your scenario
1 M
100 : 1
500 B
5 yr
Write QPS
—
requests / sec
Read QPS
—
requests / sec
Peak Read QPS
—
~5× average
Storage needed
—
over retention period
Cache size (20%)
—
20% of daily active
Total URLs
—
over retention period
💡

The key insight these numbers reveal: this system is overwhelmingly read-heavy, storage is manageable, bandwidth is a non-issue, and the cache footprint is small. That combination, high read ratio, cheap cache, modest storage, is exactly why a simple cache-aside pattern with Redis works so well here. The architecture follows naturally from the numbers. Section 12 collects every figure in this article next to the decision it settles.

04

High-level architecture

Start with this overview diagram before diving into any component. Establish the full picture, then drill down. This is the structure you should draw on the whiteboard in the first 10 minutes.

CLIENT EDGE APPLICATION STORAGE Client browser / app Load balancer L7 · HTTP-aware API servers shorten · redirect stateless · autoscaled Cache Redis · hot code → URL map Database urls · PostgreSQL → Cassandra at scale click event (async) ANALYTICS · ASYNC Kafka click events Flink/Spark → ClickHouse aggregates · dashboards synchronous — in the request path asynchronous — never blocks the redirect
Figure 1: High-level architecture. Everything above the analytics tier is synchronous and in the request path; the dashed tap below it is not, which is why a slow dashboard can never slow a redirect.

Component breakdown

Load balancer. L7 (HTTP-aware), distributes traffic across stateless API servers. Can route /create (write path) separately from /:code (read path) for independent scaling.

API servers. Fully stateless; no session affinity required. This is the core architectural choice that enables horizontal autoscaling. Every request contains everything needed to process it.

Redis cache. The most important component for read performance — a 20% working-set cache covers ~80% of redirects without touching the database.

Database. Stores URL mappings durably. PostgreSQL works at moderate scale; Cassandra or DynamoDB for large-scale write throughput and horizontal sharding.

Analytics service. Fully asynchronous. Clicks are published to Kafka, never blocking the redirect response. A stream processor (Flink/Spark) aggregates to a columnar store.

Architectural rationale

Every component is a deliberate answer to a specific constraint. Expand each section to understand the reasoning behind the choice, which is what interviewers at L5+ are listening for.

① Load balancer Availability & independent scaling ›

Why a layer-7 over a layer-4 load balancer? An L7 (HTTP-aware) load balancer lets you route by URL path. The write path (POST /shorten) and read path (GET /:code) have completely different traffic patterns. Reads are 100× more frequent. L7 lets you scale them independently, routing them to separate server pools, without any client changes.

Why not DNS round-robin? DNS has no health-check awareness. A failed server receives traffic until the DNS TTL expires, which is potentially minutes. A proper load balancer detects unhealthy nodes within seconds and removes them. That responsiveness is essential for a 99.99% availability target.

⚖ Tradeoff The load balancer itself is a potential single point of failure. Mitigate with an active-passive LB pair, or use a managed cloud LB (AWS ALB, GCP GLB) that provides built-in redundancy.
Alternatives Nginx / HAProxy (self-hosted) AWS ALB / GCP GLB (managed) Layer-4 LB (if path-based routing isn't needed)
② API servers Statelessness is the key decision ›

Why stateless? A stateless server holds no session data. Every request carries everything needed to process it. This is the single architectural decision that makes horizontal autoscaling trivial: spin up more servers, point the load balancer at them, done. No sticky sessions, no session replication, no coordination overhead.

What state is explicitly excluded? No in-flight request state, no user sessions, no per-connection counters. The only "state" is the pre-fetched ID range in memory (counter-based encoding), which is safe to lose on restart, since the server simply fetches a new range from Zookeeper (a distributed coordination service for leader election and shared config).

⚖ Tradeoff Statelessness pushes all durable state into the cache and DB layer. Those become the scaling bottlenecks, which is intentional, since they have well-understood strategies (sharding, read replicas).
③ Redis cache The highest-impact component ›

Why is this the most important component? The power-law access pattern from Section 3 means caching the hot 20% yields 80%+ hit rates. Without cache, every redirect hits the DB; with it, most complete in ~1 ms with no DB I/O whatsoever.

⚖ Tradeoff Cache introduces a stale-data window. A deleted URL may still be served until its TTL expires. Mitigate with proactive cache invalidation on every write, issue DEL short_code to Redis alongside the DB delete.
Alternatives Memcached (simpler, but no persistence or cluster mode) Local L1 cache (used in addition to Redis for hot-key burst protection)
④ Database Durability anchor of the system ›

Why not use Redis as the primary store? Redis is an in-memory store. Data loss on failure is possible without careful persistence configuration, and even then it lags behind a relational DB in durability guarantees. The database is the source of truth; the cache is a performance optimisation layered on top of it.

PostgreSQL vs Cassandra: when to switch? urls stays single-writer, but be precise about why. Generated codes cannot collide across regions — the Zookeeper-leased ID ranges above guarantee that two allocators never share a number, which is a second reason to prefer the counter over a hash. The genuine constraints are narrower: PostgreSQL has no first-class multi-master story, and custom aliases — where the user picks the string — are a real cross-region race and a CP problem whatever your ID scheme. At 11.5 writes/sec neither is worth solving, and one primary with regional read replicas is correct. Staying single-writer is also why the primary runs synchronous_commit against a standby rather than async-by-default streaming replication, trading commit latency (and a possible write stall on a lone standby) for durability. Cassandra or DynamoDB earns its place as the eventual replacement for PostgreSQL on urls itself, once a single primary can no longer absorb the write rate and you want automatic resharding rather than manual splits. It is not the answer for clicks — those never land in an OLTP store at all (Section 7). For interviews, PostgreSQL + read replicas is still the right starting answer for URL mappings.

⚖ Tradeoff A relational DB gives ACID (atomicity, consistency, isolation, durability) guarantees and easy deduplication queries, but requires explicit sharding at large scale. Cassandra shards automatically but trades consistency for availability, which is fine for URL lookups, more complex to reason about for writes.
⑤ Analytics service Why async is non-negotiable ›

Why not log clicks synchronously in the redirect handler? A redirect must complete in <10 ms. Even a fast synchronous DB write adds 5–15 ms on every redirect. At 1,000 redirects/sec that's significant load; at 50,000/sec after a viral link, it becomes a bottleneck that degrades the entire service for all users. Decoupling with Kafka means the redirect path never waits on analytics.

Why Kafka over a background thread writing to the DB? Kafka is a durable buffer. If the consumer falls behind during a traffic spike, events queue safely with no data loss and no backpressure on API servers. A background thread writing directly to the DB has no such buffer; if the DB slows down, the thread queue grows in the API server's memory until OOM.

⚖ Tradeoff Async analytics means click counts are eventually consistent, dashboard numbers may lag by seconds to minutes. This is explicitly acceptable per the NFRs in Step 2. If real-time exact counts were a hard requirement, this decision would need to be revisited.
Alternatives Redis INCR (fast counts, but lossy and hard to enrich with geo/referrer) Direct DB write (simpler, but couples analytics to the redirect path) AWS Kinesis / GCP Pub/Sub (managed Kafka alternatives)
💬

What the interviewer is really asking: "Walk me through why you made each of these choices." Candidates who can articulate the reasoning behind a component, not just what it does, are the ones who pass at L5+.

How this compares to Bitly and TinyURL

Both are production systems at significant scale, and their known architectures validate several of the choices above.

DecisionOur designBitly (known)TinyURL (known)
Short code generation Counter + Base62, pre-allocated ID ranges (Zookeeper) Custom hash + Base58 (avoids visually ambiguous chars like 0/O) Sequential counter, Base36 for older links
Primary datastore PostgreSQL + read replicas → Cassandra at scale Cassandra for URL mappings (known from engineering blog) MySQL (still relational at their scale)
Caching L1 local + L2 Redis Redis + CDN edge caching for top URLs In-process cache + Redis
Redirect type 302 default, 301 opt-in 301 for branded links (SEO benefit for customers); 302 for tracked links 301 by default (analytics less central to their model)
Analytics pipeline Event buffer → stream aggregator → columnar store (Kafka → Flink → ClickHouse) Kafka-based event pipeline, proprietary analytics layer Basic click counts; no real-time analytics product
💡

Bitly's choice of 301 for branded links is worth noting. It's a deliberate product decision that trades click analytics for SEO link equity passing to the destination. This is the same tradeoff our design exposes as an opt-in. There's no universally correct answer; the right choice follows from requirements.

05

Encoding strategies

The core algorithmic question: given a long URL, how do you generate a short, unique, collision-free code? There are two dominant approaches. Hash-based encoding (MD5/SHA-256 truncated to 7 characters) returns the same code for the same input URL, which handles deduplication naturally, but requires a collision check on every write. Counter-based encoding (an auto-incrementing ID converted to Base62) guarantees no collisions and is faster on writes, but sequential IDs are slightly guessable and two submissions of the same URL produce different codes. At FAANG scale, counter + Base62 with Zookeeper range pre-allocation is the preferred approach because it eliminates DB round-trips for collision resolution.

Approach A · Hash + Base62 Approach B · Counter + Base62 https://example.com/very/long/url MD5 / SHA-256 128-bit hash output a1b2c3d4e5f67890... take first 7 chars short.ly/a1b2c3d 7-char short code if collision Collision handling Append salt · rehash · retry + Same URL always yields same code – Needs DB check for collision detection – Retry loop adds latency on write Global counter / distributed ID Snowflake IDs · Zookeeper ranges Base62 encode Base62 alphabet 0–9 a–z A–Z → 62 symbols ID=12345 → "3d7" short.ly/3d7 7-char short code Distributed ID service Zookeeper range pre-allocation + No collisions possible + Fast and predictable – Sequential IDs are slightly guessable
Figure 2: Two encoding strategies. Approach B (counter + Base62) is recommended for production systems.

Recommended: Approach B with Zookeeper range allocation

Each API server pre-fetches a range of IDs (e.g., 1,000–2,000) from Zookeeper at startup. It burns through them locally without any network call. When the range is exhausted, it fetches the next range. This removes Zookeeper from the hot path entirely. The critical redirect path never touches it.

// Base62 encoding
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

function toBase62(id) {
  let result = '';
  while (id > 0) {
    result = ALPHABET[id % 62] + result;
    id = Math.floor(id / 62);
  }
  return result.padStart(7, '0');
}

// ID=12345 → "00003d7", ID=3,521,614,606,207 (max 7-char) → "ZZZZZZZ"
📐

62⁷ = 3,521,614,606,208 unique codes, over 3.5 trillion. At 1 billion new URLs per day, this lasts nearly 10 years before you need an 8-character code.

One open question: what if the same URL is submitted twice?

Approach A (hash) handles this naturally. The same long URL always produces the same hash, so the same short code is returned. No extra work needed.

Approach B (counter) does not. Two submissions of https://example.com/same-url will get two different IDs and two different short codes. Whether this matters depends on your product requirements. If it doesn't (many shorteners intentionally allow multiple short codes per long URL, e.g. for tracking different campaigns), ignore it. If deduplication is needed, maintain a separate long_url → short_code lookup table with a unique index on long_url. This adds one DB read on every creation request to check the dedup table first, return the existing code if found, otherwise create a new one. At 11.5 writes/sec the overhead is trivial; at millions of writes/sec you'd shard the dedup table on a hash of the long URL.

05b

API design

The two endpoints are simple, but the design choices within each one are worth articulating.

The two core endpoints

EndpointMethodPurpose
/shortenPOSTCreate a new short URL
/:codeGETRedirect to the original URL

POST /shorten, create a short URL

Request:

// POST /shorten
{
  "long_url": "https://example.com/very/long/path?with=params",
  "custom_alias": "my-link",    // optional
  "expires_at": "2025-12-31T00:00:00Z"  // optional
}

Response:

// 201 Created
{
  "short_url": "https://short.ly/abc123",
  "short_code": "abc123",
  "long_url": "https://example.com/very/long/path?with=params",
  "expires_at": "2025-12-31T00:00:00Z",
  "created_at": "2025-03-28T10:00:00Z"
}
Why POST and not GET for URL creation? Idempotency and caching ›

GET requests are by definition idempotent and cacheable. Browsers, proxies, and CDNs feel free to cache them. A creation operation is neither. Submitting the same GET request twice could create two short codes (with Approach B). Using POST signals to the entire HTTP stack that this request has side effects and must not be cached or replayed automatically.

The response is 201 Created rather than 200 OK, a small detail that signals a resource was created and allows the Location header to point to the new resource if needed.

Input validation: what to check Security and correctness ›

Five things to validate on every creation request, in order of importance:

URL validity. Reject malformed URLs that would never resolve. Check for valid scheme (http:// or https://), reject bare IP addresses if you want to block direct IP linking, enforce a maximum length (URLs over ~2,000 chars cause issues in some browsers).

SSRF / egress control. If anything in your stack ever fetches long_url server-side (preview generation, malware scan), block private, link-local, and loopback ranges plus the cloud metadata IP (169.254.169.254). A hostname check at submit time isn't enough — DNS rebinding lets a name resolve public during validation and private at fetch time — so enforce this at the resolver/fetch layer, not just on the string you were handed.

Custom alias availability and safety. Check against reserved words (api, health, admin, blog), profanity blocklist, and existing aliases. Return 409 Conflict if taken.

Rate limiting. Limit creation requests per API key and per IP. URL shorteners are frequently abused for spam and phishing; without rate limiting a single actor can generate millions of short codes.

Expiry sanity. If expires_at is provided, reject values in the past and values beyond a maximum retention window (e.g. 10 years).

⚖ Tradeoff Malware and phishing URL checks (e.g. Google Safe Browsing API) should be async. Never block the creation response on an external API call. Flag the URL for review and serve a warning interstitial on redirect until the check completes.

GET /:code, redirect to the original URL

// GET /abc123
// → 302 Found
// Location: https://example.com/very/long/path?with=params
// Cache-Control: no-store  (prevents browser caching for analytics)

// → 404 Not Found  (code doesn't exist or has expired)
// → 410 Gone       (code existed but was deliberately deleted)
💡

410 Gone vs 404 Not Found. A small detail that signals senior thinking. A 404 means "never heard of it." A 410 means "this existed and was intentionally removed." For deleted short URLs, 410 is the semantically correct response and tells crawlers to permanently remove the URL from their index. Most implementations just use 404 everywhere. Mentioning 410 is a differentiator.

Optional endpoints worth mentioning

EndpointMethodPurposeLevel
/urls/:codeGETRetrieve metadata for a short URL (owner only)L4
/urls/:codeDELETEDelete a short URLL4
/urls/:code/analyticsGETClick stats: counts, geo breakdown, referrersL5
/urlsGETList all short URLs for the authenticated userL5
/bulk/shortenPOSTCreate multiple short URLs in one requestL5
06

Redirect flow & 301 vs 302

The redirect is the highest-volume operation in the system. Every millisecond saved here compounds across billions of requests. The choice between 301 and 302, first flagged in the analytics NFR in Section 2, gets resolved here.

CLIENT APPLICATION STORAGE GET short.ly/abc123 client HTTP request API server parse short code cache-aside logic every store read and write is issued from here lookup HIT SET · write back the API writes, not the DB MISS row Redis cache GET "abc123" ~1 ms · 80%+ of reads Database SELECT long_url WHERE code=? only on a miss 302 Found Location: long_url both success paths end here 404 Not Found short code does not exist Why 302, not 301 301 — the browser caches the redirect, so later clicks never reach the server. 302 — every click reaches the server, so the analytics stay complete.
Figure 3: Redirect request flow. Every store access is issued by the API server — including the write back into Redis, which is what makes this cache-aside rather than read-through. The hit is the common case and never touches the database.

The 301 vs 302 decision: closing the loop from Section 2

In Section 2 we established analytics as a requirement and flagged it as eventually consistent. We just need the clicks to be recorded, not instantly. That single NFR resolves this choice.

Status codeBrowser behaviorServer loadAnalytics
301 PermanentCaches redirect locally. Future clicks bypass your serverMinimal after first visit❌ Clicks not logged. Browser redirects silently
302 TemporaryRe-requests on every click. No local cachingEvery click hits your server✓ Full analytics. Every click is a server request
✓

The decision: analytics is a stated requirement, so 302 is the only valid default. Using 301 would silently discard click data after the first visit per browser. Offer 301 as an explicit opt-in per URL for users who want maximum redirect speed and have no need for click analytics. This is also the correct answer to "why not just use 301 for performance?" You'd be trading a stated requirement for a marginal latency saving on repeat visitors.

07

Data model

Don't reach for a schema immediately. Start by identifying the entities, then reason about the access patterns. The schema follows naturally from those two things.

Step 1, What entities do we have?

Think about the nouns in the system. A URL mapping is the core entity: a short code pointing to a long URL. A user may own that mapping (for management, analytics, custom aliases). A click event captures each redirect for analytics. That's it. Three entities, no more.

Step 2, What are the access patterns?

Each entity gets used very differently, and those differences end up shaping the schema more than anything else:

OperationFrequencyQuery shape
Redirect: look up long URL by short codeExtremely high (every click)Point lookup by short_code
Create: insert a new short URLLow (100× less than reads)Single-row insert
Analytics: count/group clicks per URLMedium (dashboard queries)Range scan by url_id + time
Owner lookup: list URLs by userLow (management UI only)Range scan by user_id
Expiry sweep: find expired URLsVery low (background job)Range scan by expires_at

Step 3, What does this tell us about the schema?

Two things jump out immediately. First, the redirect lookup dominates every other operation by two orders of magnitude. The short_code must be indexed, and that index must stay small and fast. Second, click data has completely different access patterns from URL data: it is append-only, queried by time range, and will grow without bound. These two things should not live in the same table, and at scale they should not live in the same database.

💡

A common early mistake is putting click counts directly on the urls row as a counter column. This creates a write hotspot. Every redirect hammers the same row with an UPDATE. Separate the concerns: keep urls append-mostly (created once, rarely updated) and handle click aggregation in the analytics pipeline.

The schema that follows from this reasoning

OLTP · SYSTEM OF RECORD urls idBIGINT PK short_codeVARCHAR(8) UNIQUE ★ long_urlTEXT user_idBIGINT FK created_atTIMESTAMP expires_atTIMESTAMP NULL is_customBOOLEAN click_countBIGINT † ★ the redirect index — a point lookup on every click † batch-updated by the pipeline, never on the redirect users id · email · created_at · api_key Indexes urls(short_code) UNIQUE every redirect deliberately not indexed: expires_at urls is append-mostly — created once, rarely updated. click_count is the one exception, and the analytics pipeline owns it. 1 : N url_id → urls.id L4 · CLICKS AS A TABLE clicks idBIGINT PK url_idBIGINT FK clicked_atTIMESTAMP ip_hashVARCHAR(64) salted country_codeCHAR(2) referrerTEXT INDEX (url_id, clicked_at) filter first, range second L5+ · CLICKS AS A PIPELINE No clicks table at all. Kafka the redirect publishes, then returns Flink / Spark aggregates the stream ClickHouse MergeTree ORDER BY (url_id, clicked_at) S3 Parquet raw archive, partitioned by date
Figure 4: Data model. The schema is a conclusion, not a given. The part that changes shape most sharply with level is the click data: a table in the same database at L4, and no table at all from L5 up, where the pipeline replaces it. Saying which one you are drawing is most of the answer.

Why each field and index?

The schema above is a conclusion, not a given. Expand each section to understand why each column and index was chosen, and what would break without it.

urls.short_code (VARCHAR(8) UNIQUE) The most critical column ›

This column carries the most important index in the system. Every redirect, the dominant operation, is a point lookup by short_code. A unique B-tree index on it gives O(log n) lookups in the worst case, but with a hot working set in the buffer pool, it is effectively O(1) in practice.

Why VARCHAR(8) not VARCHAR(255)? Index pages are fixed-size. Narrower columns pack more entries per page, which means more of the index fits in memory. For a table that could hold hundreds of millions of rows, this difference matters. 8 characters comfortably covers a 7-character Base62 code with one spare byte.

Why store both id and short_code? The id is the internal primary key used for joins (e.g., clicks.url_id). The short_code is the external lookup key. Keeping them separate means your internal row identity is decoupled from the URL encoding scheme. If you ever change encoding strategies, you don't need to rewrite foreign keys across the database.

⚖ Tradeoff A UNIQUE constraint adds a write overhead, every insert must check for collisions. At 11 writes/sec this is trivial. At extreme write scale (millions/sec) you'd shard the index, which distributes the uniqueness check but makes it non-atomic. For this system, UNIQUE on a single-shard scale is the right default.
urls.expires_at (TIMESTAMP NULL) Nullable by design ›

Most URLs never expire, so the column is nullable. A NULL value means "no expiry". This avoids storing a sentinel value like 9999-12-31 which looks awkward and can confuse application code.

Do not index this column on the main table. Expiry sweeps are a background job running infrequently, they don't need sub-millisecond performance. Adding an index on expires_at would slow down every insert for negligible gain. If sweep performance becomes a concern, run the job against a replica, or maintain a separate lightweight table of (short_code, expires_at) that is cheap to scan.

How does this interact with the cache? When a URL is created with an expiry, the cache TTL should be set to min(24h, expires_at − now). This ensures the cache entry naturally falls away when the URL expires, without needing a proactive invalidation sweep.

urls.click_count (BIGINT) A deliberate denormalisation ›

Strictly speaking, this is redundant. You can derive the total click count with SELECT COUNT(*) FROM clicks WHERE url_id = ?. But at scale that query becomes expensive if the clicks table has billions of rows. A denormalised counter on the urls row enables instant display of a click count in the UI.

Why not update it synchronously on every redirect? This is the counter hotspot problem. If a URL receives 10,000 clicks per second, you'd have 10,000 concurrent UPDATE urls SET click_count = click_count + 1 statements fighting for a row lock. The right approach is to update it periodically via the analytics pipeline, batch-aggregate click events in Kafka, then apply a single UPDATE once per minute per URL. Eventual consistency is acceptable here; users don't need sub-second click count precision.

⚖ Tradeoff This counter will be slightly stale, typically lagging the analytics pipeline by 30–60 seconds. Document this in your API response (e.g., a count_updated_at timestamp). Callers who need exact real-time counts should query the analytics store directly.
The clicks table: why it's separate Different access patterns, different store ›

clicks is append-only and grows without bound. A URL shortener with 1M new URLs/day and a 100:1 read:write ratio generates 100M click events per day (about 1,000 rows per second, continuously). Within a year that's 36 billion rows. That volume is incompatible with a traditional RDBMS table sitting alongside the operationally critical urls table.

The solution: separate the store at the infrastructure level. At L4, putting clicks in a separate table in the same DB is acceptable, and you should say so. At L5 and above, clicks stops being a table you write to at all: the redirect publishes the event to Kafka and returns, Flink aggregates it, ClickHouse serves the dashboard, and S3 Parquet holds the raw archive partitioned by date for ad hoc queries and backfill (Section 9 walks the pipeline stage by stage). A wide-column store like Cassandra or Bigtable is a defensible answer for raw clicks in isolation, but once the Kafka pipeline exists it is a third store with no query of its own — so pick one and say which.

Why store ip_hash not ip_address? A salted hash is pseudonymised, not anonymised — GDPR (Recital 26) still treats it as personal data, since whoever holds the salt can re-link it, and IPv4's ~4.3B addresses are brute-forceable against a known salt anyway. What hashing actually buys you is reduced blast radius if the store leaks and a cleaner data-minimisation story, not an exemption from scope — a rotating salt and short retention window do the real work. Say this precisely; claiming the hash "isn't personal data" is the kind of overclaim that costs points at L6+.

⚖ Tradeoff Separating clicks into a different store means you lose foreign-key guarantees, making orphaned click records for deleted URLs possible. Handle this at the application layer: when a URL is deleted, publish a tombstone event to the analytics pipeline so downstream consumers can filter or purge the associated clicks.
Index on clicks(url_id, clicked_at) Composite ordering matters ›

The dominant analytics query is: "give me all clicks for URL X between time A and time B." A composite index (url_id, clicked_at) handles this perfectly. The leftmost column (url_id) filters down to a specific URL's rows, and the rightmost column (clicked_at) supports efficient range scans within that set.

Why not (clicked_at, url_id)? Reversing the order would make the index useful for "give me all clicks across all URLs in a time range", a much less common query. Always index in the order that matches your most frequent access pattern: filter first, range second.

At large scale, this index itself becomes enormous — and it stops being the thing that answers the query. Once clicks move to the pipeline, the same access pattern is served by a ClickHouse MergeTree with ORDER BY (url_id, clicked_at): the sort key does the work the composite index did, and the columnar layout means a per-URL time-range scan reads only the columns the dashboard asked for rather than whole rows.

Schema decisions by level

L4 Identify the three entities, list the access patterns, arrive at the schema. Explain the short_code unique index and why it is the most important one.

L5/L6 Separate clicks into a write-optimised store. Discuss sharding urls on short_code via consistent hash (Section 9 explains it). Mention the ip_hash vs raw IP tradeoff.

L7/L8 Discuss the clicks archival pipeline: hot tier (ClickHouse, last 30 days) vs cold tier (S3 Parquet, partitioned by month). Raise the dual-write risk during migration and how to handle it with a feature flag and backfill job. Discuss tombstone events for deleted URLs.

08

Caching strategy

A URL shortener caches the short_code → long_url mapping in two layers: an L1 in-process cache on each API server (~1,000 entries, 1-second TTL) for ultra-hot keys, and an L2 Redis cluster (LRU eviction, 24-hour TTL) covering ~20% of the active working set — the slice that, per the power-law numbers in Section 3, serves ~80% of redirects with no database I/O.

Sections 3 and 4 established why the cache exists and why it's non-optional. This section covers the how: how many layers, what belongs in each, how eviction works, and what to do when data changes.

What gets cached

The short_code → long_url mapping. Small (~500 bytes), written once, rarely updated, and queried on every single redirect. It is close to a perfect cache candidate.

Cache hierarchy

LayerLocationHit latencySizeEviction
L1 (local)Each API server process memory~0.1 ms~1,000 entriesTTL = 1 second
L2 (distributed)Redis Cluster~1 ms20% of working setLRU + TTL = 24h
L3 (database)PostgreSQL / Cassandra~10 msFull dataset—

Expand the sections below for the reasoning behind each layer.

Why L1 (local) in addition to L2 (Redis)? Not redundancy: different failure modes ›

Redis handles most traffic well, but a single viral URL can saturate the network connection to the specific Redis shard holding that key, even if Redis itself is healthy. The L1 local cache on each API server handles this case: requests served from L1 never touch the network at all. With a 1-second TTL, each API server effectively rate-limits its own outbound Redis traffic to at most one request per key per second, regardless of incoming load.

The two layers also fail independently. If Redis goes down, L1 continues serving hot entries for up to 1 second, during which a circuit breaker (trips after a failure threshold, then fails fast) can activate and route misses directly to the DB rather than hammering a failing Redis. Without L1, a Redis failure immediately becomes a DB flood.

⚖ Tradeoff The 1-second L1 TTL means a deleted URL can still be served by in-flight L1 caches for up to 1 second. For most cases this is fine. If sub-second deletion propagation is required (e.g. legal takedown), you'd need a pub/sub invalidation broadcast to all API servers. This is a meaningful complexity increase that's only justified for a small subset of URL shorteners.
LRU eviction vs TTL: why both? Different problems, different tools ›

LRU eviction handles memory pressure: when the cache fills up, the least recently used entries are dropped to make room. TTL handles correctness: entries that haven't been evicted are still expired after 24 hours, preventing perpetually stale data from accumulating for obscure URLs that were cached long ago and never requested since.

They solve different problems and you need both. LRU alone means a long-tail URL cached six months ago stays forever, even if the underlying long URL was changed. TTL alone means a popular URL that's accessed every second still gets evicted after 24 hours and causes an unnecessary DB read.

For URLs with an expiry date, override the TTL with the min(24h, expires_at − now) formula from Section 7, so the cache entry never outlives the URL itself.

Cache invalidation on write Rare but must be immediate ›

URL updates and deletions are uncommon; users rarely change a short URL once created. But when they do happen, the cache must be invalidated immediately or you risk serving a stale (or deleted) URL for up to the TTL duration. The correct approach is synchronous invalidation: on any write to the urls table, issue a DEL short_code to Redis as part of the same operation (not as a background job). If the DEL fails, the write should still succeed (the TTL will eventually clean it up), but log the failure and alert.

This is a case where cache invalidation is simpler than it looks. Because short_code is the exact cache key, there's no partial invalidation, no cache stampede (concurrent misses on one hot key all hitting the DB at once), and no need for versioning. Delete the key; the next request repopulates it from the DB.

09

Deep-dive scalability L5+

The high-level architecture in Section 4 works well at moderate scale. This section covers the decisions that come under pressure at 10× and 100×. These are the ones you need to reason about at Senior level and above. The diagram shows the full expanded system; the sections below explain the reasoning behind each scaling choice.

EDGE APPLICATION STORAGE ANALYTICS · ASYNC Clients DNS + CDN edge GeoDNS · anycast routing Load balancer L7 HTTP · health checks API server 1 API server 2 API server N Stateless Stateless Stateless · autoscaled cache first · DB on miss Redis cluster Sharded · LRU eviction L2 shared cache Database cluster Primary + read replicas Sharded on short_code click events (async) Kafka Durable buffer · 7d replay Flink / Spark Aggregates per URL / hour ClickHouse Dashboard queries raw events S3 Parquet Raw archive · backfill
Figure 5: The same system at 10× and 100×, in four tiers. Every API server takes the identical path to storage — cache first, database on miss — and the analytics tap is dashed because it never sits in the redirect's critical path.
Database sharding: why and on what key? Shard key choice is the whole game ›

As established in capacity estimation, the urls table at moderate scale (~900 GB) fits on a single node and doesn't need sharding for capacity. You shard when read throughput exceeds what a single primary + replica set can serve, or when you need geographic distribution for latency.

The shard key is short_code, using consistent hashing. The reasoning is direct: every redirect is a point lookup by short_code, so sharding on it ensures each request goes to exactly one shard, with no cross-shard queries and no scatter-gather aggregation (fanning a query out to every shard and merging results). The alternative (sharding on user_id) would be wrong: it optimises for the management UI (low traffic) at the expense of the redirect path (the dominant operation).

⚖ Tradeoff Consistent hashing distributes load evenly in theory, but hot shards emerge when a small number of short codes receive disproportionate traffic. Mitigate with virtual nodes (many hash ring slots per physical shard) to spread load, and with the L1/L2 cache absorbing the truly viral URLs before they reach the DB at all.
Distributed ID generation: Zookeeper ranges vs Snowflake IDs Keep it off the hot path ›

A global auto-incrementing counter is the simplest ID generator but a single point of contention at scale. Two patterns eliminate that contention:

Zookeeper range allocation: each API server pre-fetches a range of IDs (e.g. 1,000–2,000) from Zookeeper at startup. It burns through them locally with no network calls. When exhausted, it fetches the next range. Zookeeper sees only one request per thousand URLs created, effectively removing it from the hot path. The risk: if a server crashes mid-range, those IDs are lost. Given the key space is 3.5 trillion, a few thousand lost IDs is irrelevant.

Snowflake IDs: a 64-bit value composed of an unused sign bit (1), a timestamp (41 bits), datacenter ID (5 bits), machine ID (5 bits), and per-machine sequence (12 bits) — 1+41+5+5+12 = 64. Entirely self-contained, requiring no coordination or single point of contention. The tradeoff is clock skew: if a machine's clock jumps backward, ID monotonicity breaks. Handle with a clock guard that refuses to generate IDs if the system clock regresses.

Which to choose Zookeeper ranges are simpler to reason about and sufficient for most cases. Snowflake IDs are better for truly distributed multi-datacenter setups where you want no coordination dependency at all.
Analytics pipeline: why this sequence of components? Each stage has a specific job ›

The stand, stated once. Click events never enter an OLTP store. The redirect publishes to Kafka and returns; everything downstream is a consumer. Cassandra and DynamoDB appear in this design only as the large-scale alternative to PostgreSQL for urls, never for clicks. Quietly mixing the two is the most common way this answer loses coherence.

The pipeline is Kafka → Flink/Spark → ClickHouse → S3, and each stage exists for a specific reason. Kafka is the durable buffer: it decouples the API servers (producers) from the analytics processors (consumers), absorbs traffic spikes without backpressure, and retains events for 7 days allowing replay if a consumer has a bug. Flink or Spark Streaming aggregates raw click events into per-URL, per-hour, per-country counts, which is the shape your analytics dashboard actually queries. ClickHouse stores the aggregated metrics and answers dashboard queries in milliseconds due to its columnar storage format. S3 Parquet holds the raw events, partitioned by date, as the long-term archive for ad hoc queries and backfill.

Why not write directly from Kafka to ClickHouse? You could, but the aggregation step in Flink is important: it reduces 100M raw click rows/day to a far smaller set of pre-computed aggregates, keeping ClickHouse fast and cheap. Without it, every dashboard query would scan billions of rows.

Geo-distribution: what breaks at global scale? Replication lag is the core tradeoff ›

GeoDNS routes users to the nearest regional cluster, reducing redirect latency from ~100 ms (cross-continent) to ~5–10 ms (local region). Each region gets its own Redis cache and a read replica of the DB. URL creation writes always go to the global primary, and it is the API tier that routes them there — a regional API server holds two connections, a local one for reads and one to the primary for writes. The replica never forwards anything; it accepts no writes at all. Say this explicitly, because "writes go to the primary" leaves open the wrong implementation, where the regional database is expected to proxy them. active-active writes are off the table for the urls table, to avoid split-brain (both regions accepting conflicting writes), since two regions could otherwise mint the same short_code for different URLs. Click events have no such conflict: each region publishes to its own local Kafka cluster and the aggregates merge downstream, so the analytics path really is active-active even though the write path is not.

User in Frankfurt User in Virginia GeoDNS + anycast resolves to the nearest healthy region REGION · EU-WEST REGION · US-EAST · PRIMARY Edge + L7 load balancer Edge + L7 load balancer API servers · stateless API servers · stateless every create routes to the primary Redis Redis regional cache regional cache Read replica Global primary accepts no writes urls · all writes reads that miss the replica streaming replication · async not in the commit path Sync standby other AZ commit waits for this fsync Kafka · regional Kafka · regional aggregates merge downstream aggregates merge downstream
Figure 6: Geo-distribution. Writes are routed by the API tier, not by the database — an EU-West API server talks straight to the US-East primary, because the replica beside it accepts no writes. Durability is settled inside US-East before the async link to EU-West carries anything.

"Async replication" and "durable" are answers to different questions. The cross-region link is asynchronous, but nothing about the commit depends on it. A create is durable the moment the primary and its synchronous standby — same region, different availability zone — have both flushed the WAL record, and that happens before EU-West hears about the row at all. The async replica buys read latency and a warm disaster-recovery target; it buys no durability, and it was never in the commit path. What it does cost you is RPO on losing the entire home region: writes committed but not yet shipped are gone, bounded by the replication lag. Continuous WAL archiving to object storage in a third region is the backstop that shrinks that window to the archive interval instead of the lag (see the durability deep-dive below). Making EU-West synchronous instead would put a cross-ocean flush — 80–150 ms — on every create and make EU an availability dependency of the write path: if EU became unreachable, writes on the primary would stall. For a shortener that is a bad trade in both directions.

The failure mode to reason through: a URL is created in region A, and a user in region B clicks it 200 ms later, before the async replication has propagated the new row to region B's replica. The correct behaviour is not a 404 but a fallback: on a cache miss that also misses the local replica, the read path falls through to the global primary. This adds latency for that one request but guarantees correctness. P99 replication lag between regions is typically under 100 ms, so this fallback is rare in practice.

⚖ Tradeoff Fallback to the global primary works, but if the primary is in a distant region (e.g. US East while the user is in Singapore) that fallback read is slow. Mitigate by pre-warming the cache: when a URL is created, proactively push the mapping to all regional caches via a pub/sub broadcast, rather than waiting for the first cache miss per region to trigger the DB read.
Durability: what actually guarantees a mapping is never lost The one thing you cannot lose ›

Section 2 asserted that a lost URL mapping is unrecoverable while a lost click event is not. That asymmetry is a requirement, not a mechanism. Here is the mechanism, and it is four layers deep — candidates who say "we use a database, databases are durable" stall at L4.

1 — The commit. By default PostgreSQL acknowledges a commit once the WAL record is on the primary's disk. That is durable against a process crash but not against losing the node. Setting synchronous_commit = on with synchronous_standby_names = 'ANY 1 (s1, s2)' holds the acknowledgement until the record has also reached a standby. Name at least two candidate standbys: with a single named standby, losing it stalls every write on the primary, which converts a redundancy outage into a total one.

2 — The write-ahead log. fsync = on and full_page_writes = on are what make the WAL meaningful. Turning either off to buy write throughput means a power loss leaves a torn page and an unrecoverable cluster. This also assumes the storage layer honours fsync rather than acknowledging from a volatile write cache — worth one sentence in an interview, because it is the layer most people never check.

3 — Point-in-time recovery. Replication is not backup. A bad migration or an accidental DELETE replicates to every standby in milliseconds, perfectly. Continuous WAL archiving plus periodic base backups gives you restore-to-timestamp, which is the only defence against logical corruption. Keep the archive in a different failure domain from the cluster — a different account or project, not just a different bucket.

4 — Proof. An unrestored backup is a hypothesis. Run an automated weekly restore into a scratch cluster and assert a row count and a few known short_code lookups. The restore drill is also how you learn your real RTO, which is almost never the number in the design doc.

Spend the budget where it matters. The rest of the system is deliberately less durable, and you should say so out loud. Redis is a cache with no system-of-record role — losing it costs latency, not data. Kafka gets acks=all, replication.factor=3, min.insync.replicas=2, but the redirect path publishes fire-and-forget, so a click event can genuinely be lost in a broker-side outage; that is the relaxation Section 2 signed up for. S3 holds the raw archive with versioning enabled. Four different durability postures, each argued from the requirement rather than applied uniformly, is what the question is actually testing.

⚖ Tradeoff Synchronous commit costs you a network round trip plus an fsync on the create path — roughly 1–3 ms across availability zones, and ten times that across regions. So keep the synchronous standby in the same region and a different AZ, and make the cross-region replica asynchronous. This bounds RPO at zero for a node or AZ loss and at the replication lag for losing the whole region, which is the right place to draw the line for a shortener.
10

Failure modes & edge cases

Proactively raising these signals L5+ thinking. Don't wait for the interviewer to probe; mention them as you walk through the design.

ScenarioProblemSolutionLevel
URL expiry Expired URLs still served until TTL expires Background sweep job + proactive cache invalidation on expiry, using the TTL formula from Section 7. L4
Same long URL submitted twice Creates duplicate short codes Hash approach dedups naturally; counter approach needs the optional lookup table from Section 5. L4
Viral / hot-key URL Single Redis key overloaded L1 local in-process cache (1s TTL) on each API server. Reduces Redis traffic by 100–1000×. L5
Custom alias conflicts User requests an alias that's taken or reserved Check against a blocklist (reserved words, profanity). Rate limit per user. Return 409 on conflict. L5
Malicious URLs Short URL masking phishing / malware Async Google Safe Browsing API check on creation. Flag URL, show warning interstitial page. Never block the write path synchronously. L5/L6
SSRF via long_url long_url points at an internal host or the cloud metadata endpoint; any server-side fetch of it becomes a confused deputy into the private network Block private/link-local/loopback ranges and 169.254.169.254 at the resolver/fetch layer, not just on submit — DNS rebinding defeats a submit-time hostname check. L5
Redis cache failure All traffic falls through to DB Circuit breaker on the cache layer. DB can handle the fallback load with read replicas. Alert and restore quickly. L5
Analytics data loss Kafka consumer lag / consumer crash Kafka retention of 7 days. Consumer group offset management. Idempotent click writes: the API server stamps each event with a generated event_id (UUID) before publishing, and the consumer dedups on it. The clicks.id primary key is assigned on write, so it cannot serve this purpose. L5
Multi-region consistency Redirect arrives before replication completes Fall back to global primary on regional cache miss. Accept eventual consistency on reads; never accept it on writes. L7/L8
11

How to answer by level

The same question, "design a URL shortener", has a different correct answer depending on the level being evaluated. What changes isn't the system; it's the frame. Lower levels are evaluated on correctness and coverage. Higher levels are evaluated on judgment, tradeoff reasoning, and how independently you drive the conversation.

L4 SDE II Can you build a working system? ›
What good looks like
  • Arrives at the high-level architecture without being led
  • Chooses Base62 encoding and explains the math
  • Identifies short_code as the critical index
  • Puts Redis in front of the DB without being asked
  • Resolves 301 vs 302 with a clear justification
  • Produces plausible capacity numbers
Common gaps
  • No cache layer, DB handles all reads
  • Analytics written synchronously in the redirect path
  • Hash collisions not addressed
  • No index discussion at all
  • 301 vs 302 not mentioned
  • Skips capacity estimation entirely
L5 Senior SDE Do you understand the tradeoffs you made? ›
What good looks like
  • Identifies tradeoffs in their own design unprompted
  • Explains why PostgreSQL before Cassandra, not just which
  • Raises hot-key problem and the L1 cache solution
  • Designs the Kafka analytics pipeline end-to-end
  • Discusses sharding strategy and shard key choice
  • Connects NFRs back to specific architectural choices
What separates L4 from L5
  • L4 names components; L5 justifies them
  • L4 waits to be asked about edge cases; L5 raises them
  • L4 picks Cassandra because it "scales"; L5 explains when not to
  • L4 describes the cache; L5 reasons about hit rates and eviction
  • L4 knows 302 is correct; L5 connects it back to the analytics NFR
L6 Staff SDE Can you own this system end-to-end? ›
What good looks like
  • Surfaces hidden requirements before the interviewer does: GDPR, multi-tenancy, abuse
  • Discusses operational concerns: deploy strategy, SLOs, alerting, on-call burden
  • Proposes a migration path: how you'd move from v1 to v2 safely
  • Identifies organisational dependencies (who owns the analytics store?)
  • Reasons about the system under failure, not just the happy path
  • Drives the conversation and asks better questions than the interviewer
What separates L5 from L6
  • L5 designs the system; L6 designs the system and its lifecycle
  • L5 raises edge cases; L6 raises cross-cutting concerns across teams
  • L5 answers the interviewer's questions well; L6 changes the questions
  • L5 discusses operational topics when asked; L6 introduces them naturally
L7 / L8 Principal / Distinguished (Google L8) Should we be building this, and how? ›
What good looks like
  • Challenges the requirements themselves: why 99.99%, what's the cost of each additional nine?
  • Reframes the problem space: custom domains, white-label, multi-region regulatory partitioning
  • Introduces long-horizon thinking: 5-year capacity, platform extensibility, API versioning
  • Connects technical decisions to business outcomes explicitly
  • Identifies the highest-risk assumptions and proposes how to validate them early
  • Discusses build vs buy vs adopt (managed Kafka vs Kinesis vs in-house)
What separates L6 from L7/L8
  • L6 owns a system; L7/L8 shapes what systems get built
  • L6 proposes good solutions; L7/L8 questions whether the solution space is right
  • L6 thinks about SLOs; L7/L8 thinks about the business model behind the SLO
  • L6 designs for current scale; L7/L8 designs for optionality at future scale
  • The interviewer at this level is a peer. The conversation is collaborative, not evaluative

Classic probes: how the expected answer shifts by level

The same question has a different correct answer depending on who's being evaluated. The depth of reasoning is the signal, not the conclusion. Each probe below points back at the section that works it through.

Probe L4 L5/L6 L7/L8
"What if the same URL is submitted twice?"Section 5 Describes a dedup lookup on long_url before insert, so the same input returns the same short code Notes that hashing gets dedup for free while counter + Base62 needs an explicit index on long_url — and prices that index, which is roughly as large as the table it protects. Still picks the counter, because collision handling is the worse problem Questions whether dedup should be the default at all. Separate codes per submission is what makes per-campaign attribution possible, so this is a product decision the data model has to encode, not a storage optimisation
"Someone shortens http://169.254.169.254/latest/meta-data/. What happens?"Sections 5b, 10 Validates the scheme and rejects anything that isn't http or https Spots that it only bites if something server-side ever fetches the URL, then blocks private, link-local and loopback ranges plus the metadata IP — enforced at the resolver or fetch layer, because a hostname check at submit time loses to DNS rebinding Names it as a confused-deputy problem rather than a validation bug, and puts the control in a shared egress proxy owned by one team, so every service that fetches user-supplied URLs inherits it instead of re-implementing it
"Why 302 and not 301?"Sections 2, 6 Knows 301 is permanent and 302 is temporary, and that 302 keeps bringing the request back to the server Derives it instead of recalling it: analytics is a stated NFR, a 301 is cached by the browser so later clicks never reach you, therefore 302 is the only default consistent with the requirement. Offers 301 as a per-link opt-in Prices both sides. 301 passes link equity to the destination and drops redirect traffic to near zero after the first visit — Bitly's branded-link choice. Frames it as a per-product default, and notes a 301 already in the wild is effectively unrecallable
"Where do click events actually live?"Sections 7, 9 A clicks table in the same database, one row per redirect, indexed by url_id Says the switch out loud: at scale clicks stops being a table you write to. The redirect publishes to Kafka and returns, Flink aggregates, ClickHouse serves the dashboard, S3 Parquet holds the raw archive — and can say why each stage earns its place Notices that bolting a wide-column store onto that pipeline leaves a third store with no query of its own, and commits to one answer. Then discusses hot/cold tiering and the dual-write risk during migration
"How do you expire URLs without a full table scan?"Sections 7, 10 Background job that sweeps for expired rows; set cache TTL to min(24h, expires_at − now) Run the sweep against a read replica to avoid impacting the primary; maintain a lightweight (short_code, expires_at) index table to make the sweep cheap Questions whether expiry is enforced at the DB layer at all. At scale, soft-delete with async cleanup via a Kafka tombstone event is more operationally tractable than synchronous row deletion
"Walk me through a cache miss"Section 8 Cache miss → DB lookup → cache the result → return redirect Same, but notes the stampede risk: if 10,000 requests miss simultaneously for the same cold key, all hit the DB at once. Mitigate with a per-key mutex or probabilistic early expiration Distinguishes cold miss (key never cached) vs expired miss (key evicted) vs invalidated miss (key explicitly deleted). Each has different frequency and mitigation strategy
"The primary's disk dies one millisecond after you return 201. What did you lose?"Section 9 Says the database is durable and a replica takes over Answers with the commit path: nothing, provided synchronous_commit held the acknowledgement until the WAL record reached a standby. Names at least two candidate standbys, because a single named standby stalls every write the moment it dies Separates durability from recoverability. Replication faithfully replicates a bad DELETE, so WAL archiving and point-in-time recovery are what cover logical corruption — and since an unrestored backup is only a hypothesis, the answer is the restore drill, not the config line
"A link created in Virginia is clicked in Frankfurt 200 ms later."Section 9 Notes the regional replica may not have the row yet, so the user could get a 404 A 404 is the wrong answer: on a miss in both the regional cache and the regional replica, fall through to the global primary. Slow for that one request, correct every time — and rare, since P99 replication lag is typically under 100 ms Fixes the latency rather than accepting it: broadcast the new mapping to every regional cache on create, so the first click in a region never pays the cross-ocean read. Also keeps this visibility question separate from durability — the async cross-region link was never in the commit path
"What's the biggest bottleneck?"Sections 3, 9 The database, as it has to serve every redirect The DB at low scale (solved by caching), then ID generation at high scale (solved by Zookeeper ranges or Snowflake IDs) Depends on traffic shape, explains the analysis before answering. Then reframes: at true scale the bottleneck is often organisational (who owns the on-call, which team controls the analytics store)
12

Numbers to know

Every architectural decision in this article is downstream of about fifteen numbers. Not one of them needs to be precise. What earns credit is knowing which number settles which argument: the reason a cache is mandatory rather than nice-to-have, the reason one Redis node is genuinely enough, the reason clicks never shares a store with urls.

The pattern to practise is a single move: name the number, then name the decision it closes. "Redis answers in well under a millisecond, the budget is 10 ms at p99, so the cache isn't optional" is a complete argument in one breath. Reciting latency tables without attaching them to a choice is not.

Latency: the numbers that set the p99

NumberValueWhat it settles
L1 in-process cache read ~0.1 ms, no network Why L1 exists on top of Redis. A viral key saturates the network path to one Redis shard long before Redis itself is busy, and an L1 hit never touches the network at all (§8)
Redis GET, same data centre <1 ms p99 Whether you need a cache at all. The <10 ms p99 redirect budget has room for a sub-millisecond lookup and almost nothing else (§2, §8)
Indexed point read on a warm DB 1–5 ms typical, ~10 ms+ at p99 Why the database cannot be the redirect path. The median is fine; the tail is what blows the budget, because p99 is dominated by queueing, lock contention and GC pauses rather than by the read itself (§2, §8)
Round trip within one AZ ~0.2 ms That network hops inside a region are free at this budget. Stack the API tier, cache and DB however the availability story requires; the hops don't cost you the p99
Round trip across AZs ~0.5 ms That synchronous_commit against a standby in another AZ is affordable. Durability on the write path costs a fraction of a millisecond, which at 11.5 writes/sec is nothing (§9)
Round trip across continents
us-east-1 → eu-central-1
~90 ms Two things at once. That the synchronous commit partner must be cross-AZ and never cross-region, and that GeoDNS plus regional replicas are structural rather than cosmetic: they turn a ~90 ms redirect into a ~5–10 ms one (§9)
Async replication lag, same continent <100 ms at p99 The read-your-write question. A link created in Virginia and clicked in Frankfurt 200 ms later is usually already replicated, but "usually" isn't a design: a regional miss falls through to the global primary rather than returning a 404 (§9)

Throughput ceilings: the numbers that decide how many boxes

These are the ones that answer "is a single node enough?" — the question that separates an architecture sized to the traffic from one sized to the diagram.

NumberValueWhat it settles
Redis, single node ~100k simple GET/sec Why one Redis node is enough. Peak read traffic is 5,750 QPS, under 6% of a single node. You are nearly two orders of magnitude from needing Redis Cluster for throughput, so if you cluster, say plainly that it's for failover and not for load (§3, §8)
Redis working set 1–10 GB The same question from the memory side. The hot 20% fits in RAM on one unremarkable instance, which is why the cache layer stays simple at this scale (§3)
One DB primary, indexed point reads order 104/sec When you add read replicas versus when you shard. At an 80% hit rate the DB sees only the misses, roughly 1,150 QPS at peak, comfortably inside one primary. Sharding here is an availability and geography decision, never a capacity one (§9)
One Kafka broker ~100 MB/sec sustained That the analytics tap is never the bottleneck. 100 M clicks/day at ~100 bytes is about 115 KB/sec average: four orders of magnitude of headroom, which is why fire-and-forget publishing on the redirect path is safe (§9)

Volume: the numbers that decide what goes where

NumberValueWhat it settles
Seconds per day 86,400 ≈ 105 Every QPS conversion you'll do out loud. Divide a daily figure by 100,000 and you're within 15%: 1 M/day → ~11.5/sec (§3)
Write QPS 11.5/sec How much you're allowed to spend on the write path. A dedup lookup, a synchronous commit, a Zookeeper range fetch every 1,000 IDs — all of it is free at this rate. Complexity on writes is cheap; complexity on reads is not (§3, §5)
Read:write ratio 100:1 The character of the whole system. At 10:1 you'd question whether a cache earns its complexity; at 100:1 it's obvious; at 1000:1 it's the only thing between you and a dead database (§2, §3)
Peak multiplier ~5× average What you actually size for. Provision against 5,750 read QPS, not the 1,150 average, because traffic is never flat (§3)
Hit rate from the hot 20% 80%+ Every DB number downstream of it. URL access follows a power law, so caching a fifth of the working set absorbs four fifths of the traffic — which is what reduces peak DB load to ~1,150 QPS (§3, §8)
urls over 5 years 1.8 B rows, ~900 GB That you don't shard for storage. This fits one modern node with room to spare, so proposing shards on day one is complexity bought with nothing (§3, §9)
clicks at 100:1 100 M rows/day, ~3.6 TB/year The single most consequential comparison in the article. Against 900 GB over five years for urls, it's why click events never enter the OLTP store and why the analytics pipeline exists as a separate system (§3, §7, §9)
Base62 key space 627 ≈ 3.5 trillion Code length. Seven characters at 1 M URLs/day is thousands of years of headroom, and still about a decade at 1 B/day — so 7 is a considered choice, not a guess (§5)
✓

How to use these in the room. Three habits separate a number that lands from one that doesn't:

  • Round hard, then move. 11.5 writes/sec and "about 10 a second" buy the identical conclusion. Spending twenty seconds on the decimal signals you've mistaken arithmetic for the answer.
  • Say the assumption before the number. "Assuming 1 M/day and 100:1" makes the figure auditable, and lets the interviewer redirect you early instead of after you've built on it.
  • Attach the number to a decision immediately. An unattached figure reads as recall. A figure that closes an argument reads as judgement, and it's the same figure either way.

The failure mode to avoid is quoting a ceiling you can't defend. If you say a Redis node does 100k ops/sec, be ready for "how do you know?" — the honest answer is that it's a simple single-key lookup on one core, and that you'd verify with redis-benchmark before sizing anything real on it.

How the pieces connect

Every architectural decision in this article traces back to a small number of observations established early on. The chain is worth assembling in one place:

  • 1 Analytics is a stated NFR (§2) → 302 is the only valid default → 301 survives only as an explicit per-URL opt-in (§6)
  • 2 <10 ms p99 on a cache hit (§2) → a database round trip cannot hold that p99 → Redis is mandatory, not optional (§8)
  • 3 100:1 read:write ratio (§3) → power-law access distribution → 20% cache covers 80%+ of traffic → 1–10 GB Redis is sufficient (§3, §8)
  • 4 Analytics consistency can be eventual (§2) → analytics writes can be async → Kafka pipeline never blocks the redirect path (§4, §9)
  • 5 Redirect is a point lookup by short_code → shard key must be short_code → consistent hash on short_code routes every request to one shard (§7, §9)
  • 6 URLs need durability (§2), clicks need write throughput (§3) → different consistency requirements → urls stays in an OLTP store, clicks never enters one (§7, §9)
  • 7 “No silent loss of URL mappings” (§2) → the write must be on disk in two places before the 201 → a synchronous standby in another AZ, not the asynchronous cross-region replica (§9)

System Design Mock Interviews

AI-powered system design practice with real-time feedback on your architecture and tradeoff reasoning.

Coming Soon

Practice Coding Interviews Now

Get instant feedback on your approach, communication, and code — powered by AI.

Start a Coding Mock Interview →
Also in this series