{}The Interview
Handbook

Tracks / System Design

Case studies — URL shortener, feed, chat, rate limiter

staff 5 questions · 6 min read system-designcase-study

Questions in this set 5
  1. 01Design a URL shortener (TinyURL / bit.ly).
  2. 02Design a news feed (Twitter/Instagram home timeline).
  3. 03Design a chat system (WhatsApp / Slack).
  4. 04Design a distributed rate limiter.
  5. 05What questions should you ask the interviewer in a design round?
01

Design a URL shortener (TinyURL / bit.ly).

Requirements. Shorten a URL, redirect, optional custom alias, optional expiry, click analytics. 100M new URLs/month, 10:1 read:write.

Estimates. 100M/month ≈ 40 writes/s, 400 reads/s (peak maybe 4k). 500 bytes/record × 100M/month × 5 years ≈ 3 TB. Hot set is small — a few percent of links get almost all traffic, so caching is very effective.

Key generation. Options: (a) hash the URL (MD5/SHA) and take 7 base62 characters — must handle collisions with a retry-and-rehash loop; (b) a distributed counter encoded in base62 — no collisions, but sequential ids are enumerable, so scrape-proofing needs obfuscation; (c) pre-generate a table of unused keys and hand them out — no collision checks at request time, and my preferred answer. 62^7 ≈ 3.5 trillion keys.

Storage. A key-value store is a perfect fit (short_key → long_url, owner, expires_at). Postgres is entirely adequate at this scale with an index on the key; DynamoDB/Cassandra if you want no capacity planning.

Redirect path. Read from cache (Redis, LRU) → database on miss → 301 (permanently cached by browsers, which loses your analytics) or 302 (every click hits you — usually what you want). Put the whole thing behind a CDN with a short TTL.

Analytics. Do not write a row per click synchronously. Fire an event to Kafka, aggregate in a stream processor, store rollups. This is where the design gets interesting, and where an interviewer will push.

Follow-ups to be ready for: custom aliases (unique constraint, reserve a namespace), expiry (TTL + a background sweeper), abuse (Safe Browsing checks, rate limits per account), and analytics at 100x (sampling, approximate counts with HyperLogLog).

02

Design a news feed (Twitter/Instagram home timeline).

The core question is fan-out on write vs on read.

  • Fan-out on write (push) — when a user posts, push the post id into every follower's precomputed timeline (a Redis list, capped at ~800 entries). Reads are O(1) and fast. Writes are expensive: a user with 50M followers generates 50M list writes.
  • Fan-out on read (pull) — at read time, query the recent posts of everyone you follow and merge. Cheap writes, expensive reads, and merging 5,000 followees per request is not viable at scale.

The real answer is hybrid, and saying so is the point of the question: push for ordinary users, and for celebrity accounts (above some follower threshold) do not fan out — instead, at read time, merge the precomputed timeline with the celebrity posts of the accounts you follow. This bounds both sides.

Components. Post service → Kafka → fan-out workers → timeline cache (Redis sorted set keyed by user, score = timestamp or a ranking score) → timeline service that hydrates post ids into full posts from a post store + cache. Media goes to object storage behind a CDN.

Ranking. Chronological is easy; ranked feeds add a scoring service (engagement prediction) reading features from a feature store, usually applied to a candidate set of a few hundred at read time.

Bottlenecks. Fan-out lag during spikes (queue depth is the metric), Redis memory (cap timeline length, evict inactive users), and the hot-key problem for a viral post (replicate that key across shards or cache at the app layer).

03

Design a chat system (WhatsApp / Slack).

Connections. Persistent WebSockets, so you need a connection layer that holds millions of long-lived sockets — separate from stateless HTTP services, because deploys and autoscaling behave completely differently. A session registry (Redis) maps user_id → gateway_node, so a message for a user can be routed to the node holding their socket.

Message flow. Client → gateway → message service: persist first (durability before ack), then push to the recipient's gateway via the registry, or enqueue for offline delivery. The client gets sent; recipients' devices ack delivered and read as separate events. Idempotency: the client generates a message id so retries do not duplicate.

Storage. Messages are an append-only, time-ordered, per-conversation workload — a wide-column store partitioned by (conversation_id, bucket) with clustering on message_id DESC, or a partitioned Postgres table. You almost never query across conversations, which is exactly what Cassandra is good at.

Ordering. Per-conversation ordering only; use a per-conversation sequence number assigned server-side rather than client clocks. Global ordering is neither achievable nor needed.

Group chat. For small groups, fan out to each member's inbox. For large channels (Slack's 100k-member channels), switch to fan-out on read with a per-channel log and a per-user read cursor — the same hybrid insight as the feed.

Extras interviewers like: presence (heartbeats with a TTL in Redis; presence is best-effort and eventually consistent), typing indicators (ephemeral, never persisted), end-to-end encryption (the server stores ciphertext; key exchange via the Signal protocol; this also means no server-side search), and push notifications via APNs/FCM when the socket is absent.

04

Design a distributed rate limiter.

Requirements. Limit each API key to N requests per window, across many application servers, with low added latency (< 1 ms ideally), and fail open or closed by policy.

Algorithm. Token bucket — allows bursts up to the bucket size while enforcing an average rate, and it needs only two numbers per key (tokens, last-refill timestamp).

lua
-- Redis + Lua: atomic read-refill-consume, one round trip
local tokens, ts = unpack(redis.call('HMGET', KEYS[1], 'tokens', 'ts'))
local now, rate, cap, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
tokens = math.min(cap, (tonumber(tokens) or cap) + (now - (tonumber(ts) or now)) * rate)
local allowed = tokens >= cost
if allowed then tokens = tokens - cost end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(cap / rate) * 2)
return { allowed and 1 or 0, tokens }

Where it runs. At the edge/gateway for cheap rejection before you spend backend capacity, with a second layer in the service for correctness. A local token bucket per instance with periodic sync to Redis cuts latency and Redis load at the cost of approximate limits — a legitimate trade-off to offer.

Failure policy. If Redis is unreachable: fail open for user-facing traffic (do not take the site down to enforce a limit) and fail closed for expensive or abusable endpoints (login, export, anything that costs money). Being able to argue both sides is the point.

Response contract. 429 with Retry-After and RateLimit-Limit/Remaining/Reset headers so well-behaved clients can back off instead of hammering.

Follow-ups: per-user and per-IP and per-endpoint limits (take the minimum); distributed fairness across tenants; sliding-window counters for smoother enforcement at window boundaries; and the difference between rate limiting (protecting capacity) and quota (billing), which are separate systems with separate storage.

05

What questions should you ask the interviewer in a design round?

Before designing: "What scale are we targeting — and is this day one or year three?" · "Which of these features is core and which can I defer?" · "How consistent must X be — is a few seconds of staleness acceptable?" · "Is this a greenfield system or does it live alongside existing services?" · "Is there a latency budget?"

During: "I see two options here — do you want me to go deep on this one, or cover more breadth first?"

These are not filler. An interviewer's scoring rubric almost always includes "clarified requirements before designing", and asking them is the cheapest way to earn that mark.