The 60 system design questions interviewers actually ask, with direct answers, ASCII architecture sketches, worked estimation math, and what the interviewer is listening for. Grouped into foundations, building blocks, and design walkthroughs.
60 questions with answersKey Takeaways
A system design interview is an open-ended discussion where you design a large software system, a URL shortener, a news feed, a chat app, while an interviewer probes your reasoning. Unlike a coding round, there's rarely one correct answer. You're evaluated on how you clarify vague requirements, estimate the scale you're designing for, sketch a high-level architecture, then go deep on the hard parts and The trade-offs you accepted. The building blocks recur no matter the prompt: load balancers spread traffic, caches cut latency, databases store state (and get sharded when one machine can't hold it), and message queues decouple services so a slow consumer can't stall a fast producer. Underneath sits a small set of hard truths, the CAP theorem, consistency models, the physics of latency, that shape every choice. AWS publishes the Well-Architected Framework covering these same pillars (reliability, performance, cost, security, operations), and it's a good canonical reference for the vocabulary the question expects. This page collects the 60 questions that come up most, each with a direct answer, and for the classic prompts a clarify to components to hard-problem to trade-offs outline you can rehearse. Increasingly the first design conversation happens inside an AI-led interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: System Design Interview: A Step-By-Step Guide
Video: System Design Interview: A Step-By-Step Guide (ByteByteGo, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your System Design certificate.
The concepts every design round assumes you know: what's being evaluated, how to estimate scale, and the physics that shape every choice. If any answer here surprises you, that's your study list.
It evaluates judgment under ambiguity, not memorized architectures. The interviewer watches how you turn a vague prompt into concrete requirements, estimate the scale you're designing for, choose components, and defend the trade-offs you accepted.
There's rarely one right answer. Two candidates can design the same system differently and both pass, if each clarifies scope, reasons out loud, and names what they gave up. Silence and jumping straight to boxes-and-arrows are the two most common ways to fail.
Key point: The interviewer is a collaborator, not an examiner. Think out loud, check assumptions with them, and treat it like a design discussion with a senior colleague.
Watch a deeper explanation
Video: System Design Interview: Step By Step Guide (System Design Interview, YouTube)
Five phases in order. Clarify the requirements (functional and non-functional, scale, read/write mix). Estimate the load with back-of-the-envelope math. Sketch a high-level design of the main components. Deep-dive on the one or two hard parts the interviewer steers toward. Then discuss trade-offs, bottlenecks, and what you'd do next.
Timing matters: spend real minutes on clarify and estimate before drawing anything. A design that solves the wrong problem cleanly still fails.
How a 45-minute round usually splits
Let the interviewer redirect you. Where they push is where the points are; follow their interest into the deep dive.
Key point: Narrate which phase you're in ('let me clarify requirements first'). It shows structure and keeps you from skipping the phase that scores highest.
Functional requirements are what the system does: shorten a URL, post a message, return search results. Non-functional requirements are how well it does it: how many users, how fast, how available, how consistent, how much data.
Non-functional requirements drive the architecture. 'Support 1,000 users' and 'support 100 million users' are the same functional spec but completely different designs. Pin these numbers down before you draw anything.
| Type | Question it answers | Examples |
|---|---|---|
| Functional | What must it do? | Shorten URLs, redirect, show analytics |
| Scale | How much load? | Daily active users, reads/sec, writes/sec |
| Performance | How fast? | p99 latency target, throughput |
| Availability | How reliable? | Uptime target, acceptable downtime |
Key point: Always ask for the read-to-write ratio early. A read-heavy system (like a news feed) and a write-heavy one need opposite optimizations.
Round everything to clean powers of ten and multiply out loud. Take a service with 100 million daily active users, each making 10 requests a day. That's 100,000,000 x 10 = 1,000,000,000 requests per day (10^9).
Convert to per second: a day is 86,400 seconds, round to 100,000 (10^5). So 10^9 / 10^5 = 10^4 = about 10,000 requests per second average. Peak is usually 2 to 3 times average, so design for roughly 20,000 to 30,000 QPS. Every number here is self-contained arithmetic, not a claimed real-world figure.
# Estimation is just arithmetic on round numbers.
daily_active_users = 100_000_000 # 10^8, an assumption
requests_per_user_per_day = 10
requests_per_day = daily_active_users * requests_per_user_per_day
# = 1_000_000_000 (10^9)
seconds_per_day = 100_000 # 86,400 rounded to 10^5
avg_qps = requests_per_day // seconds_per_day
# = 10_000 (10^4)
peak_qps = avg_qps * 3 # peak is ~2-3x average
# = 30_000 -> design for thisKey point: Memorize the round numbers: a day is ~10^5 seconds, a month is ~2.5 x 10^6. State your assumptions out loud so a wrong guess doesn't sink the whole estimate.
Multiply items per day by size per item, then by how long you keep them. Say you store 1,000,000 new records a day and each record is 1,000 bytes (1 KB). That's 1,000,000 x 1,000 = 10^9 bytes = 1 GB per day.
Over five years: 1 GB/day x 365 x 5 is about 1,825 GB, round to roughly 2 TB. Add replication (3 copies is common) and you're near 6 TB. Now you know whether this fits one machine or needs sharding, which is the whole point of the estimate.
records_per_day = 1_000_000 # 10^6
bytes_per_record = 1_000 # 1 KB
bytes_per_day = records_per_day * bytes_per_record
# = 1_000_000_000 = 1 GB
days = 365 * 5 # 5 years
total_gb = (bytes_per_day * days) // (10**9)
# ~1825 GB -> ~2 TB
with_replication = total_gb * 3 # 3 copies
# ~5.5 TB -> too big for one node, plan to shardKey point: The output of a storage estimate is a decision: one machine or many. If the number crosses a few terabytes, you've just motivated sharding before anyone asked.
Latency intuition tells you where the time goes so you optimize the right thing. Reading from memory is on the order of nanoseconds; reading from SSD is hundreds of microseconds; a network round trip in the same datacenter is under a millisecond; a cross-region round trip is tens to a hundred milliseconds.
The gap is enormous: memory is roughly a million times faster than a cross-continent network hop. That single fact explains caching, why you co-locate services, and why chatty designs that make many sequential network calls feel slow no matter how fast each service is.
Latency of memory vs disk vs network (log scale)
Illustrative round numbers in nanoseconds, so a single scale holds. Memory is nanoseconds; a cross-region network round trip is milliseconds, roughly a million times slower.
Key point: You don't need exact numbers, just the orders of magnitude and their ratios. 'Memory is nanoseconds, network is milliseconds' is enough to reason correctly out loud.
Vertical scaling means a bigger machine: more CPU, RAM, disk on one box. It's simple, needs no code changes, and avoids distributed complexity, but it hits a hard ceiling (the biggest machine money can buy) and is a single point of failure.
Horizontal scaling means more machines behind a load balancer. It scales almost without limit and tolerates node failure, but it forces you to handle statelessness, data partitioning, and coordination. Most large systems go vertical first for simplicity, then horizontal when they outgrow one box.
| Vertical (scale up) | Horizontal (scale out) | |
|---|---|---|
| How | Bigger single machine | More machines |
| Ceiling | Hardware limit | Effectively unlimited |
| Failure | Single point of failure | Survives node loss |
| Complexity | Low, no code changes | High, needs partitioning |
Key point: Say 'scale up first, scale out when you must.' Reaching for a distributed cluster on day one when a bigger box would do is a classic over-engineering flag.
Watch a deeper explanation
Video: System Design BASICS: Horizontal vs. Vertical Scaling (Gaurav Sen, YouTube)
A stateless service keeps no client-specific data between requests, so any instance can handle any request. That's what makes horizontal scaling and load balancing work: you can add, remove, or restart instances freely, and a failed node loses nothing.
The state has to live somewhere, so push it out: sessions into a shared cache like Redis, data into databases, files into object storage. The application tier stays disposable while the stateful tiers are managed carefully.
Client
|
v
[Load Balancer]
|----------|----------|
v v v
[App 1] [App 2] [App 3] <- stateless, interchangeable
|__________|__________|
v
[Shared Session Store] <- state lives here (Redis)
|
v
[Database]Key point: The one-line rule: 'push state out of the app tier so instances are disposable.' It explains most of why modern services are designed the way they are.
A load balancer sits in front of a pool of servers and spreads incoming requests across them, so no single server is overwhelmed. It's the piece that makes horizontal scaling usable: clients hit one address, the balancer picks a healthy backend.
It also does health checking (stop sending traffic to a dead node), and it's a natural place for TLS termination and sticky sessions. Common algorithms are round robin, least connections, and hashing by a key.
Requests
|
v
+--------------+
| Load Balancer| health checks + algorithm
+--------------+
/ | \
v v v
[Srv A][Srv B][Srv C]
ok ok DOWN <- LB stops routing to CA Layer 4 (transport) load balancer routes on IP and port. It doesn't inspect the request content, so it's fast and cheap, forwarding packets or TCP connections without understanding HTTP.
A Layer 7 (application) load balancer understands HTTP, so it can route by URL path, host header, or cookie, do content-based routing, and terminate TLS. It's more flexible and more expensive per request. Use L7 when you need smart routing (send /api to one pool, /images to another); use L4 when raw throughput matters most.
| Layer 4 | Layer 7 | |
|---|---|---|
| Routes on | IP and port | HTTP path, headers, cookies |
| Sees content? | No | Yes |
| Speed | Faster, lower overhead | Slower, more work per request |
| Use when | Raw throughput, any protocol | Smart HTTP routing, TLS termination |
Key point: The follow-up is 'when would you pick L4 over L7?'. Answer: when you don't need to inspect HTTP and want maximum throughput or non-HTTP traffic.
A cache stores the results of expensive work (a database query, a computed page) in fast storage so repeat requests skip the work. Because memory is roughly a million times faster than a cross-region network call and far faster than disk, a cache hit turns a slow operation into a fast one.
Caching is the single most effective tool for read-heavy systems. It cuts latency, reduces load on the database, and lets you serve far more traffic from the same backend. The whole discipline is deciding what to cache, where, and how to keep it fresh.
Request
|
v
[Cache] --hit--> return fast (memory: ~nanoseconds)
|
miss
|
v
[Database] --> compute, store in cache, return (slower)Key point: Frame caching as a trade: you trade freshness and memory for latency and throughput. Every cache question is really about that trade.
Watch a deeper explanation
Video: Cache Systems Every Developer Should Know (ByteByteGo, YouTube)
Cache-aside (lazy loading): the app checks the cache, and on a miss reads the database then populates the cache. Simple and only caches what's actually requested, but the first read after any miss is slow, and stale data lingers until it expires.
Write-through: every write goes to the cache and the database together, so the cache is always fresh. Reads are fast and consistent, but writes are slower (two writes) and you cache data that may never be read. Write-back (write to cache, flush to DB later) is faster still but risks data loss on crash.
| Strategy | How it works | Best for / cost |
|---|---|---|
| Cache-aside | App loads cache on miss | Read-heavy; first read is slow, can go stale |
| Write-through | Write to cache + DB together | Consistency; slower writes |
| Write-back | Write to cache, flush to DB later | Write-heavy; risks loss on crash |
| Read-through | Cache loads from DB itself on miss | Simpler app code; cache owns loading |
Key point: Cache-aside is the default answer for read-heavy systems. Mention write-through when the interviewer pushes on consistency of cached data.
Cache invalidation is removing or updating cached data when the underlying source changes, so readers don't see stale values. It's famously hard because the cache and the source are separate, and keeping them in sync across many nodes, with concurrent writes, has real edge cases.
The practical tools: TTLs (expire entries after a set time, accepting bounded staleness), explicit invalidation on write (delete the key when the data changes), and versioned keys. Most systems combine a short TTL with invalidation-on-write, accepting that perfect freshness is expensive.
Key point: Say the honest thing: perfect cache freshness is impossible without giving up the speed the cache exists for. The real question is how much staleness the product can tolerate.
A content delivery network is a fleet of caching servers spread across geographic locations. It stores copies of static content (images, video, CSS, JS) close to users, so a request is served from a nearby edge node instead of crossing the globe to your origin.
Use a CDN for anything static and cacheable, and increasingly for cacheable API responses at the edge. It cuts latency dramatically for a global audience and offloads huge traffic from your origin. The trade-off is invalidation lag when content changes and cost per gigabyte served.
Key point: Whenever a prompt mentions global users or serving images/video, reach for a CDN early. Not mentioning one for a media-heavy system is a visible miss.
Match the database to the data and access patterns, not to fashion. Pick SQL when you need transactions, joins, ad-hoc queries, and strong consistency, and the data is naturally relational: payments, orders, anything where correctness beats raw scale. Pick NoSQL when you have huge write volume, simple key-based access, a flexible or evolving schema, and you can tolerate eventual consistency: activity feeds, session data, logging.
The strong move is to say 'it depends' and then commit. The access pattern, state which property matters most (consistency vs write scale), and justify the pick for this problem. Many real systems use both: SQL for the core transactional data, NoSQL for the high-volume peripheral data.
| Need | Lean SQL | Lean NoSQL |
|---|---|---|
| Transactions across rows | Yes | Usually not |
| Joins and ad-hoc queries | Yes | Awkward |
| Massive write throughput | Harder | Yes |
| Flexible / evolving schema | Rigid | Yes |
Key point: Never answer 'NoSQL because it scales' with no context. the choice maps to the access pattern; that's the difference between a buzzword and a decision.
An index is a separate data structure (usually a B-tree) that lets the database find rows by a column value without scanning the whole table, turning an O(n) scan into an O(log n) lookup. It's the first fix for a slow query that filters or sorts on a column.
The cost is real: every index takes storage and slows writes, because each insert, update, or delete must also update the indexes. So you index the columns you query on, not every column, and you watch write-heavy tables carefully.
Key point: The trade to state out loud: indexes speed reads and slow writes. On a write-heavy table, too many indexes is a bottleneck, not a feature.
CAP says a distributed system can guarantee at most two of Consistency, Availability, and Partition tolerance. But partitions (network failures between nodes) are a fact of life you can't wish away, so partition tolerance isn't optional. That means the real choice, during a partition, is between consistency and availability.
Honestly: when the network splits, do you refuse to answer rather than return possibly-stale data (consistency, a CP system like a bank ledger), or do you keep answering and reconcile later (availability, an AP system like a social feed)? When there's no partition, you get both. CAP is a lens for one specific failure, not a permanent label on a database.
Key point: Avoid the myth 'pick two of three.' The nuanced answer, partitions are unavoidable so it's really C vs A during a partition, signals you actually understand it.
Strong consistency means every read returns the most recent write; there's a single agreed-upon truth. It's the easiest to reason about and the most expensive, because it needs coordination across nodes, which adds latency and hurts availability during partitions.
Eventual consistency means replicas converge over time, so a read might return a slightly stale value briefly, but the system stays fast and available. In between sit models like read-your-writes (you always see your own updates). The choice is per-feature: a bank balance needs strong consistency, a like-count tolerates eventual.
Key point: Consistency is a spectrum you choose per feature, not one setting for the whole system. Naming a feature that's fine with eventual consistency shows practical judgment.
ACID (Atomicity, Consistency, Isolation, Durability) is the guarantee model of traditional relational databases: transactions are all-or-nothing, isolated from each other, and durable once committed. It prioritizes correctness, which is why you want it for money and orders.
BASE (Basically Available, Soft state, Eventual consistency) is the looser model many NoSQL systems adopt to get horizontal scale and availability. It accepts temporary inconsistency in exchange for staying up and fast under load. The pair maps directly onto the SQL-vs-NoSQL and consistency-vs-availability trade-offs.
Key point: ACID/BASE back connects to CAP and SQL/NoSQL rather than reciting the acronyms. the question needs the through-line, not a definition dump.
A single point of failure is any component whose failure takes down the whole system: one database, one load balancer, one instance holding state. Finding and removing them is a core reliability goal.
The fix is redundancy at every tier: multiple app instances behind a load balancer, replicated databases with failover, and even redundant load balancers (an active-passive pair with a floating address). The question to keep asking is 'what happens if this box dies?' until every answer is 'traffic shifts and users don't notice.'
Key point: When you draw an architecture, point at each box and ask 'what if this dies?' out loud. Catching your own single points of failure before the interviewer does is a strong signal.
A forward proxy sits in front of clients and makes requests on their behalf: the servers it talks to see the proxy, not the real client. It's used for outbound control, caching, and anonymity inside a network.
A reverse proxy sits in front of servers: clients think they're talking to it, and it forwards to whichever backend it chooses. That single entry point is where you do load balancing, TLS termination, caching, and rate limiting, which is why a reverse proxy shows up at the edge of almost every system design. A load balancer is one specialized kind of reverse proxy.
Forward proxy: [Client] -> [Forward Proxy] -> [Internet]
(hides the client from the server)
Reverse proxy: [Client] -> [Reverse Proxy] -> [Backends]
(hides the backends; does LB, TLS, caching)Key point: The one-liner: forward proxy fronts clients, reverse proxy fronts servers. The reverse proxy is the one that matters in most design answers.
Normalization splits data into related tables with no duplication, so each fact lives in exactly one place. It keeps writes clean and prevents inconsistencies, but reads often need joins across many tables. It's the default for transactional SQL systems.
Denormalization deliberately duplicates data (or precomputes joined views) so a read hits one place instead of joining. You denormalize read-heavy paths where join cost hurts, accepting that a single fact now lives in several rows you must keep in sync on write. It's a read-latency-for-write-complexity trade, and it's why NoSQL data models often look pre-joined.
| Normalized | Denormalized | |
|---|---|---|
| Duplication | None | Deliberate |
| Reads | Need joins | Single lookup, fast |
| Writes | Simple, consistent | Must update copies |
| Fits | Transactional SQL | Read-heavy paths, NoSQL |
Key point: Denormalization is a read optimization you pay for on writes. Framing it as that trade, not as 'NoSQL is just messy,' is the mature answer.
The reusable parts every design is assembled from: databases at scale, queues, APIs, rate limiting, and the failure-handling patterns that keep systems up. Know these cold and any walkthrough becomes assembly.
Replication keeps copies of the same data on multiple database nodes. The common shape is leader-follower (primary-replica): writes go to the leader, which streams changes to followers that serve reads. This scales reads, provides redundancy, and lets you fail over to a follower if the leader dies.
The catch is replication lag: followers are slightly behind the leader, so a read right after a write can miss it. You handle that with read-your-writes routing (send a user's reads to the leader briefly after they write) or by accepting bounded staleness for reads that can tolerate it.
Writes
|
v
[ Leader ] ---- replicates ----+
| |
(async stream) v
| [Follower 1] <- reads
+------------------> [Follower 2] <- readsKey point: Replication scales reads, not writes. If the prompt is write-heavy, replication alone won't save you, that's what leads into sharding.
Plain hash-mod-N sharding breaks when N changes: add or remove one node and hash(key) % N remaps almost every key, forcing a massive data reshuffle. Consistent hashing fixes that. It maps both keys and nodes onto a ring (a hash space), and each key belongs to the next node clockwise.
When a node joins or leaves, only the keys between it and its neighbor move; the rest stay put. Virtual nodes (many ring positions per physical node) smooth out the distribution. It's the backbone of distributed caches and databases like Cassandra and DynamoDB.
Node A
* . . . key1 -> A
. .
key3 Node B
. *
Node C . . . key2 -> B
*
Adding a node steals only the arc between it and its
successor; every other key keeps its home.Key point: Lead with the problem it solves: 'hash mod N remaps everything when N changes.' Naming the pain before the solution is what makes this answer land.
A message queue is a buffer between a producer that creates work and a consumer that processes it. The producer drops a message and moves on; the consumer pulls it when ready. This decouples the two: a slow or offline consumer can't stall the producer, and a burst of work queues up instead of overwhelming anyone.
It also enables async processing (return to the user instantly, do the slow work later), load leveling (absorb spikes), and retries (redeliver a failed message). The cost is added complexity and eventual, not immediate, processing.
[Producer] --push--> [ Queue: msg msg msg ] --pull--> [Consumer]
^ buffers bursts,
survives slow consumersKey point: Reach for a queue whenever a request triggers slow work the user doesn't need to wait for: sending email, encoding video, updating a feed. 'Do it async' is often the right instinct.
In a plain queue, each message is consumed by exactly one worker: it's for distributing work. In publish-subscribe, a publisher sends to a topic and every subscriber gets its own copy: it's for broadcasting events.
Pub-sub lets many independent services react to the same event without the publisher knowing who they are. An 'order placed' event can simultaneously trigger billing, inventory, email, and analytics, each subscribing independently. It's the backbone of event-driven architectures.
+--> [Billing]
[Publisher] -> (Topic) --> [Inventory]
+--> [Email]
+--> [Analytics]
one event, every subscriber gets a copyKey point: The distinction interviewers check: queue is one-to-one (work), pub-sub is one-to-many (events). Mixing them up is a common tell.
A rate limiter caps how many requests a client can make in a window, protecting the system from abuse and overload. The token bucket algorithm is the common pick: a bucket refills tokens at a fixed rate up to a capacity, each request spends a token, and requests with no token available are rejected. It allows short bursts while enforcing an average rate.
Single process: keep a bucket per user in memory. Distributed across many servers: the state has to move to a shared store like Redis (atomic increment plus expiry, or a Lua script), because per-process counters can't see global traffic. Return 429 with a Retry-After header on rejection.
import time
class TokenBucket:
def __init__(self, rate, capacity):
self.rate, self.capacity = rate, capacity
self.tokens, self.updated = capacity, time.monotonic()
def allow(self):
now = time.monotonic()
# refill based on elapsed time
self.tokens = min(self.capacity,
self.tokens + (now - self.updated) * self.rate)
self.updated = now
if self.tokens >= 1:
self.tokens -= 1
return True
return FalseKey point: Distributed rate limiting is the real follow-up: where does the counter live so every server agrees? 'Redis with atomic operations' is the expected answer.
An operation is idempotent if doing it many times has the same effect as doing it once. It matters because networks are unreliable: a client sends a request, times out without a response, and retries, but the first request may have succeeded. Without idempotency, that retry double-charges a card or creates two orders.
The standard technique is an idempotency key: the client sends a unique key with the request, and the server records it. If the same key arrives again, the server returns the stored result instead of re-executing. Naturally idempotent operations (set X to 5) need no key; side-effecting ones (charge $10) do.
def charge(idempotency_key, amount):
prior = store.get(idempotency_key)
if prior is not None:
return prior # retry: return the same result
result = payment_gateway.charge(amount)
store.set(idempotency_key, result)
return result # first time: do the work onceKey point: Bring up idempotency yourself whenever a design involves payments, orders, or any retryable write. Volunteering it signals real distributed-systems experience.
REST uses HTTP verbs on resources, returns JSON, and is simple, cacheable, and universally understood: the default for public APIs. Its weaknesses are over-fetching (you get the whole resource) and under-fetching (many round trips for related data).
gRPC uses HTTP/2 and binary protobuf, which makes it fast and strongly typed with generated clients: ideal for internal service-to-service calls where performance matters. It's less browser-friendly. GraphQL lets the client request exactly the fields it needs in one query, killing over- and under-fetching, at the cost of server complexity and harder caching. Pick by audience: REST for public, gRPC for internal, GraphQL for rich clients with varied data needs.
| Style | Best at | Watch out for |
|---|---|---|
| REST | Public APIs, simplicity, caching | Over- and under-fetching |
| gRPC | Fast internal service-to-service | Not browser-native, binary |
| GraphQL | Flexible client-driven queries | Server complexity, caching is hard |
Key point: Don't declare one universally best. 'REST for public, gRPC internal, GraphQL for flexible clients' shows you choose by context, which is the whole point.
Short polling (client asks 'anything new?' on a timer) is simple but wasteful: most requests return nothing, and updates lag by the poll interval. Long polling (the server holds the request open until there's data) cuts waste but still reconnects per message.
WebSockets open a single persistent, bidirectional connection, so the server pushes updates instantly with no repeated handshakes: the right choice for chat, live feeds, collaborative editing, anything genuinely real-time and high-frequency. The cost is stateful connections that complicate load balancing and scaling. For occasional updates, polling or server-sent events are simpler.
| Approach | Best for | Cost |
|---|---|---|
| Short polling | Occasional updates, simplicity | Wasteful, laggy |
| Long polling | Near-real-time, simple infra | Reconnect per message |
| WebSockets | Chat, live feeds, high frequency | Stateful connections, harder to scale |
| Server-sent events | One-way server push | One-directional only |
Key point: Match the tool to update frequency. Reaching for WebSockets when a 30-second poll would do is over-engineering; polling for a chat app is under-engineering.
A monolith is one deployable application. It's the right default for a new product or small team: simpler to build, test, deploy, and debug, with fast in-process calls and no distributed-systems tax. The pain comes at scale, one large codebase, coupled deploys, and the whole app scaling together.
Microservices split the system into independently deployable services, letting teams own and scale pieces separately. That autonomy is real, but so is the cost: network latency between services, distributed failures, data consistency across service boundaries, and heavy operational tooling. The honest answer is 'start monolith, extract services when team size and scale actually demand it,' not 'microservices because they're modern.'
Key point: The mature take is 'monolith first.' Proposing microservices for a greenfield small-team system is a classic over-engineering red flag interviewers watch for.
If services share one database, they're coupled through the schema: a change one team makes can break another, and you're back to a distributed monolith with none of the autonomy. Giving each service its own database keeps them independent, so a team can evolve its data model and scale its store without coordinating.
The cost is that you lose cross-service joins and single-database transactions. You get data via APIs or events instead, and you handle multi-service operations with patterns like the saga (a sequence of local transactions with compensating actions on failure). This is where eventual consistency enters most microservice designs.
Key point: Naming the saga pattern for a cross-service transaction is a strong production signal. It shows you know you can't just wrap two services in one ACID transaction.
Observability is your ability to understand what a running system is doing from the outside, which is essential once you have many services and can't just attach a debugger. The three pillars are logs (discrete timestamped events), metrics (aggregated numbers over time like request rate and error rate), and traces (the path of one request across all the services it touched).
Together they answer different questions: metrics tell you something is wrong (error rate spiked), traces tell you where (which service in the chain), and logs tell you why (the exact error). A production design that doesn't mention monitoring and alerting is incomplete.
Key point: Mention observability in practice when wrapping up a design. 'How would we know this is healthy?' is a question senior interviewers love to hear you ask yourself.
Transient failures (a brief timeout, a blip) are worth retrying, but naive retries are dangerous. Retrying immediately and in lockstep across many clients can create a retry storm that hammers a recovering service into staying down. The fix is exponential backoff (wait longer after each failure) plus jitter (randomize the delay) so clients don't all retry at the same instant.
Also cap the number of retries and only retry idempotent operations, otherwise a retry might duplicate a side effect. Retries should be paired with timeouts so a slow call fails fast instead of hanging.
import random, time
def call_with_backoff(fn, max_tries=5):
for attempt in range(max_tries):
try:
return fn()
except TransientError:
if attempt == max_tries - 1:
raise
# exponential backoff + jitter
delay = (2 ** attempt) + random.random()
time.sleep(delay)Key point: Always add jitter, not just backoff. 'Exponential backoff with jitter' is the phrase that shows you've been burned by a synchronized retry storm before.
A circuit breaker wraps calls to a dependency and tracks failures. When failures cross a threshold it 'trips' and stops sending calls for a cooldown period, immediately failing them instead. This does two things: it lets the struggling dependency recover instead of being hammered, and it lets callers fail fast rather than piling up on slow, doomed requests.
After the cooldown it goes 'half-open,' lets a trial request through, and closes again if that succeeds. It's the standard defense against cascading failures, where one slow service drags down everything calling it.
CLOSED ---(failures exceed threshold)---> OPEN
^ |
| (cooldown)
(trial ok) v
+----------- HALF-OPEN <--(let one request through)Key point: Pair 'retries' and 'circuit breaker' in your answer. Retries handle blips; circuit breakers stop a dead dependency from taking the caller down with it.
Named after ship compartments that stop one flooded section from sinking the whole vessel, the bulkhead pattern isolates resources so a failure in one part can't exhaust everything. In practice you give each dependency or tenant its own pool of connections or threads, rather than one shared pool.
Without bulkheads, one slow dependency can consume every thread in a shared pool, and suddenly requests that don't even use that dependency start failing too. Isolating pools contains the blast radius to just the affected feature.
Key point: Bulkheads plus circuit breakers plus timeouts are the trio for graceful degradation. Naming all three when asked about resilience indicates production experience.
Graceful degradation means the system keeps serving a reduced but useful experience when a part fails, instead of returning an error page. If the recommendation service is down, show a generic list instead of nothing. If the live like-count is unavailable, show a cached or approximate number.
You design for it by ranking features into core and non-core, and by having fallbacks (cached data, defaults, feature flags to shed load). The mindset is that partial availability beats total failure, and users forgive a missing sidebar far more than a blank screen.
Key point: When discussing failure, don't stop at 'it errors.' Describing a fallback ('serve stale data', 'hide the non-core widget') shows product-aware engineering judgment.
On a single database, ACID transactions are easy: the database coordinates them. Once data is sharded or split across services, a transaction spanning multiple nodes needs distributed coordination, which is slow and fragile. Two-phase commit provides it but blocks and doesn't tolerate coordinator failure well, so large systems mostly avoid it.
The common alternative is the saga: model the operation as a series of local transactions, each with a compensating action to undo it if a later step fails. You trade strict atomicity for availability and accept eventual consistency, then design the compensations carefully.
Key point: 'Just use a distributed transaction' is a weak answer at scale. Reaching for the saga pattern with compensating actions shows you know two-phase commit's costs.
Not in the database. Large binary files go in object storage (S3-style blob stores), which is built for cheap, durable, effectively unlimited storage of files. The database stores only metadata and a reference (the object key or URL), keeping it small and fast.
Clients then upload and download directly to object storage using pre-signed URLs, so the file bytes never flow through your application servers, and a CDN sits in front to serve popular files from the edge. Putting multi-megabyte blobs in a relational database is a classic anti-pattern that bloats and slows it.
Key point: The pattern to state: 'files in object storage, metadata in the database, CDN in front.' Any media-heavy design (photos, video) should follow it.
Read-heavy systems (news feeds, product catalogs) lean on caching and read replicas: push data close to readers, add replicas to fan out reads, and precompute expensive views. The database's write path is rarely the bottleneck.
Write-heavy systems (logging, metrics, activity tracking) can't cache their way out, since caching helps reads. They need sharding to spread writes, sometimes a write-optimized store (LSM-tree databases), buffering writes through a queue, and batching. Identifying which one you're dealing with, from the read-to-write ratio you asked for early, steers the whole design.
Where scaling effort goes: read-heavy vs write-heavy
Illustrative relative emphasis (arbitrary units) to contrast the two profiles, not measured values.
Key point: This is why you ask for the read-to-write ratio in the clarify phase. It decides whether you spend your design budget on caches or on shards.
At-most-once: send and forget. A message might be lost but never duplicated. It's the cheapest and fine for data you can afford to drop, like some metrics.
At-least-once: keep retrying until acknowledged, so nothing is lost, but a message can arrive more than once (the ack got lost, so you resend). This is the common default, and it's why consumers must be idempotent. Exactly-once is the ideal but is genuinely hard end-to-end; in practice you get it by combining at-least-once delivery with idempotent processing or deduplication on the consumer, rather than magic in the queue.
at-most-once : send once, no retry -> may lose, never dup
at-least-once : retry until ack -> never lose, may dup
exactly-once : at-least-once + dedup -> effectively once
(dedup/idempotency on the consumer)Key point: When someone says 'exactly-once,' probe it: real systems usually achieve at-least-once plus idempotent consumers. Saying that shows you know the guarantee isn't free.
Nodes send periodic heartbeats (a health signal) to a monitor or to each other. If heartbeats stop for a threshold, the node is presumed dead and traffic is routed elsewhere: a load balancer stops sending to it, or a replica is promoted to take over.
The subtlety is that a missed heartbeat can mean a dead node or just a slow network, and being too aggressive causes false failovers (declaring healthy nodes dead), while being too slow leaves you serving errors. Leader election protocols coordinate who takes over so two nodes don't both think they're the new leader (split-brain).
Key point: The two failure modes of failover: too twitchy causes false positives, too slow causes downtime. split-brain matters.
The prompts that come up again and again, each at outline depth: clarify, components, the one hard problem, and the trade-offs. Rehearse these out loud until the The technical sequence is automatic.
Clarify: we shorten long URLs to short codes, redirect on lookup, and it's massively read-heavy (far more redirects than creations). Ask about custom aliases, link expiry, and analytics. Estimate to decide storage: if you create 1,000,000 links a day and keep them 5 years, that's about 1.8 billion links, small enough that the hard part is fast reads, not storage.
Components: an API service, a database mapping short code to long URL, and a heavy cache in front (redirects are the same lookup over and over, so cache hit rate is huge). Put it behind a load balancer and a CDN.
Hard problem: generating the short code. Two clean options. Encode an auto-incrementing counter in base62 (a-z, A-Z, 0-9), which gives short, unique, collision-free codes but needs a distributed counter. Or hash the URL and take the first few characters, which risks collisions you must detect and resolve. Base62 of a counter is the usual pick.
Trade-offs: caching gives fast reads but the analytics count goes eventually-consistent; a distributed ID generator adds a component but removes collision handling. State that you optimized for read latency at the cost of write-path complexity.
Create: [Client] -> [LB] -> [API] -> counter -> base62 -> [DB]
Redirect (the hot path):
[Client] -> [CDN] -> [LB] -> [API]
|
[Cache] --hit--> 301 redirect (fast)
|
miss
v
[DB] -> cache it -> redirectKey point: Lead with 'this is read-heavy' and design around the cache. The short-code generation is the fun part, but the read path is where the scale actually is.
Clarify: users follow others and see a timeline of their posts, newest first. It's read-heavy, and the key tension is when to build each user's feed. Ask about feed ranking (chronological vs ranked) and how many people a user can follow.
Components: a post service, a follower/graph service, a feed store (often a per-user list in a fast cache), plus fan-out workers driven by a message queue.
Hard problem: fan-out. Fan-out-on-write (push): when someone posts, immediately write it into every follower's feed. Reads are then instant, but a celebrity with millions of followers causes a write explosion. Fan-out-on-read (pull): build the feed on demand by pulling recent posts from everyone a user follows. Cheap writes, but slow reads for users following many people. The standard answer is a hybrid: push for normal users, pull for celebrity accounts, then merge.
Trade-offs: push spends storage and write work to make reads fast (right for read-heavy feeds); pull saves storage but makes reads expensive. The hybrid is more complex but handles the follower-count skew that breaks either pure approach.
Post -> [Post Service] -> [Queue] -> [Fan-out Workers]
|
push into each follower's feed v
[Per-user Feed Cache]
^
Read feed <-------------------------- (already assembled)
(celebrities handled via pull-on-read + merge)Key point: The fan-out write-vs-read trade is the entire question. Naming the celebrity problem and proposing a hybrid is what separates a pass from a great answer.
Clarify: one-to-one messaging first, then group chat; needs real-time delivery, online/offline presence, and delivery receipts. Ask about message history retention and group size limits.
Components: WebSocket servers holding persistent client connections (chat is genuinely real-time, so not polling), a message store, a presence service, and a queue for offline delivery. A connection-routing layer tracks which server holds which user's socket.
Hard problem: delivering a message when the recipient is offline or connected to a different server. You need a way to find the recipient's active connection (a routing table mapping user to server), and if they're offline you persist the message and push it when they reconnect. Ordering and exactly-once-feeling delivery (dedup with message IDs) are the fiddly parts.
Trade-offs: persistent WebSocket connections give instant delivery but are stateful, which complicates load balancing and scaling (you can't just round-robin). You accept that connection state in exchange for real-time push, and lean on the queue plus message store to make offline delivery reliable.
[User A] ==WebSocket== [Chat Server 1]
|
[Routing / Presence] -- who is B on?
|
[User B] ==WebSocket== [Chat Server 2]
|
offline? -> [Message Store] + [Queue] -> deliver on reconnectKey point: The stateful-connection problem is the crux: WebSockets don't load-balance like stateless HTTP. Showing you know why is the differentiator here.
Watch a deeper explanation
Video: WHATSAPP System Design: Chat Messaging Systems for Interviews (Gaurav Sen, YouTube)
Clarify: what are we limiting (requests per user, per IP, per API key), what limits (100 requests per minute?), and does it need to be exact or approximate? Where does it sit, at the API gateway or inside each service?
Components: a limiter that runs at the gateway in front of your services, backed by a fast shared store (Redis) so every gateway instance sees the same counts. Token bucket or sliding window is the algorithm.
Hard problem: making it work across many gateway instances. A per-process counter is useless because a user's requests hit different instances, so the count must live in one shared place, updated atomically (Redis INCR with expiry, or a Lua script for the bucket logic) to avoid race conditions where two instances both think there's room.
Trade-offs: a centralized Redis store gives accurate global limits but adds a network hop and a dependency (and a potential single point of failure you must replicate). A purely local limiter is faster but only approximate. Many systems accept slightly fuzzy limits for speed, and return 429 with Retry-After on rejection.
[Client] -> [Gateway A] --+
|--> [Redis] atomic counter per user
[Client] -> [Gateway B] --+ (INCR + EXPIRE / Lua bucket)
|
over limit? --> 429 Too Many Requests + Retry-AfterKey point: The single word that makes this answer work is 'shared.' The counter can't be local, or independent instances undercount. Redis with atomic ops is the expected home for it.
Clarify: which channels (push, SMS, email), what volume, and do we need delivery guarantees, user preferences, and rate limits so we don't spam people? Are notifications triggered by events or scheduled?
Components: a notification API that accepts requests, a message queue to decouple sending from triggering, per-channel worker pools (one for push, one for SMS, one for email), and adapters to third-party providers. A preferences service checks whether the user opted in, and a template service formats content.
Hard problem: reliable delivery through flaky third-party providers. Providers fail and rate-limit you, so workers need retries with backoff, a dead-letter queue for messages that keep failing, and idempotency so a retry doesn't send the same notification twice. Fan-out to multiple channels should be independent, so a failing SMS provider doesn't block emails.
Trade-offs: the queue makes the system resilient and async but means delivery is eventual, not instant. Per-channel isolation (bulkheads) adds components but contains provider failures. You accept added moving parts for reliability and the ability to absorb bursts.
[Event] -> [Notification API] -> [Queue]
|
+---------------+-----------+-----------+
v v v
[Push Workers] [SMS Workers] [Email Workers]
| | |
[APNs/FCM] [SMS Provider] [Email Provider]
retries + backoff + dead-letter queue + idempotency keysKey point: Idempotency and retries are the heart of this one: nobody forgives a system that sends the same push five times. Bring both up before the interviewer asks.
Watch a deeper explanation
Video: System Design Interview - Notification Service (System Design Interview, YouTube)
Clarify: suggest completions as the user types, ranked by popularity, returning within a few tens of milliseconds because it fires on every keystroke. Ask how many suggestions, and whether they're personalized or global.
Components: a trie (prefix tree) holding the corpus of query strings, precomputed with the top suggestions cached at each node so a lookup is a single tree walk, not a search. Serve it from memory behind a service, with an aggregation pipeline updating popularity counts offline.
Hard problem: latency at every keystroke plus keeping suggestions fresh. You can't run a ranking query per keystroke, so you precompute the top-k completions for each prefix and store them at that trie node; a request just walks to the node and returns the cached list. Updating popularity happens asynchronously (batch jobs aggregate search logs) rather than on the hot read path.
Trade-offs: precomputing top-k per prefix trades memory and update lag for blazing-fast reads, which is the right call because reads vastly outnumber updates. Suggestions being minutes-stale is fine; a slow typeahead is not.
Type 'sy' : walk trie s -> y
|
node 'sy' caches top-k: [system design,
symptoms, syntax]
Reads: one in-memory tree walk, no ranking query.
Updates: offline batch job recomputes counts, refreshes nodes.Key point: The insight is 'precompute, don't search.' Explaining that you cache top-k completions at each trie node, updated offline, is exactly what this prompt is testing.
Fall back on the loop, not on memory. Any unfamiliar prompt yields to the same five steps: clarify requirements and scale, estimate the load, sketch high-level components, deep-dive the hardest part, and name trade-offs. The prompt changes; the method doesn't.
Map the new problem onto building blocks you know. Almost every system is some combination of load balancing, caching, a database (maybe sharded and replicated), queues for async work, and a plan for failure. If you understand those parts and the loop, you can reason your way through 'design a system for X' even having never seen X before, which is precisely the skill being tested.
Key point: When you get a prompt you've never rehearsed, say the loop out loud and start clarifying. Interviewers often prefer a novel prompt specifically to see if you have a method or just memorized answers.
Clarify: a fast key-value store shared across many app servers, holding hot data to offload the database. Ask about size (does it fit one node?), eviction policy, and whether stale reads are acceptable.
Components: a cluster of cache nodes, a client that knows how to route a key to the right node, and an eviction policy (LRU is the default: evict least-recently-used when full). Data is partitioned across nodes so the total cache is bigger than one machine.
Hard problem: distributing keys across nodes so that adding or removing a node doesn't blow away the whole cache. Plain hash-mod-N remaps almost everything when the node count changes, causing a cache stampede against the database. Consistent hashing solves it: only the keys near the changed node move. Replication of hot keys adds availability if a node dies.
Trade-offs: consistent hashing keeps the cache warm through membership changes at the cost of slightly more complex routing. A larger cache spread over more nodes improves hit rate but adds coordination. You accept eventual consistency between cache and database, managed with TTLs and invalidation.
[App] --key--> hash onto ring --> [Cache Node]
Node1
* . key A -> Node1
. .
key C Node2
. * . key B -> Node2
Node3 . . .
*
Add/remove a node -> only neighboring keys move (consistent hashing)Key point: This is where consistent hashing pays off concretely. Connecting it to 'avoid a cache stampede when a node changes' shows you understand why the algorithm exists, not just what it is.
Escalate in order of complexity, cheapest first. Start by adding read replicas if reads are the pressure, which fans out read traffic while writes stay on the leader. Add a cache in front to absorb repeated reads before they ever reach the database.
If writes or storage are the ceiling, next comes sharding: split the data across nodes by a shard key so each handles a slice. Before that, tune what you have, add indexes, optimize slow queries, and denormalize hot read paths, because sharding is a big commitment.
Hard problem: choosing when to shard and on what key, since sharding removes easy joins and cross-shard transactions. Trade-offs: replicas and caching are low-risk and handle read-heavy growth; sharding unlocks write scale but permanently complicates the data layer. Reach for it only when simpler steps are exhausted.
Step 1 add cache [App] -> [Cache] -> [DB]
Step 2 add replicas writes -> [Leader] -> [Replicas] <- reads
Step 3 tune + index kill slow queries, denormalize hot paths
Step 4 shard split by shard key across [DB0][DB1][DB2]
(last resort: loses joins and cross-shard transactions)Key point: Present the escalation ladder in order: cache and replicas before sharding. Jumping straight to sharding signals you reach for the big hammer before the cheap fixes.
Clarify: we ingest a firehose of events (clicks, views) and answer analytics queries over them. It's extremely write-heavy on ingest and read-heavy on the query side, and those two profiles want different stores. Ask whether queries need real-time or can be batch, and how long we keep raw data.
Components: a queue or log (a durable event stream) as the ingestion buffer that absorbs bursts, stream processors that aggregate, a raw store (object storage or a columnar warehouse) for the full data, and a serving store with precomputed rollups for fast queries.
Hard problem: the ingest rate is far too high to write straight into a queryable database and too high to query raw. So you decouple: buffer through the log, aggregate in the stream layer, and precompute rollups (counts per minute, per user) that the query side reads cheaply. Raw data lands in cheap columnar storage for occasional deep queries.
Trade-offs: precomputed aggregates make dashboards instant but fix the questions you can answer quickly; raw storage keeps flexibility but is slow to query. Buffering through a log adds latency (analytics are near-real-time, not instant) in exchange for surviving huge ingest spikes.
[Events] -> [Ingest Log / Queue] (absorbs the firehose)
|
v
[Stream Processors] --> [Rollup Store] <- fast queries
|
v
[Raw Columnar Store] <- deep / ad-hoc queries (slow)Key point: Separate the write path from the read path explicitly. High-volume ingest and fast analytics queries almost never want the same store, and naming both is the point.
Clarify: we need unique IDs across many machines, ideally roughly time-sortable, at high throughput, without a central bottleneck. Ask whether IDs must be strictly monotonic or just unique and mostly increasing.
Components: the Snowflake-style approach packs an ID into 64 bits: a timestamp (so IDs sort by time), a machine/worker ID (so different machines never collide), and a per-machine sequence counter (for multiple IDs in the same millisecond). Each machine generates IDs locally, no coordination per ID.
Hard problem: uniqueness without a central counter that would bottleneck and become a single point of failure. Snowflake solves it by giving each machine its own ID space via the machine-ID bits, so machines never step on each other. Clock skew and clock-going-backwards are the edge cases you must handle (wait, or refuse to issue).
Trade-offs: a central auto-increment is dead simple and strictly sequential but a scaling bottleneck and a single point of failure. Snowflake scales linearly and is roughly time-ordered but only approximately monotonic across machines, and it depends on reasonably synced clocks. For most systems that trade is worth it.
64-bit ID (Snowflake style):
+-------------------+-----------+--------------+
| timestamp (ms) | machine | sequence |
+-------------------+-----------+--------------+
sortable by time no collision many per ms
Each machine generates locally -> no per-ID coordinationKey point: Frame it as 'avoid a central counter.' The Snowflake bit-packing exists precisely so machines never coordinate per ID, which is the scaling insight the question tests.
Clarify: fetch pages starting from seed URLs, follow links, and store content, at scale and politely. Ask about scope (whole web vs a set of sites), freshness (recrawl cadence), and whether we respect robots.txt (yes).
Components: a URL frontier (a prioritized queue of URLs to fetch), a pool of fetcher workers, a parser that extracts links and content, a store for page content, and a 'seen URLs' set to avoid re-crawling the same page. The frontier and the seen-set are the coordination points.
Hard problem: not re-crawling the same URL and being polite to each host. Deduplication needs a fast membership check over billions of URLs (a hash set, often backed by a bloom filter to save memory). Politeness means rate-limiting per domain so you don't hammer one server, which pushes you toward per-host queues in the frontier.
Trade-offs: a bloom filter for the seen-set saves enormous memory but allows rare false positives (skipping a page you haven't seen), usually an acceptable trade at web scale. Per-host politeness limits throughput per domain but keeps you from getting blocked. Distributing the frontier across machines adds coordination but is required for scale.
[Seed URLs] -> [URL Frontier (per-host queues)] -> [Fetchers]
^ |
| [Parser]
| / \
add new links <----------------- links content
| |
[Seen set / bloom filter] [Store]Key point: Politeness (per-host rate limiting) and dedup (a bloom filter for seen URLs) are the two details interviewers dig for. Volunteer both and you've hit the core of the prompt.
Clarify: collect time-series metrics (request rate, latency, error rate) from many services, store them efficiently, and support dashboards and alerts. Ask about resolution (per-second vs per-minute) and retention.
Components: agents on each service pushing (or a central poller pulling) metrics, an ingestion layer, a time-series database optimized for timestamped numeric data, a query and dashboard layer, and an alerting engine that evaluates rules against incoming data.
Hard problem: the volume of time-series data is enormous and mostly only recent data is queried at full resolution. So you downsample over time (keep per-second data for a day, roll up to per-minute for a week, per-hour for a year), which shrinks storage dramatically while keeping trends. The time-series database is specialized precisely because relational stores handle this poorly.
Trade-offs: downsampling saves huge storage but loses fine detail on old data, which is almost always fine. Push vs pull collection each have costs (push scales with service count, pull centralizes control). You accept approximate old data for affordable retention.
[Services] --metrics--> [Ingestion] -> [Time-Series DB]
|
downsample: 1s (1 day) -> 1m (1 week) -> 1h (1 year)
|
+--------------------+-------------+
v v
[Dashboards] [Alerting Engine]Key point: Downsampling over time is the storage insight here. Explaining that you keep recent data fine-grained and roll up old data shows you understand time-series workloads specifically.
Clarify: a user places an order, we reserve inventory, charge payment, and create the order, reliably. This is the opposite of a social feed: correctness beats raw scale, so it wants strong consistency and careful failure handling. Ask about inventory reservation and payment retry behavior.
Components: an order service, an inventory service, a payment service (talking to an external gateway), all coordinated. The core data (orders, inventory, payments) belongs in a SQL database for transactions and consistency.
Hard problem: the operation spans services and an external payment provider, so a naive distributed transaction won't do. You use a saga: reserve inventory, then charge payment, then confirm the order, with compensating actions (release inventory, refund) if a later step fails. Payment must be idempotent (an idempotency key) so a network retry can't double-charge.
Trade-offs: choosing SQL and a saga prioritizes correctness (no lost orders, no double charges) over the write scale a NoSQL feed system would chase. You accept more coordination and eventual consistency across services in exchange for never taking money without creating an order, which is non-negotiable for commerce.
Saga:
1. reserve inventory --fail--> (nothing to undo)
2. charge payment --fail--> release inventory
3. create order --fail--> refund + release inventory
Payment uses an idempotency key so retries never double-charge.Key point: Flip the usual instinct: this prompt rewards choosing consistency (SQL, saga, idempotency) over scale. Reaching for NoSQL and eventual consistency on a payment flow is the wrong signal here.
Clarify: rank users by score, show the top N and a given user's rank, updated in near-real-time as scores change. Ask how many users (thousands vs hundreds of millions) and how fresh ranks must be, because that decides the approach.
Components: a sorted structure keyed by score. Redis sorted sets are the go-to: they keep members ordered by score and give O(log n) inserts and O(log n + k) range reads, so top-N and rank-of-user are both cheap operations out of the box.
Hard problem: computing a specific user's rank efficiently at large scale. A sorted set answers rank directly for moderate sizes; at very large scale you partition by score range or bucket approximate ranks, because exact global rank over hundreds of millions on every read gets expensive. Ties and simultaneous updates need a consistent tiebreak (timestamp).
Trade-offs: a Redis sorted set gives exact ranks and instant updates but lives in memory, so the data set must fit (or be sharded). Approximating rank at extreme scale trades exactness for speed. You pick based on the user count you clarified up front.
Redis sorted set 'leaderboard':
ZADD leaderboard 980 alice # update score, O(log n)
ZREVRANGE leaderboard 0 9 # top 10, O(log n + k)
ZREVRANK leaderboard alice # alice's rank, O(log n)
At extreme scale: shard by score range or bucket approx ranks.Key point: Naming the exact data structure (a sorted set) and its operations is the win here. It shows you match a specialized tool to the access pattern instead of hand-rolling ranking.
Clarify: users upload photos, follow others, and see a feed of photos from people they follow. It's read-heavy on the feed and involves large binary files, so it combines the news-feed problem with media storage. Ask about photo sizes, feed ranking, and whether we generate thumbnails.
Components: an upload service, object storage for the photo bytes (not the database), a metadata database (who posted what, when), a CDN in front of the images, the fan-out feed machinery from the news-feed design, and an async worker that generates resized thumbnails after upload.
Hard problem: serving large images fast to a global audience while keeping the database small. The bytes live in object storage with only a reference in the database, uploads go straight to storage via pre-signed URLs so they never touch app servers, and a CDN caches popular images at the edge. Thumbnail generation runs off a queue so the upload response is instant.
Trade-offs: separating blobs (object storage plus CDN) from metadata (database) keeps each store doing what it's good at, at the cost of more moving parts. Precomputed thumbnails spend storage to make reads fast. The feed inherits the same fan-out write-vs-read trade as any timeline.
Upload: [Client] --pre-signed URL--> [Object Storage] (bytes)
| |
v [Queue] -> [Thumbnail Worker]
[Metadata DB] (who/when/ref)
View: [Client] -> [CDN] -> image bytes (edge-cached)
feed assembled via fan-out (see the news-feed design)Key point: The insight that ties it together: 'bytes in object storage + CDN, metadata in the database, thumbnails async.' It reuses the news-feed fan-out plus the blob-storage pattern.
Summarize and self-critique. Restate the design in a sentence, then proactively name its bottlenecks and single points of failure before the interviewer has to, and say how you'd address each. This shows engineering maturity: real systems are never finished, and you know where yours would strain first.
Then mention what you deliberately left out for time (security hardening, detailed monitoring, cost optimization, multi-region) and how you'd extend the design for 10x the scale. Ending with 'here's what I'd do next and where this would break first' lands far better than trailing off, and it invites the interviewer into a final discussion rather than an awkward silence.
Key point: Reserve two minutes to critique your own design. Candidates who spot their own weak points read as senior; candidates who present a design as flawless read as inexperienced.
Every system design answer is a chain of trade-offs, and two show up in almost every interview: which database family to pick, and how to slice the application. SQL databases give you strong consistency, joins, and transactions, and they scale vertically until sharding gets painful. NoSQL trades some of that for horizontal scale and flexible schemas, which fits huge write volumes and simple access patterns. A monolith is one deployable unit: simple to build, test, and reason about, but every change ships together. Microservices split the system so teams deploy independently and scale hot paths in isolation, at the cost of network calls, distributed failures, and operational overhead. There's no universally right choice, and saying that out loud, then justifying yours for the specific problem, is exactly the signal interviewers screen for.
| Choice | Best at | Trade-off |
|---|---|---|
| SQL (relational) | Transactions, joins, strong consistency, well-understood queries | Harder to scale writes horizontally; sharding adds real complexity |
| NoSQL (document, key-value, wide-column) | Huge write volume, horizontal scale, flexible schema, simple lookups | Weaker consistency by default; joins and ad-hoc queries are awkward |
| Monolith | Fast early development, simple deploys, easy local reasoning and testing | Whole app ships together; one hot path can force scaling everything |
| Microservices | Independent deploys, isolated scaling, team autonomy on large systems | Network calls, distributed failures, and heavy operational overhead |
Prepare the approach, not a catalog of answers. The prompts vary, but strong candidates run the same loop every time: clarify what's actually being asked, estimate the scale, sketch a high-level design, go deep on the one or two hard parts, then name trade-offs. Rehearse that loop until it's automatic, and practice out loud with a timer, because your structure and communication as much as your architecture is the technical point.
The system design interview approach
Spend real time on clarify and estimate. Candidates who jump straight to boxes-and-arrows usually design the wrong system well.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages, and system design discussions are part of what we help teams evaluate. These questions reflect what actually gets asked and evaluated inside the interviews we host for 5,000+ HR teams.
See how the AI Coding Interviewer works