Skip to main content
Interview Preparation beginner Lesson 4 of 10

System Design Interview Basics

The 45-minute structure, back-of-envelope sizing that changes the design, and the tradeoffs you volunteer — worked on one question end to end.

System design rounds are graded on structure and tradeoffs, not on knowing an architecture by heart. This lesson works one question through the sequence.

The question

“Design a URL shortener — like bit.ly.”

Deliberately simple, because the round is about how you reason, not the difficulty.

Minutes 0-8: requirements and sizing

Ask before drawing. Separate functional from non-functional:

FUNCTIONAL
  shorten a long URL → short code
  redirect a short code → original URL
  custom aliases?              ask
  expiry?                      ask
  analytics on clicks?         ask — this changes the write path completely

NON-FUNCTIONAL
  read:write ratio?            ask — shorteners are extremely read-heavy
  scale?                       ask
  latency target?              redirects are user-facing: <100 ms
  availability vs consistency? a redirect failing is worse than a stale one

Assume the answers: 100M new URLs/month, 100:1 read:write, custom aliases yes, no expiry, click counts needed but may lag.

Then the arithmetic, out loud:

WRITES_MONTH = 100_000_000
READ_WRITE   = 100
SECONDS_MONTH = 30 * 86_400

write_qps = WRITES_MONTH / SECONDS_MONTH
read_qps  = write_qps * READ_WRITE

print(f"write QPS (avg)   {write_qps:>10,.0f}")
print(f"read  QPS (avg)   {read_qps:>10,.0f}")
print(f"read  QPS (peak)  {read_qps*3:>10,.0f}   (3x for daily peak)")

BYTES_PER_ROW = 500          # long URL + code + metadata
storage_year = WRITES_MONTH * 12 * BYTES_PER_ROW
print(f"\nrows after 5 years {WRITES_MONTH*12*5:>14,}")
print(f"storage after 5 yrs ${'':<1}{storage_year*5/1024**4:>12.2f} TB")
write QPS (avg)           39
read  QPS (avg)        3,858
read  QPS (peak)      11,574

rows after 5 years  6,000,000,000
storage after 5 yrs         2.73 TB

Now the design is constrained by facts:

“39 writes a second is nothing — a single Postgres node handles that comfortably. 11,500 reads a second at peak is the real problem, and 2.7 TB over five years fits on one machine but not comfortably with that read load. So this is a read-scaling problem, not a write-scaling problem, which means caching and read replicas rather than sharding the writes.”

That sentence is worth more than the next ten minutes of drawing.

Size the code space too, because it is the one genuinely interesting design decision:

import string
alphabet = len(string.ascii_letters + string.digits)     # 62
for length in (5, 6, 7, 8):
    combos = alphabet ** length
    years = combos / (WRITES_MONTH * 12)
    print(f"{length}-char code: {combos:>18,} combinations → {years:>8,.1f} years of capacity")
5-char code:        916,132,832 combinations →      0.8 years of capacity
6-char code:     56,800,235,584 combinations →     47.3 years of capacity
7-char code:  3,521,614,606,208 combinations →  2,934.7 years of capacity
8-char code: 218,340,105,584,896 combinations → 181,950.1 years of capacity

“Seven characters gives essentially unlimited headroom; six is enough for 47 years but I would not want to be the person who has to migrate. I’d go with seven.”

Minutes 8-20: the high-level design

                       ┌─────────────┐
   client ────────────►│   CDN /     │  (redirects are cacheable — 301 vs 302 matters)
                       │  edge cache │
                       └──────┬──────┘
                              │ miss
                       ┌──────▼──────┐
                       │ load balancer│
                       └──────┬──────┘
              ┌───────────────┴───────────────┐
       ┌──────▼──────┐                 ┌──────▼──────┐
       │ write service│                │ read service│  (separate: 100:1 ratio)
       │  POST /urls  │                │  GET /{code}│
       └──────┬───────┘                └──────┬──────┘
              │                               │
              │                        ┌──────▼──────┐
              │                        │ Redis cache │  code → long URL
              │                        └──────┬──────┘
              │                               │ miss
       ┌──────▼───────────────────────────────▼──────┐
       │  primary DB  ──async replication──►  read   │
       │  (writes)                            replicas│
       └───────────────────────────────────────┬─────┘

                                      ┌─────────▼────────┐
                                      │ click events →   │
                                      │ queue → analytics│  (async — never block redirect)
                                      └──────────────────┘

Decisions to state as you draw:

DecisionReasonAlternative
Separate read and write services100:1 ratio — scale them independentlyone service, simpler, wasteful
Cache in front of the DBreads dominate and the data is immutableno cache — needs far more replicas
Read replicasspread 11.5k QPSsharding — unnecessary at this write rate
Async click countinga redirect must not wait on an analytics writesynchronous — adds latency and a failure mode
Postgres39 writes/s fits easily; transactions for alias uniquenessCassandra if writes grew 1000×

The immutability point is worth making explicitly: “Once a short code is created it never changes, so cache invalidation — normally the hard part — does not exist here. That is why a cache is such a good fit, and I’d set a long TTL.”

The one real algorithm question

“How do you generate the short code?”

import hashlib, base64, random, string

LONG = "https://example.com/some/very/long/path?with=query&params=true"

# 1. hash and truncate
h = hashlib.sha256(LONG.encode()).digest()
print("hash-based    ", base64.urlsafe_b64encode(h)[:7].decode())

# 2. random
print("random        ", "".join(random.choices(string.ascii_letters + string.digits, k=7)))

# 3. counter + base62
def to_base62(n):
    chars = string.digits + string.ascii_letters
    out = ""
    while n:
        n, r = divmod(n, 62)
        out = chars[r] + out
    return out or "0"

for counter in (1, 1_000_000, 3_521_614_606_207):
    print(f"counter {counter:>16,}{to_base62(counter):>8}")
hash-based     3xK9mQ2
random         7fRt2Wq
counter                1 →        1
counter        1,000,000 →     4c92
counter 3,521,614,606,207 →  ZZZZZZZ

The comparison is the answer:

ApproachCollisionsGuessableDistributed
Hash + truncatepossible — must check and retrynoyes, stateless
Randompossible — must check and retrynoyes, stateless
Counter + base62impossible by constructionyes — sequentialneeds coordination

“A counter is collision-free but sequential codes are enumerable, which leaks how many URLs exist and lets someone crawl them. For a public shortener that matters. I’d use a counter for uniqueness but not expose it directly — either encrypt the counter with a fixed key so the output is unguessable but still bijective, or hand out pre-allocated ranges to each write node so there is no coordination on the hot path.”

The range-allocation detail is what a distributed-counter follow-up is fishing for:

class KeyRange:
    """Each write node claims a block of counters, so no per-write coordination."""
    def __init__(self, start, size=10_000):
        self.next, self.end = start, start + size

    def take(self):
        if self.next >= self.end:
            raise StopIteration("range exhausted — claim another block")
        self.next += 1
        return to_base62(self.next - 1)

r = KeyRange(1_000_000)
print([r.take() for _ in range(5)])
['4c92', '4c93', '4c94', '4c95', '4c96']

“One coordinated write every 10,000 URLs instead of every URL.”

Minutes 20-32: the deep dive

The interviewer picks a component. Two common ones:

“Walk me through a redirect.”

budget_ms = 100
path = [
    ("DNS + TLS (cached)",        5),
    ("CDN lookup",                2),
    ("load balancer",             1),
    ("Redis GET",                 2),
    ("emit click event (async)",  0),
    ("HTTP 302 response",         1),
]
print(f"{'step':<28} {'ms':>4}")
for s, ms in path:
    print(f"{s:<28} {ms:>4}")
print(f"{'TOTAL (cache hit)':<28} {sum(m for _, m in path):>4}   budget {budget_ms}")
print(f"{'cache miss adds DB read':<28} {'+8':>4}")
step                          ms
DNS + TLS (cached)             5
CDN lookup                     2
load balancer                  1
Redis GET                      2
emit click event (async)       0
HTTP 302 response              1
TOTAL (cache hit)             11   budget 100
cache miss adds DB read       +8

The detail worth volunteering: “301 versus 302 is a real decision. A 301 permanent redirect is cached by the browser, so subsequent clicks never reach us — great for load, and it destroys click analytics because we never see the request. If analytics matter, it has to be 302, and that is a cost I’d make explicit rather than discovering later.”

“What happens when the cache is cold?”

CACHE_HIT = 0.95
peak_reads = 11_574
print(f"peak reads/s              {peak_reads:>8,}")
print(f"reaching DB at 95% hit    {peak_reads*(1-CACHE_HIT):>8,.0f}/s")
print(f"reaching DB at 0% hit     {peak_reads:>8,}/s   ← cache restart / eviction storm")
peak reads/s                11,574
reaching DB at 95% hit         579/s
reaching DB at 0% hit       11,574/s   ← cache restart / eviction storm

“A cold cache is a 20× jump in database load, which is how a cache restart takes down the database it was protecting. Mitigations: warm the cache from the top-N codes on startup, stagger TTLs so keys do not expire together, and use request coalescing so a thousand simultaneous misses for the same code become one database read rather than a thousand.”

Naming the thundering-herd problem before being asked is a strong signal.

Minutes 32-40: bottlenecks and failure modes

Go through them briskly, without waiting:

failures = [
    ("Redis down",        "all reads hit the DB — 20x load", "read replicas absorb it; circuit breaker"),
    ("primary DB down",   "writes fail, reads continue",     "redirects still work from replicas + cache"),
    ("replica lag",       "new URL 404s for a moment",       "write-through the cache on create"),
    ("hot key",           "one viral link saturates a shard","CDN handles it; it is cacheable"),
    ("analytics backlog", "click counts lag",                "acceptable — decoupled by design"),
    ("code collision",    "two URLs, same code",             "unique constraint; retry on conflict"),
    ("abuse / spam",      "malware links",                   "rate limit per account, URL scanning"),
]
print(f"{'failure':<20} {'impact':<34} mitigation")
for f, i, m in failures:
    print(f"{f:<20} {i:<34} {m}")
failure              impact                             mitigation
Redis down           all reads hit the DB — 20x load    read replicas absorb it; circuit breaker
primary DB down      writes fail, reads continue        redirects still work from replicas + cache
replica lag          new URL 404s for a moment          write-through the cache on create
hot key              one viral link saturates a shard   CDN handles it; it is cacheable
analytics backlog    click counts lag                   acceptable — decoupled by design
code collision       two URLs, same code                unique constraint; retry on conflict
abuse / spam         malware links                      rate limit per account, URL scanning

The replica lag row is the one that shows care: “If a user creates a link and immediately opens it, an async replica may not have it yet and they get a 404 on their own link. Writing to the cache at creation time fixes it, and it is a two-line change that would otherwise be a confusing bug report.”

Minutes 40-45: tradeoffs

Name the ones you made, and what would change them:

Consistency        eventual for reads — a redirect that is a second stale is fine,
                   a redirect that fails is not. Availability over consistency here.

301 vs 302         302 chosen, accepting more traffic, because analytics were a
                   stated requirement. If they were not, 301 and let browsers cache.

Postgres           chosen because 39 writes/s fits one node and I want a unique
                   constraint for custom aliases. At 1000x writes I'd move to a
                   partitioned store and give up cross-shard uniqueness checks.

Counter vs random  counter for guaranteed uniqueness, encrypted so the output is
                   not enumerable. Random is simpler but needs a collision check
                   on every write, which is a read before every write.

And close with what you would push back on:

“I’d ask whether custom aliases are really needed. They force a uniqueness check on the write path, which is the only thing preventing a fully stateless write service. If they are for a small number of paying customers, I would put them in a separate table with different guarantees rather than constraining the whole system for a 1% case.”

What is actually being scored

BehaviourSignal
Asked for scale before drawingstrong
Did arithmetic that changed the designstrong
Named the read:write ratio as the defining constraintstrong
Volunteered failure modes unaskedstrong
Gave alternatives with each choicestrong
Pushed back on a requirement, with a reasonstrong
Correct architecture, tradeoffs only when promptedmid
Drew boxes for 30 minutes, no numbersweak
Proposed Kafka, Cassandra and Kubernetes for 39 writes/sweak — over-engineering

Over-engineering is worth calling out. Reaching for a distributed store when the arithmetic says one node is enough reads as not having done the arithmetic.

The reusable skeleton

1. Functional requirements     what it does
2. Non-functional              scale, latency, consistency, availability
3. Back-of-envelope            QPS, storage, growth — and what it implies
4. API                         2-3 endpoints, request/response shape
5. Data model                  tables/collections, keys, indexes
6. High-level design           the boxes, with a reason for each
7. Deep dive                   whichever component they pick
8. Bottlenecks                 where it breaks first, and why
9. Failure modes               each component down, and what degrades
10. Tradeoffs                  what you chose, what would change it

If you are 25 minutes in and still on step 6, skip detail and get to 8-10. That is where the round is decided.

Practice

1. Size the system before designing it.
write QPS 39   read QPS (peak) 11,574   storage 2.73 TB / 5 years

39 writes a second means one database. Naming this stops you designing a sharded write path nobody needs.

2. Work out how long each code length lasts.
6-char: 47.3 years    7-char: 2,934.7 years

The one genuinely interesting decision in this design, answered with arithmetic rather than a guess.

3. Compute the database load when the cache is empty.
95% hit rate →    579 reads/s at the DB
 0% hit rate → 11,574 reads/s

A 20× jump. This is how a cache restart takes down the database it was protecting, and naming it unprompted is a strong signal.

4. Decide 301 vs 302 and say what it costs.
301: browser caches → less load, no analytics
302: every click reaches us → analytics, more load

A one-character difference with a system-wide consequence. Interviewers ask specifically to see whether you know it.

Next: coding interview patterns — the handful that cover most problems.

Frequently Asked Questions

How should I spend the time in a system design interview?
Roughly 8 minutes on requirements and sizing, 12 on the high-level design, 12 on a deep dive the interviewer picks, 8 on bottlenecks and failure modes, and 5 on tradeoffs. Candidates who spend 30 minutes drawing boxes and never reach failure modes score poorly regardless of the architecture.
Do I need to do back-of-envelope calculations?
Yes, and they should change what you draw. Whether the write rate is 100/s or 100,000/s decides whether one database is enough. Two minutes of arithmetic turns an opinion into a decision, and interviewers explicitly look for it.
How much detail should I give about specific technologies?
Name a choice, give one reason, and name the alternative you rejected. 'Postgres because we need transactions and the volume fits one node; Cassandra if writes outgrew it, accepting eventual consistency' shows a decision. A list of technologies with no reasons reads as a résumé.
What if I do not know a component well?
Say what it needs to do and what properties it must have, then name what you would use and admit the limit of your knowledge. 'I'd use a message queue here for back-pressure and replay — I've used Kafka; I know SQS is simpler operationally but I've not run it at this scale' is honest and scores fine.