This page is for engineers preparing for a system design round, from a first backend role to a senior position. Most rounds test the same core: how you gather requirements and estimate load, how you scale with load balancers, caches, replicas and shards, how you reason about consistency and queues, and how you handle a classic design such as a URL shortener, a feed or a chat system. Senior rounds push harder on trade-offs and failure. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud. Practise drawing while you talk.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Requirements: core features, who uses it, scale, and which qualities matter most. Agree what's out of scope.
Estimates and API: rough load and storage, then the handful of calls the system must support.
High-level design: data model, main components, and one request traced end to end.
Deep dive: the hardest part, its failure modes and trade-offs, and what changes at ten times the load.
"I start by asking questions, not drawing. I pin down the core features, who the users are, roughly how many, and whether reads or writes dominate, and I agree what's out of scope. Then I do quick estimates, just enough to know whether this fits on one database or needs sharding, and I sketch the main API calls. Next I draw the high-level design: clients, load balancer, services, data stores, caches and queues, and I walk one request through it. That usually gets me to about the halfway mark. The rest is the deep dive, where I pick the hardest part, like how the feed is built or how we avoid sending a message twice, and talk through failures and trade-offs. I check in with the interviewer at each step so I spend time where they care, and I say my assumptions out loud."
Naming technologies and drawing boxes in the first minute without asking a single question about scope or scale.
Functional: what the system does: search restaurants, place and pay for an order, track the driver.
Non-functional: how well it must do it: latency, availability, consistency, durability, peak load, security.
Per feature: say which quality wins where, because payments and driver tracking need very different things.
"Functional requirements are the features. In a food delivery app, a customer searches restaurants, places and pays for an order, and watches the driver on a map. Non-functional requirements describe how the system must behave while doing that: how fast search responds, how available ordering is at dinner peak, whether a payment can ever be lost, how many orders a second we must handle. Those drive the design more than the features do, and they differ by feature. Payment needs strong consistency and durability, so it belongs in a transactional database. Driver location updates every few seconds and a slightly old position is fine, so I'd optimise that path for write throughput and accept eventual consistency. In an interview I list three or four features, then ask which qualities matter most, because that tells me where the hard part is."
Listing only features, or saying the system must be 'scalable and highly available' without saying which part, how much, or what gets traded away.
API: only the calls the core features need: view seats, hold seats, confirm a booking with an idempotency key.
Entities: events, seats, holds and bookings, with their keys and the status each one needs.
Store choice: booking needs transactions, so a relational database; the seat map can be read from a cache.
Hard part: one conditional update so two people can never hold the same seat.
"I keep the API small. One call returns an event's seat map with each seat's status. A second call holds a list of seats and returns a hold ID that expires after about ten minutes. A third confirms the booking with that hold ID, the payment details and an idempotency key, so a retried request never charges twice. The main tables are events, seats, holds and bookings. Each seat row has the event ID, a seat label, a status, and the hold it belongs to with an expiry time. Booking must never double-sell, so this lives in a relational database with transactions. To hold seats I run one conditional update that only touches seats that are free or whose hold has expired, and I check that the number of rows changed matches the number of seats asked for. If it doesn't, someone got there first and I roll back. The seat map can come from a cache a few seconds stale, because the hold is the real check."
BEGIN;
UPDATE seats
SET status = 'held', hold_id = 'h-913', held_until = now() + interval '10 minutes'
WHERE event_id = 42
AND seat_label IN ('14C', '14D')
AND (status = 'available' OR (status = 'held' AND held_until < now()));
-- if fewer than 2 rows changed, someone else got a seat first: ROLLBACK
COMMIT;
Drawing servers before naming a single API call, or modelling seats so that two users can end up booking the same one.
Assumptions: state them first: uploads and views per user per day, average photo size.
Rates: divide daily totals by about 100,000 seconds, then allow for a peak a few times the average.
Storage and bandwidth: multiply out per day and per year, including replicas.
So what: say what the numbers change in the design.
"I'll assume each user uploads one photo a day at about 200 kilobytes after compression, and views about 100 photos a day. A day is roughly 86,400 seconds, so I'll round to 100,000 to keep the maths easy. Ten million uploads a day is about 100 uploads a second, maybe 300 at peak. Storage is 10 million times 200 kilobytes, so 2 terabytes a day, around 730 terabytes a year, and over 2 petabytes once we keep three copies. Views are a billion a day, so about 10,000 reads a second, and at 200 kilobytes each that's roughly 2 gigabytes a second going out. So what does that tell me? Writes are easy; this is a read-heavy system dominated by image bandwidth. Images belong in object storage behind a CDN, and the metadata database mostly needs caching, not heavy sharding on day one."
Uploads: 10M users x 1 photo/day = 10M photos/day
10M / ~100,000 s = ~100 uploads/s (peak ~300/s)
Storage: 10M x 200 KB = 2 TB/day
2 TB x 365 = ~730 TB/year (x3 copies = ~2.2 PB)
Views: 10M users x 100 views/day = 1B views/day
1B / ~100,000 s = ~10,000 reads/s
Egress: 10,000/s x 200 KB = ~2 GB/s
Producing numbers with no stated assumptions, or doing careful arithmetic and then never using the result to decide anything.
Vertical: a bigger machine. Simple, no code change, but there's a ceiling and it's still one point of failure.
Horizontal: more machines behind a load balancer. Near-unlimited and fault tolerant, but the app must be stateless.
Moving state out: sessions, uploads, in-memory counters and scheduled jobs all need a shared home.
"Scaling vertically means giving one machine more CPU, memory or faster disks. It needs no code changes, which is why I'd often do it first, especially for a database, but there's a hard ceiling and the box is still a single point of failure. Scaling horizontally means running many copies behind a load balancer, which removes the ceiling and survives a machine dying. The catch is that any request may land on any copy, so the servers can't keep state of their own. Sessions move to a shared store or into signed tokens, uploaded files go to object storage instead of local disk, in-memory counters and caches move to a shared cache, and scheduled jobs need a lock or a single scheduler so they don't run once per server. Stateless app servers are easy to scale out; the database is usually the harder part."
Saying horizontal scaling is always better, or missing that local sessions and files break as soon as there is a second server.
Job: spread traffic, drop unhealthy servers via health checks, let you add or remove servers invisibly.
Algorithms: round robin for similar short requests, least connections for uneven ones, hashing for affinity.
Layer 4 vs 7: route on IP and port, or read HTTP to route by path and terminate TLS.
Redundancy: the balancer itself must not be a single point of failure.
"A load balancer sits in front of a pool of servers and spreads requests across them. It runs health checks and stops sending traffic to a server that fails them, which is what lets me deploy or add servers without clients noticing. Round robin is fine when requests are short and similar. Least connections is better when some requests are long, like uploads or streaming, because new work goes to the least busy server. Hashing on something like the user ID sends the same user to the same server, which helps if servers keep a local cache, but a few hot keys can overload one machine. There's also the layer: a layer 4 balancer routes on IP and port and is very fast, while a layer 7 balancer reads the HTTP request, so it can route by path and terminate TLS. And the balancer itself runs as a redundant pair or a managed service."
Describing the load balancer as a magic box that makes things scale, with no mention of health checks or of the balancer being a failure point itself.
Cache-aside: app reads cache, loads from the database on a miss, deletes the key on write.
Write-through: every write updates cache and database together; fresh but slower writes.
Write-back: write to cache, flush to the database later; fast, but data can be lost.
Choice: read-heavy data that can be a little stale suits cache-aside with a TTL.
"With cache-aside, the application checks the cache first. On a miss it reads the database, stores the result in the cache with a TTL and returns it. On a write it updates the database and deletes the cache key, so the next read reloads a fresh copy. Write-through sends every write to the cache and the database together, so the cache is always warm, but every write pays for both. Write-back writes only to the cache and flushes to the database later, which makes writes very fast but loses data if the cache node dies before it flushes. A product catalogue is read far more often than it changes, and a few seconds of staleness is fine, so I'd use cache-aside with a TTL and delete on update. I delete rather than overwrite, because two writers racing to set the cache can leave the older value behind."
Recommending write-back for data that must not be lost, or never mentioning how the cache gets invalidated when the data changes.
Cause: every request misses at the same instant and each one rebuilds the value from the database.
Coalesce: let one request rebuild while the others wait for it or get the old value.
Serve stale: keep the old value past its soft expiry and refresh it in the background.
Spread expiry: add random jitter to TTLs so many keys don't expire together.
"That's a cache stampede. While the key was cached, the database never saw those reads. The moment it expired, every request missed together, and each one ran the same expensive query to rebuild the value, so the database got the full traffic in one spike. The main fix is request coalescing: the first request that misses takes a short lock and rebuilds the value, and the others either wait briefly for it or get the previous value. Even better is serving stale data: I store the value with a soft expiry and a longer hard expiry, so after the soft one passes, requests keep getting the old value while one background job refreshes it. I'd also add random jitter to TTLs so keys written together don't all expire together, and for a handful of known hot keys I'd refresh them on a schedule so they never expire at all."
Suggesting a longer TTL or a bigger database as the fix, without addressing the many simultaneous rebuilds.
Cause: replication is asynchronous, so the replica that served the refresh hadn't applied the write yet.
Read your writes: send a user's reads to the primary for a short window after they write, or wait for a replica that has caught up.
Consistency per user: pin a user to one replica so they never see time go backwards.
Watch it: monitor lag and pull badly lagging replicas out of rotation.
"The write went to the primary, but replicas apply changes asynchronously, usually a fraction of a second behind and sometimes much more under load. The refresh hit a replica that hadn't caught up yet, so it returned the old row. Making replication synchronous would fix it but slow every write and tie the primary to the replicas' health. Instead I'd give that user read-your-writes. The simplest version is to route a user's reads to the primary for a short window after they write, say by setting a timestamp in their session. A more precise version records the log position of the write and only reads from a replica that has replayed past it. I'd also pin each user to one replica, so a second refresh doesn't land on a replica that's even further behind. And I'd alert on replication lag and take a replica out of rotation if it falls too far back."
Blaming the browser cache, or fixing it by sending every read to the primary and throwing away the replicas.
Basics: one leader takes writes; followers replay its change log in the same order and can serve reads.
Sync vs async: a synchronous follower has every acknowledged write but slows writes; asynchronous is fast but can lose recent writes on failover.
Middle ground: one synchronous follower, the rest asynchronous.
Failover: detect the dead leader, promote the most up-to-date follower, and fence the old leader so it can't keep taking writes.
"All writes go to one leader. It records each change in its log, and followers replay that log in the same order, so they hold the same data and can serve reads. The big choice is when the leader tells the client a write succeeded. With a synchronous follower it waits for that follower to confirm, so if the leader dies, that follower has every acknowledged write, but each write is slower and a stuck follower can stall writes. With asynchronous followers the leader confirms straight away, which is fast, but writes the followers hadn't received are lost when one is promoted. A common middle ground is one synchronous follower and the rest asynchronous. Failover itself is risky. You detect the dead leader with a timeout, promote the most up-to-date follower and repoint clients. Then you must stop the old leader coming back and still accepting writes, which is split brain. I'd use fencing, like an increasing epoch number that rejects writes from an old leader."
Saying followers are always an exact copy of the leader, or promoting a follower automatically with no thought for lost writes or the old leader returning.
First: rule out cheaper fixes like indexes, archiving old rows, read replicas and a bigger machine.
Key: follow the main query pattern and spread load evenly; customer ID keeps a customer's orders together.
Costs: cross-shard queries, joins and transactions get hard; reports move to a separate store.
Resharding: use many logical shards mapped onto fewer servers, so growth means moving whole shards.
"Before sharding I'd check the cheaper options: missing indexes, archiving old orders, read replicas and a bigger machine, because sharding makes everything after it harder. If we still need it, I choose the key from the main access pattern. Most order queries are 'show this customer's orders', so I'd shard by a hash of the customer ID. That keeps one customer's orders on one shard and spreads customers evenly. A range key like order date would be worse, because all new writes would pile onto the newest shard. To look up an order by its own ID, I'd embed the shard number in the order ID. Queries across all customers, like daily sales reports, shouldn't hit the shards at all; they go to an analytics store fed from the shards. For growth, I'd create many logical shards, say a thousand, mapped onto a few servers, so adding capacity means moving whole logical shards instead of rehashing every row."
Sharding by an auto-increment ID or a timestamp without noticing the hot spot, or never mentioning what happens to cross-shard queries.
Problem with mod N: changing N changes where most keys map, so most cache entries become misses.
Ring: servers and keys hash onto the same circle; a key belongs to the next server clockwise.
Resize: adding or removing a server only moves the keys next to it, about one Nth of them.
Virtual nodes: each server gets many points on the ring for an even spread.
"With hash mod N, the server for a key depends on N, so going from four cache servers to five changes the answer for most keys. In fact only about one key in five stays put, so the cache suddenly misses almost everything and the database takes the full load. Consistent hashing puts both servers and keys on the same hash ring. A key belongs to the first server you meet going clockwise from its position. If I add a fifth server, it only takes over the keys between itself and its neighbour, roughly a fifth of them, and everything else stays where it was. Removing a server hands its keys to the next one along. With just one point per server the spread is uneven, so each server gets many virtual nodes on the ring, which evens out the load and lets a bigger machine take more points."
import bisect, hashlib
def h(key):
return int(hashlib.md5(key.encode()).hexdigest(), 16)
class Ring:
def __init__(self, nodes, vnodes=100):
self.points = sorted((h(f"{n}#{i}"), n) for n in nodes for i in range(vnodes))
self.hashes = [p for p, _ in self.points]
def node_for(self, key):
i = bisect.bisect(self.hashes, h(key)) % len(self.hashes)
return self.points[i][1]
ring = Ring(["cache-a", "cache-b", "cache-c"])
print(ring.node_for("user:42"))
Claiming consistent hashing balances load perfectly on its own, or not being able to say which keys move when a server is added.
Strong: every read sees the latest committed write, as if there were one copy. Costs latency and availability.
Eventual: replicas converge if writes stop; reads may be stale for a while.
Read-your-writes: a user always sees their own changes, even if others see them a bit later.
Per feature: decide by the cost of a stale read.
"Strong consistency means every read sees the most recent committed write, as if there were only one copy of the data. Eventual consistency means replicas may disagree for a while, but if writes stop they all converge on the same value. Read-your-writes sits in between: a user always sees their own changes, even if other users see them slightly later. Take a ticket booking app. Seat reservation needs strong consistency, because two people must never both get seat 14C. The count of people who viewed an event can be eventually consistent; if it's a few seconds behind, nobody is harmed and it's much cheaper to scale. Reviews need read-your-writes: when I post a review and the page reloads, I must see it, or I'll post it again, but it's fine if another visitor sees it a second later. I pick per feature by asking what a stale read would actually cost."
Saying eventual consistency means data might never become correct, or insisting on strong consistency for everything without mentioning its cost.
Statement: during a network partition a replicated store must choose between consistency and availability.
Not pick two: partitions happen in real networks, so the real choice is C or A while partitioned.
Normal times: without a partition the trade-off is latency against consistency.
Apply it: choose per feature, by which failure hurts the business less.
"CAP is about what happens during a network partition, when some nodes can't reach others. In that moment a replicated store has two options. It can refuse requests it can't confirm, so nobody sees stale or conflicting data, which is choosing consistency. Or it can keep answering on both sides and reconcile later, which is choosing availability. The popular 'pick two of three' framing is misleading, because you can't opt out of partitions in a real network. It also says nothing about normal operation, where the everyday trade-off is latency against consistency. In a design I apply it per feature. A shopping cart can stay writable on both sides and merge later, because losing a sale is worse than a duplicate item the user removes. A seat booking or an account balance should reject writes it can't confirm, because an error message is better than selling the same seat twice."
Reciting 'you can only have two of the three' and labelling databases CA, without explaining what actually happens during a partition.
Benefits: decoupling, absorbing spikes, retrying failed work, letting slow tasks run out of the request.
Good fit: follow-up work after an order, like email, invoice and analytics.
Bad fit: when the caller needs the answer right now to continue.
Costs: eventual consistency, harder debugging, one more system to run.
"A queue lets a service hand off work without waiting for it to finish or even for the other service to be up. Messages wait until a consumer is ready, so a traffic spike becomes a longer queue instead of an outage, and failed work can be retried. A clear win is placing an order. The checkout service saves the order and publishes one message, and separate consumers send the confirmation email, generate the invoice and update analytics. Checkout stays fast, and if the email provider is down the emails just go out later. It's the wrong tool when the caller needs the answer to carry on, like checking a password or getting a price quote. Pushing that through a queue means inventing a reply channel and timeouts for what should be a simple request. And every queue brings eventual consistency, harder tracing and another system to monitor, so I add one when there's a real reason."
Adding a queue between every pair of services 'for scalability' with no mention of eventual consistency or the operational cost.
Meaning: a message may be delivered more than once, for example when a consumer crashes before acknowledging.
Idempotent consumer: record each message ID in the same transaction as the effect, and skip IDs already seen.
Natural idempotency: prefer 'set status to shipped' over 'add one' where you can.
Poison messages: cap retries and move repeat failures to a dead-letter queue.
"At-least-once means the broker keeps redelivering until the consumer acknowledges. If my consumer does the work and then crashes before it acks, the same message comes back. So duplicates are a normal event, and the consumer has to make repeats harmless. Where I can, I make the operation naturally idempotent, like setting an order's status to shipped rather than incrementing a counter. When I can't, I keep a processed-messages table with the message ID as the primary key, and insert into it in the same database transaction as the real change. If the insert fails because the ID already exists, I know it's a duplicate, roll back and just acknowledge. I only ack after the transaction commits. And if one message keeps failing, I stop retrying after a few attempts and move it to a dead-letter queue so it doesn't block everything behind it."
BEGIN;
-- message_id is the primary key, so a duplicate makes this insert fail
INSERT INTO processed_messages (message_id) VALUES ('msg-8841');
UPDATE accounts SET balance = balance + 50 WHERE id = 7;
COMMIT;
Assuming each message arrives exactly once, or acknowledging the message before the work is safely committed.
Cause: two separate writes with no shared transaction; a crash or error between them loses the event.
Wrong fixes: publishing first can announce an order that then fails to save.
Outbox: write the event into an outbox table in the same transaction as the order.
Relay: a separate process publishes outbox rows, so delivery is at least once and consumers stay idempotent.
"It's a dual write. Saving the order and publishing the event are two separate operations with no shared transaction. If the process crashes, gets redeployed or the broker call fails after the commit, the order exists but the event is gone. Swapping the order doesn't help: publish first and a failed save leaves consumers acting on an order that doesn't exist. The fix I'd use is a transactional outbox. In the same database transaction that inserts the order, I insert a row into an outbox table describing the event. Either both are saved or neither is. Then a separate relay reads unsent outbox rows, publishes them and marks them sent, either by polling or by reading the database's change log. If the relay crashes after publishing but before marking a row, it will publish that event again, so delivery is at least once and consumers must be idempotent. I'd also clean up old sent rows."
Suggesting a retry loop around the publish call, or a distributed transaction across the database and the broker, as if either closes the gap.
Algorithm: token bucket allows short bursts while holding the average; fixed windows leak bursts at the boundary.
Shared state: counters live in a fast shared store, updated atomically.
Placement: at the gateway or in middleware, returning 429 with a Retry-After header.
Failure mode: decide fail open or fail closed per endpoint.
"I'd use a token bucket per API key. Each bucket holds up to a set number of tokens and refills at a steady rate; each request spends one, and an empty bucket means the request is rejected with a 429 and a Retry-After header. It allows short bursts but holds the average. A fixed window counter is simpler, but a client can send double the limit by clustering requests around a window boundary, and a sliding log is exact but stores every timestamp. With many servers, the state can't live in each server's memory, so I'd keep it in a shared in-memory store and do the read-refill-spend step as one atomic operation, so two servers can't both spend the last token. The limiter lives in the gateway so every service gets it. If the store goes down, I'd fail open for normal endpoints and fail closed for sensitive ones like login."
import time
class TokenBucket:
def __init__(self, capacity, refill_per_sec):
self.capacity = capacity
self.rate = refill_per_sec
self.tokens = capacity
self.last = time.monotonic()
def allow(self):
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
bucket = TokenBucket(capacity=10, refill_per_sec=5)
print(bucket.allow())
Keeping counters in each server's memory behind a load balancer, or doing a separate read and write so concurrent requests overspend the limit.
Estimates: writes and reads per second, total links over the retention period, storage.
Code generation: unique ID from a counter service, encoded in base 62.
Read path: cache in front of a key-value store; redirect with 302 or 301.
Extras: expiry, custom aliases, abuse checks, guessability.
"Say 100 million new links a month and a hundred redirects per new link. A month is about 2.6 million seconds, so that's roughly 40 writes and 4,000 redirects a second. Over five years it's 6 billion links, and at about 500 bytes each, around 3 terabytes, which fits easily in a sharded key-value store. For the code, a counter service hands each app server a block of IDs, so there are no collisions and no coordination per request, and I encode the ID in base 62. Seven characters give about 3.5 trillion codes, far more than we need. On a click I look up the code, cache first because popular links are very hot, and return a 302 so every click reaches us for analytics. A 301 gets cached by browsers, which cuts load but hides repeat clicks. Sequential codes are guessable, so for private links I'd scramble the ID before encoding."
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def to_base62(n):
if n == 0:
return ALPHABET[0]
out = []
while n:
n, r = divmod(n, 62)
out.append(ALPHABET[r])
return "".join(reversed(out))
print(to_base62(125)) # "21"
Generating random codes and checking the database for collisions on every write without noticing the cost, or never doing the estimates.
Fan-out on write: on each post, push its ID into every follower's feed list. Fast reads, costly for huge accounts.
Fan-out on read: on open, pull recent posts from everyone followed and merge. Cheap writes, slow reads.
Hybrid: push for normal accounts, pull for very large ones at read time, then merge.
Details: store only post IDs, cap list length, hydrate from a post cache, page with a cursor.
"There are two approaches. Fan-out on write means that when someone posts, a background worker pushes the post ID into a cached feed list for each follower, so opening the app is just reading one list. That's great for reads, but an account with tens of millions of followers turns one post into tens of millions of writes. Fan-out on read means building the feed when the user opens the app, by fetching recent posts from everyone they follow and merging them. Writes are cheap, but reads are slow for someone following thousands of accounts. So I'd go hybrid. Normal accounts fan out on write. Very large accounts are skipped at write time, and their recent posts are pulled and merged in when a follower reads. Feed lists hold only post IDs and are capped at a few hundred, the post content comes from a separate cache, and paging uses a cursor. I'd also skip precomputing feeds for inactive users."
Picking pure fan-out on write without noticing accounts with millions of followers, or storing full post bodies in every follower's feed.
Connections: clients hold a WebSocket to a gateway server; a registry maps each user to their gateway.
Send path: chat service assigns a per-conversation sequence number, stores the message, then routes it.
Offline: send a push notification; on reconnect the client syncs everything after its last sequence number.
Reliability: client-generated message IDs for dedupe, acks for sent, delivered and read.
"Each client keeps a WebSocket open to one of many gateway servers, and a registry records which gateway each online user is connected to. When I send a message, it carries an ID my client generated, so retries don't create duplicates. The chat service gives it the next sequence number for that conversation, stores it in a store partitioned by conversation ID and ordered by sequence, and only then acks back to me. Then it looks up the recipient in the registry. If they're online, their gateway pushes the message and their client acks delivery. If they're offline, we send a push notification, and when they reconnect the client asks for everything after the last sequence number it has, so nothing is lost even if a push was missed. Groups work the same way with fan-out to each member, and very large groups switch to members pulling. Presence comes from heartbeats and is allowed to be a little stale."
Having clients poll the server every second for new messages, or ordering messages by client timestamps.
Intake: one API with an idempotency key, the user, a template and a priority.
Rules: check opt-outs and channel preferences, quiet hours in the user's time zone, and per-user caps.
Delivery: separate queues per channel and priority; workers call providers with backoff and a dead-letter queue.
Tracking: record the status of each send so teams can see what happened.
"Teams call one API with the user, a template name, data and an idempotency key. The service first checks that key in a dedupe store, so a caller retrying after a timeout doesn't cause a second send. Then it applies rules: the user's opt-outs and channel preferences, quiet hours in the user's own time zone, and a cap on how many notifications one user gets in a day. Anything that shouldn't go now is scheduled for later or dropped. It renders the template and puts the message on a queue for that channel. I'd keep urgent traffic, like login codes, on separate queues from marketing, so a huge campaign never delays someone signing in. Workers call the push, email or SMS providers, retry with exponential backoff, and move repeated failures to a dead-letter queue. Every send gets a status record, so a team can see whether a message was sent, skipped or failed, and why."
Sending straight from the API request to the provider with no queue, or having no answer for retries producing duplicate messages.
Chunks: split files into chunks identified by content hash; upload only the ones the server lacks.
Storage split: chunks in object storage, file metadata and versions in a database.
Sync: a per-user change log with a cursor; devices fetch changes since their cursor when notified.
Conflicts: if two devices edit the same version, keep both and create a conflicted copy.
"I'd split every file into chunks of a few megabytes and identify each chunk by a hash of its content. Before uploading, the client asks which hashes the server already has, and only sends the missing ones. That gives resumable uploads, deduplication, and cheap edits, because changing one part of a big file only re-uploads the chunks that changed. Chunks go straight to object storage through short-lived signed URLs, so file bytes never pass through my app servers. The metadata service stores each file as an ordered list of chunk hashes, with versions, in a relational database. Every change is appended to a per-user change log with an increasing cursor. Other devices get a lightweight notification, then ask for all changes after their last cursor and download only the chunks they're missing. If two devices edit from the same version, the first commit wins and the second is saved as a conflicted copy, so no one's work is silently lost."
Uploading whole files through the app servers on every save, or letting the last writer silently overwrite another device's changes.
Offline: aggregate query logs over a time window and compute the top suggestions for every prefix.
Serving: a prefix table or trie with the top results stored at each node, held in memory and replicated.
Client and edge: debounce keystrokes, cache prefixes in the browser and at a CDN.
Freshness and safety: periodic rebuilds, a faster path for trending terms, filtering of unwanted suggestions.
"The key is to do the heavy work offline. A batch job reads the query logs for a recent window, counts how often each query was searched, and builds a map from every prefix to its top ten or so completions, which is really a trie with the top results precomputed at each node. At request time, a lookup is just 'give me the list stored for this prefix', so it's served from memory in well under the time between keystrokes. The data is replicated across servers and can be split by prefix if it grows. On the client I debounce, so fast typing doesn't send a request per letter, and cache results in the browser. Short prefixes are the same for everyone, so they can be cached at a CDN too. The full rebuild runs on a schedule, a small streaming job boosts terms trending right now, and a filter removes offensive or unwanted suggestions before anything is published."
Running a prefix-matching LIKE query against the main database on every keystroke, or ranking suggestions live at request time.
Metrics: cheap numbers over time, like request rate, errors and latency percentiles. Used for alerts and dashboards.
Traces: the path of one request across services, with timing for each step.
Logs: detailed events, structured and tagged with a request ID so they join up with traces.
Order: metrics to find where and when, traces to find the slow step, logs for the exact detail.
"Metrics are numbers aggregated over time, like requests per second, error rate and latency percentiles per endpoint. They're cheap to keep, so they drive dashboards and alerts. Traces follow one request through every service it touches and show how long each step took. Logs are detailed events, and they're most useful when they're structured and carry the same request ID as the trace. When latency jumps, I start with metrics: which service, which endpoint, when it started, and whether it lines up with a deploy or a traffic change. I look at the 99th percentile, not the average, because averages hide the slow tail that users actually feel. Then I open traces of slow requests to see which step is eating the time, maybe a database call or a downstream service. Finally I read the logs for that step to find the exact query, error or timeout."
Starting by grepping raw logs on each server, or judging latency only by the average.
Cascade: callers hold threads and connections while waiting, run out, and start failing themselves.
Timeouts: every remote call gets one, shorter than the caller's own deadline.
Retries: only for idempotent calls, with exponential backoff, jitter and a small budget.
Circuit breaker: after repeated failures stop calling for a while, fail fast or use a fallback, then probe.
"A slow dependency is often worse than a dead one. Every caller holds a thread or connection while it waits, the pools fill up, and then the caller can't serve anything, even requests that never touch that dependency. First, every remote call needs a timeout, set shorter than the deadline of whoever is calling me. Retries help with brief blips, but only for idempotent calls, with exponential backoff and random jitter, and a small cap. Without that, retries multiply load on a service that's already struggling, and if three layers each retry three times, one user request becomes dozens of calls. A circuit breaker watches failures, and once they cross a threshold it opens and fails fast, or serves a fallback like cached data, instead of calling at all. After a pause it lets a few trial requests through and closes again if they succeed. I'd also give each dependency its own connection pool, so one slow service can't take them all."
Adding retries everywhere as the fix without backoff or limits, which turns a slowdown into a retry storm.
Context: what the system did, the load, the team and the deadline.
Decision: the options you weighed and why you picked one.
Outcome: what happened in production, with a concrete result.
Hindsight: what you'd keep, what you'd change and why.
"At my last company I designed the service that sent order status updates to customers. We had a few weeks and a team of three, and the big decision was whether to use a queue from day one or have the order service call the sender directly. I chose the queue with an outbox table, because the email provider had regular slowdowns and I didn't want checkout waiting on it. It cost us about a week more and a new piece of infrastructure to run. In production it paid off: when the provider had an outage for several hours, messages just queued up and drained afterwards, and checkout never noticed. What I'd change is that I didn't build deduplication at first, and a consumer redeploy sent some customers two emails. I'd design for duplicates from the start now, because with a queue they're a certainty, not an edge case."
A story where every decision was right, or where you can't name a single alternative you considered.
Symptom: what broke, when and how users felt it.
Diagnosis: the metrics, traces or tests that pointed to the real limit.
Fix: the change, and why it addressed the cause rather than the symptom.
Proof: how you confirmed it worked and what you put in place afterwards.
"At my last company our reporting API started timing out every Monday morning when clients pulled weekly reports. The first instinct on the team was to add more app servers, but the app servers' CPU was low. The database metrics told the story: connections were maxed out and one query dominated the slow query log. Traces showed each report ran the same heavy aggregation over a week of raw events, once per request. Adding servers would only have added more connections fighting over the same database. I moved that aggregation into a job that built a daily summary table overnight, so the report read a few hundred rows instead of millions. Report time dropped from around twenty seconds to under a second, and the Monday spike disappeared from the database graphs. Afterwards I added an alert on connection pool usage, and we put a load test for the Monday pattern into our release checks."
A story where the fix was adding servers or memory without ever identifying what the actual bottleneck was.
Concern: what you disagreed with and the specific risk you saw.
Evidence: how you backed it up: numbers, a prototype, a failure scenario.
Outcome: what was decided, including if you didn't get your way.
Relationship: how you worked with the author afterwards.
"At my last company a senior engineer proposed keeping user sessions in each app server's memory for a new service, to avoid another dependency. My concern was that we'd planned to run several instances behind a load balancer, so users would be logged out whenever a request hit a different server or during every deploy. Instead of arguing in the meeting, I asked for a day, ran two instances locally and recorded a short demo of a user losing their session on the second request. I suggested signed tokens or a small shared session store and wrote down the cost of each. He agreed once he saw it, and we went with the shared store. I made sure to credit his point about fewer dependencies, which is why we picked the simplest managed option."
A story about winning an argument by seniority or persistence, with no evidence and no respect for the other person's reasoning.
Re-estimate: redo the load numbers and say which component breaks first.
Reads first: caching and read replicas if the growth is mostly reads.
Then writes: batching, queues to smooth spikes, and sharding only if a single primary can't take the write rate.
Say what changes: name the new trade-offs, like staleness or harder queries.
"I wouldn't redraw the whole diagram. I'd first redo the numbers out loud. If we were at 2,000 reads and 200 writes a second, we're now at 20,000 and 2,000, and I'd ask whether the growth is mostly reads, since for most products it is. For reads, I'd put a cache in front of the hottest queries and add read replicas, and I'd make sure the app servers are stateless so they scale out behind the balancer. Then writes: 2,000 a second may still fit on one well-tuned primary, but if it doesn't, I'd look at batching writes, moving non-urgent ones behind a queue, and finally sharding by a key that matches the main access pattern. I'd close by naming what we gave up, like slightly stale reads from the cache and replicas, and cross-shard queries getting harder, and ask whether that's acceptable for this product."
Jumping straight to sharding and microservices without re-estimating or checking whether reads or writes grew.
Find the pain: ask what problem the split is meant to solve.
Costs: network failures, distributed data, deploy and monitoring overhead, split across only five people.
Cheaper options: a modular monolith with clear boundaries, then extract a service only where there's a concrete reason.
Next step: pick the one module with the strongest case and measure the result.
"I'd start by asking what problem we're trying to solve. If it's slow deploys, tangled code or one part needing to scale differently, each of those has a cheaper fix than a dozen services. For five people, a dozen services means each person owns two or three, each with its own pipeline, monitoring, on-call and data store. Calls that used to be function calls become network calls that can time out, and joins across what used to be one database become distributed data problems. So I'd suggest making the monolith modular first: clear module boundaries, no reaching into another module's tables, and tests that enforce it. Then, if one part has a real reason to stand alone, like a video processing job that needs very different hardware, we extract just that one and see what it costs us to run. If that goes well and the team grows, we'll have a pattern for the next one."
Agreeing that microservices are always more scalable, or dismissing the idea without asking what problem the team is actually facing.
Measure: find the top queries by total time and whether the pressure is reads, writes, connections or storage.
Quick wins: indexes, query fixes, caching hot reads, read replicas, connection pooling, a bigger instance.
Protect launch: load test the launch pattern, add rate limits and feature flags to shed load.
Long term: start the real scaling plan in parallel, without betting the launch on it.
"First I'd find out what 'close to its limits' actually means: CPU, disk, connections or storage, and which queries are responsible. Usually a handful of queries account for most of the load, and a missing index or an unbounded query can be fixed in a day. Next I'd buy headroom with low-risk moves: cache the hottest reads, send read-only traffic like reports to a replica, put a connection pooler in front if connections are the issue, and schedule a move to a bigger instance well before launch day. Then I'd protect the launch itself: load test the expected pattern against a copy of production, add rate limits, and put heavy non-essential features behind flags so we can switch them off if the database struggles. Background jobs pause during the launch window. Sharding or moving some data elsewhere becomes a separate project that starts now, but the launch doesn't depend on it."
Starting an emergency sharding migration two weeks before launch, or doing nothing until the database falls over.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.