Lesson: How Do Apps Survive a Million Users Without Crashing?
π Full written solution: https://interview-kit-fe.vercel.app/system-design-concepts-every-software-engineer-should-know π» Code on GitHub: https://github.com/timepasshub2539-hue/leetcode-python/tree/main/system-design-concepts-every-software-engineer-should-know Chapters: 0:00 How Do Apps Survive a Million Users Without Crashing? 0:27 The Only Two Words You Need First 0:49 Vertical vs Horizontal Scaling 1:13 Load Balancer: The Traffic Director 1:32 Caching: Remember the Answer 1:53 Database Scaling: Replicas 2:15 Sharding: Splitting the Data 2:36 Message Queues: Don't Wait in Line 2:57 Mistake: The Single Point of Failure 3:19 Mistake: Caching Stale Data 3:43 SQL vs NoSQL 4:17 Monolith vs Microservices 4:45 Putting It All Together 5:07 The One Question That Replaces All Six Terms In this lesson we break down system design from first principles: why apps crash under load, vertical vs horizontal scaling, load balancers, caching, database replicas and sharding, message queues, and the two classic mistakes (single point of failure, stale cache). We also cover SQL vs NoSQL and monolith vs microservices, then tie it all together with the one question that replaces every buzzword. #systemdesign #softwareengineering #scalability #computerscience #coding Watch next: - Lesson 11: Why Does return None Happen to Everyone?: https://youtu.be/uTHuP6xhP5M - Lesson: The Bug That Gets Everyone (Lists vs Tuples Explained): https://youtu.be/N-aP3biAzDs - One habit fixes every edge case #shorts: https://youtu.be/0E46uSsX7c4
Introduction
Every developer eventually hits the same wall: the code works perfectly on your laptop, passes every test, and then falls over the moment real traffic shows up. That gap between "it runs" and "it survives a million users" is exactly what system design is about.
System design interviews exist because they test something code katas can't: can you reason about failure? Can you look at a system and immediately spot the piece that, if it disappeared, would take everything down with it? That instinct matters just as much in production as it does in an interview room.
This lesson isn't about memorizing buzzwords like "load balancer" or "sharding" so you can drop them in an interview. It's about understanding why each piece exists, what problem it solves, and what breaks if you skip it. Once you understand the why, the vocabulary takes care of itself.
Problem Overview
Here's the scenario, stripped down to its core: you have one server running your application, and it's handling a small number of users just fine. Then your product takes off. Traffic goes from a hundred users to a hundred thousand, then a million. At some point, that one server simply cannot keep up β it runs out of CPU, memory, or database connections, and requests start timing out or failing outright.
The question system design answers is: how do you restructure an application so that no single machine, and no single piece of infrastructure, is a bottleneck or a failure point? The answer isn't one trick β it's a stack of techniques that work together: scaling strategy, traffic distribution, caching, data replication, data partitioning, and asynchronous processing.
The goal of this lesson is to build each piece up individually, in isolation, so you understand exactly what job it does β and then show how they all click together into one coherent system.
Example
Let's ground this in a concrete example: an e-commerce checkout page.
- A user loads the product page β this is a read.
- A user adds an item to their cart β another read (check stock) plus a write (update cart).
- A user completes checkout β a write (create order), followed by a background job (send confirmation email).
At small scale, one server and one database handle all of this without issue. At large scale:
- Thousands of simultaneous product-page reads would overwhelm a single database if every request hit it directly β this is where caching and read replicas come in.
- Millions of user records would eventually be too much for one database machine to store efficiently β this is where sharding comes in.
- Sending confirmation emails synchronously would make checkout slow and fragile β this is where message queues come in.
- If your one server crashes, checkout should keep working β this is where horizontal scaling and load balancers come in.
Every technique in this lesson maps directly onto a real bottleneck this simple checkout flow would eventually hit.
Intuition
The way an experienced engineer approaches this problem isn't "which fancy technology should I use" β it's "where exactly does this system break, and what's the smallest fix for that specific break."
Start with two words: latency and throughput. Latency is how long a single request takes. Throughput is how many requests the whole system can handle per second. They're related but distinct, and they fail for different reasons β a system can have great latency (each request is fast) but terrible throughput (it can only handle ten requests at a time), or vice versa. Every technique below is really just a lever for one or both of these numbers.
From there, the intuition builds in a logical chain:
- One machine isn't enough β add more machines (scaling).
- Now you have many machines, so something must route traffic to them β load balancer.
- Many requests ask for the same data repeatedly β don't recompute it, cache it.
- Most systems read far more than they write β make copies of the data for reading (replicas).
- Even a fast database can run out of physical room β split the data across machines (sharding).
- Some work is slow and doesn't need to happen immediately β hand it off asynchronously (queues).
Each step exists because the previous step, taken alone, creates a new bottleneck. That's the pattern to internalize: system design is bottleneck-chasing, not technology collecting.
Brute Force Solution
The "brute force" approach to scaling is vertical scaling: buy a bigger machine. More CPU, more RAM, a faster disk.
Algorithm:
- Monitor the single server's resource usage.
- When it's saturated, upgrade the hardware.
- Repeat as traffic grows.
Advantages:
- Extremely simple β no architectural changes, no new failure modes, no network calls between components.
- No data consistency issues, since there's only one copy of everything.
Disadvantages:
- Hits a hard physical ceiling β there's a limit to how big a single machine can get.
- Zero fault tolerance: if that one machine crashes, the entire application goes down.
- Expensive at the high end β the biggest machines cost disproportionately more than several smaller ones combined.
Complexity (informally):
- "Time" (capacity growth): bounded by the largest machine you can buy β effectively O(1) ceiling.
- "Space" (fault tolerance): zero redundancy β a single point of failure by construction.
This is a reasonable starting point for small apps, but it fundamentally cannot survive "a million users" because failure isn't distributed β it's concentrated in one place.
Optimal Solution
The optimal approach is horizontal scaling combined with the supporting techniques that make many machines behave like one reliable system.
Step-by-step:
1. Horizontal scaling β instead of one large server, run several normal-sized servers. If one fails, the others keep serving traffic.
2. Load balancer β sits in front of the servers and routes each incoming request to a healthy instance. It continuously checks server health and stops routing to any instance that's down.
ββββββββββββββ
Requests ββββ βLoad Balancerβ
βββββββ¬βββββββ
βββββββββββΌββββββββββ
βΌ βΌ βΌ
[Server A] [Server B] [Server C]
3. Caching β before hitting the database, servers check a fast in-memory cache. If the answer is already there, return it in milliseconds instead of hundreds of milliseconds.
4. Database replicas β one primary database accepts writes; multiple read replicas serve read traffic, since most apps read far more than they write.
Writes Reads
β β
βΌ βΌ
[Primary DB] ββcopiesβββ [Replica 1]
[Replica 2]
5. Sharding β when the dataset itself is too large for one machine, split it by a rule (e.g., user ID range) across multiple database machines.
6. Message queues β slow, non-urgent work (emails, notifications, report generation) gets pushed onto a queue and processed by background workers, so the main request path stays fast.
Request β [do fast work] β respond to user
β
βΌ
[Queue] β [Worker] β (slow work happens here)
Put together: a request hits the load balancer, gets routed to a healthy server, that server checks the cache, falls back to a replica for reads or the primary for writes, and offloads anything slow to a queue. No single piece carries the whole system, and no single failure takes everything down.
Python Code
Here's a simplified simulation that captures the core ideas β load balancing, caching, and a basic queue β as runnable Python rather than production infrastructure (in reality you'd use tools like NGINX, Redis, and RabbitMQ/SQS):
import random
import time
from collections import deque
class Server:
"""A single backend instance that can go up or down."""
def __init__(self, name):
self.name = name
self.healthy = True
def handle_request(self, request):
if not self.healthy:
raise RuntimeError(f"{self.name} is down")
return f"{self.name} handled '{request}'"
class LoadBalancer:
"""Routes requests to healthy servers using round-robin."""
def __init__(self, servers):
self.servers = servers
self._index = 0
def route(self, request):
for _ in range(len(self.servers)):
server = self.servers[self._index]
self._index = (self._index + 1) % len(self.servers)
if server.healthy:
return server.handle_request(request)
raise RuntimeError("All servers are down")
class Cache:
"""A tiny sticky-note cache with a fixed expiry (TTL) per entry."""
def __init__(self, ttl_seconds=5):
self.ttl = ttl_seconds
self._store = {}
def get(self, key):
entry = self._store.get(key)
if entry is None:
return None
value, expires_at = entry
if time.time() > expires_at:
del self._store[key]
return None
return value
def set(self, key, value):
self._store[key] = (value, time.time() + self.ttl)
class MessageQueue:
"""A basic FIFO queue for offloading slow work."""
def __init__(self):
self._queue = deque()
def enqueue(self, task):
self._queue.append(task)
def process_one(self):
if self._queue:
task = self._queue.popleft()
print(f"Worker processing: {task}")
def fetch_data(cache, database, key):
cached = cache.get(key)
if cached is not None:
return f"(cache) {cached}"
result = database[key]
cache.set(key, result)
return f"(database) {result}"
if __name__ == "__main__":
servers = [Server("A"), Server("B"), Server("C")]
servers[1].healthy = False # simulate a crashed server
lb = LoadBalancer(servers)
for i in range(4):
print(lb.route(f"request-{i}"))
cache = Cache(ttl_seconds=5)
database = {"user:42": "Alice"}
print(fetch_data(cache, database, "user:42")) # database
print(fetch_data(cache, database, "user:42")) # cache
queue = MessageQueue()
queue.enqueue("send confirmation email to Alice")
queue.process_one()
Code Walkthrough
Serverrepresents one backend instance.healthyis the flag a load balancer checks before routing β this mirrors real health checks.LoadBalancer.routeimplements round-robin routing: it walks through servers starting from_index, skipping any unhealthy one, and raises only if all servers are down. This is the "host at a restaurant" analogy in code β customers keep getting seated as long as one table is open.Cachestores a(value, expiry_timestamp)tuple per key.getchecks the timestamp and evicts expired entries automatically β this directly encodes the "cache without an expiry plan is a bug" lesson from the transcript.fetch_datais the classic cache-aside pattern: check the cache first, fall back to the "database" (a dict here), and populate the cache on a miss.MessageQueueis a deliberately minimal FIFO queue.enqueuerepresents the app dropping off a task;process_onerepresents a worker picking it up independently β decoupling the fast path (respond to the user) from the slow path (send the email).
Dry Run
Using the example above:
- Four requests come in:
request-0throughrequest-3. Server B is marked unhealthy. routestarts at index 0 (Server A) β handlesrequest-0, moves to index 1.- Index 1 is Server B, which is unhealthy β skipped, moves to index 2.
- Server C handles
request-1, wraps back to index 0. - Server A handles
request-2, skips B again, Server C handlesrequest-3. - Result: A and C split the load evenly; B never receives traffic β exactly what a load balancer should do.
fetch_data(cache, database, "user:42")is called twice. First call: cache miss β reads"Alice"from the database, stores it in the cache with a 5-second expiry, returns"(database) Alice".- Second call (within 5 seconds): cache hit β returns
"(cache) Alice"without touching the database. queue.enqueue(...)adds the email task;process_one()pops and "processes" it β this would happen on a separate worker process in a real system, not blocking the original request.
Complexity Analysis
- Load balancer routing: O(n) in the worst case per request, where n is the number of servers (if most are down), but O(1) amortized in the common case of mostly-healthy servers. Space: O(n) to track server states.
- Cache lookup/insert: O(1) average time for both
getandset, since it's backed by a dictionary. Space: O(k) where k is the number of cached keys, bounded by eviction/TTL policy. - Queue enqueue/dequeue: O(1) for both operations using a
deque, since it supports O(1) appends and pops from either end.
These are correct because each structure is backed by a hash map or a doubly linked list under the hood (Python's dict and deque), both of which guarantee constant-time operations for these access patterns.
Alternative Solutions
An alternative to round-robin load balancing is least-connections routing, where the load balancer sends each request to whichever server currently has the fewest active connections. It's more accurate under uneven request durations but requires tracking live connection counts, adding state and complexity. Round-robin is simpler and works well when requests are roughly uniform in cost β which is why it's the more common default.
Similarly, an alternative to synchronous cache population is write-through caching, where the cache is updated at write time rather than lazily on the next read. It avoids the first-request cache miss but adds complexity to every write path. Cache-aside (used above) is simpler and sufficient for most read-heavy workloads.
Edge Cases
- All servers down: the load balancer must fail gracefully (raise a clear error) rather than infinite-looping β handled above with the
for _ in range(len(self.servers))bound. - Empty cache: first request for any key is always a miss β this is expected and should degrade gracefully to the database, not crash.
- Expired cache entry: must be treated identically to a missing key, not returned stale β this is the "stale cache" trap from the lesson.
- Duplicate cache keys with different values: the newest
setcall should always win; there's no scenario where an older value should override a newer one. - Empty queue:
process_oneon an empty queue should be a no-op, not an error β a worker with nothing to do simply waits.
Common Mistakes
- Treating caching as "set it and forget it" β without an expiry or invalidation strategy, users eventually see stale data (prices, statuses) that quietly erodes trust.
- Building with a single database and "adding a backup later" β later often never comes, and that one server becomes a single point of failure.
- Reaching for microservices before you have separate teams or separate scaling needs β the network calls between services add latency and failure modes that a monolith simply doesn't have.
- Jumping to NoSQL for scale you'll never reach β a well-indexed SQL database is often simpler, safer, and perfectly capable.
- Confusing latency and throughput β optimizing one without checking the other (e.g., adding more servers when the real problem is a slow query) wastes effort on the wrong lever.
- Sharding before it's necessary β sharding adds real complexity (the app must know which shard to query), so it should be a response to actual data volume, not a preemptive move.
- Forgetting that only the primary can accept writes β routing a write to a read replica either fails outright or, worse, silently creates data inconsistency.
Interview Questions
- How would you decide when to shard a database, and what sharding key would you choose for a given dataset?
- What happens to your system if the load balancer itself goes down β how do you avoid it becoming a new single point of failure?
- How do you keep a cache consistent when the underlying data changes frequently?
- When would you choose eventual consistency (replicas) over strong consistency, and what are the tradeoffs?
- Walk through what changes in your design if reads suddenly outnumber writes 100-to-1.
- How would you design retry logic for a message queue worker that fails partway through a task?
Similar Problems
- Design a URL shortener β tests caching, database indexing, and read-heavy scaling, directly building on the caching and replica concepts here.
- Design a rate limiter β tests throughput control, closely related to the load balancer's job of managing traffic distribution.
- Design a notification system β tests message queues and asynchronous processing directly, extending the queue section above.
- Design a distributed cache (like Redis) β goes deeper into the caching concept, including eviction policies and consistency.
- Design an e-commerce inventory system β tests sharding and write consistency under concurrent updates, extending the replica/shard tradeoffs discussed here.
Key Takeaways
System design isn't a list of buzzwords to memorize β it's a set of answers to one recurring question: what happens if this piece disappears right now? Horizontal scaling and load balancers solve the server problem. Caching solves the speed problem. Replicas and sharding solve the database problem. Message queues solve the "slow work shouldn't block fast work" problem. Every technique exists because skipping it creates a specific, predictable failure β and once you can name that failure, the solution becomes obvious rather than memorized.
Watch the Video
For the full walkthrough with the cafe and restaurant analogies that make these ideas click, watch the video here: {LINK}
About the Series
This lesson is part of the Daily Python LeetCode series, where each entry breaks down one interview-relevant concept β from classic algorithm problems to the system design fundamentals that come up in senior and staff-level interviews. The goal is always the same: build real intuition, not memorized answers.
Call To Action
If this breakdown helped clarify how real systems scale, give the video a like on YouTube and subscribe for the rest of the series. Subscribe to the Substack newsletter to get each new lesson straight to your inbox. Drop a comment with the system design topic you want covered next, and share this with a developer prepping for interviews.
The solution
if cache.has(key):
return cache.get(key)
result = slow_database_query(key)
cache.set(key, result)
return resultReady to try it yourself? Solve Stack problems with instant feedback in the practice sandbox.
Practice now β


