Skip to content

System Design Summary

This is one big, deep-dive note covering many important system design topics, explained in simple, plain English with real-world examples. Think of this as a reference sheet you can come back to whenever you need a refresher on any of these topics.

When your application starts getting more users/traffic, at some point your server can’t handle the load anymore. To fix this, you “scale” your system. There are two ways to do this.

This means making your existing single server more powerful - adding more CPU, more RAM, faster disk, etc., to the same machine.

flowchart LR A["Server: 2 CPU, 4GB RAM"] -->|Upgrade| B["Same Server: 16 CPU, 64GB RAM"]

Pros:

  • Simple - no code changes needed, no need to manage multiple machines.
  • No need for a load balancer.

Cons:

  • There’s always a hardware limit - a single machine can only get so big.
  • Usually requires a restart (downtime) to apply the upgrade.
  • Single point of failure - if this one server goes down, your whole app goes down.
  • Can get very expensive very quickly (bigger machines cost disproportionately more).

This means adding more machines/servers that all do the same job, instead of upgrading one single machine.

flowchart LR A[Incoming Traffic] --> LB[Load Balancer] LB --> S1[Server 1] LB --> S2[Server 2] LB --> S3[Server 3]

Pros:

  • Practically unlimited scaling - just keep adding more machines.
  • No downtime needed - new servers can be added while old ones keep running.
  • More fault-tolerant - if one server dies, others keep serving traffic.

Cons:

  • More complex - you now need a Load Balancer to split traffic between machines.
  • Your application needs to be designed to work well across multiple machines (for example, it shouldn’t store important data only in one server’s memory - this is called being “stateless”).
  • Managing many machines (deployment, monitoring, etc.) is more work.

A Load Balancer is a component that sits in front of your servers and distributes incoming traffic across them, so no single server gets overwhelmed.

flowchart LR U[Users] --> LB[Load Balancer] LB --> S1[Server 1] LB --> S2[Server 2] LB --> S3[Server 3]
  • Prevents any one server from being overloaded.
  • Allows horizontal scaling (adding/removing servers without users noticing).
  • Improves reliability - if one server is down, the load balancer simply stops sending traffic to it.

Common Load Balancing Algorithms (Strategies)

Section titled “Common Load Balancing Algorithms (Strategies)”
  1. Round Robin - requests go to servers one by one, in order (Server 1, Server 2, Server 3, Server 1, Server 2…). Simple, works well when all servers are equally powerful.
  2. Least Connections - sends the new request to whichever server currently has the fewest active connections. Useful when some requests take longer than others.
  3. Weighted Round Robin - similar to round robin, but more powerful servers get a higher “weight,” so they receive more requests than weaker servers.
  4. IP Hash - uses the user’s IP address to always send them to the same server (useful when you need a user to keep talking to the same server, called “session stickiness” or “sticky sessions”).

This refers to which “layer” of networking the load balancer operates at:

  • Layer 4 (Transport Layer) Load Balancer: works at a lower, more basic level - it looks only at IP address and port, and forwards raw network traffic (TCP/UDP) without understanding the actual content (like URLs). It’s very fast but “dumb” (it can’t make smart decisions based on the request content).
  • Layer 7 (Application Layer) Load Balancer: understands actual application data - like HTTP requests, headers, URLs, cookies. This means it can make smarter routing decisions, for example: “send all /video requests to the Video servers, and all /login requests to the Auth servers.” Popular tools: NGINX, AWS Application Load Balancer (ALB), HAProxy.

Load balancers regularly “ping” each server to check if it’s healthy (alive and responding correctly). If a server fails a health check, the load balancer automatically stops sending traffic to it, until it becomes healthy again.

Imagine you have data spread (distributed) across multiple servers/caches, and you decide which server holds which piece of data using a simple formula like:

server_number = hash(key) % number_of_servers

This works fine… until you add or remove a server. The moment number_of_servers changes, almost all your keys get mapped to a completely different server than before! This means almost your entire cache/database becomes “wrong” overnight, causing a massive, unnecessary reshuffling of data - very slow and wasteful.

flowchart LR A["hash(key) % 3 servers"] --> B[Server assignments] C["Add 1 server: hash(key) % 4 servers"] --> D["Almost ALL keys move to a different server!"]

Consistent Hashing solves this by arranging servers (and data) on an imaginary circle (called a “hash ring”) instead of using a simple modulo formula.

  1. Both servers and data keys are hashed and placed onto positions on this circular ring.
  2. Each piece of data belongs to the first server found by moving clockwise from its position on the ring.
flowchart LR subgraph Ring["Hash Ring (circular)"] S1[Server A] S2[Server B] S3[Server C] end K1[Key 1] -.->|clockwise, lands on| S1 K2[Key 2] -.->|clockwise, lands on| S2 K3[Key 3] -.->|clockwise, lands on| S3

The magic: When you add or remove a server, only the data between that server and its neighbor on the ring gets reshuffled - everything else stays exactly where it was. This drastically reduces the amount of data movement compared to the simple modulo approach.

In practice, instead of placing just one point on the ring per server, each real server is given many points (virtual nodes) spread around the ring. This helps distribute data more evenly and avoids one server ending up with way more data than others (avoiding “hotspots”).

  • Distributed caches (like Memcached) - deciding which cache server holds which key.
  • Distributed databases like DynamoDB (AWS) and Cassandra - deciding which node stores which piece of data.
  • Load balancers - to route requests consistently based on some key (like user ID), so the same user tends to land on the same backend server.

A Message Queue lets one part of your system send a message/task, and another part pick it up and process it later, independently - without the sender having to wait.

flowchart LR A[Producer / Sender] -->|Pushes message| Q[Message Queue] Q -->|Worker pulls message| B[Consumer / Worker]
  1. Decoupling - the sender and receiver don’t need to know about each other directly, or even be online at the exact same time.
  2. Handling traffic spikes (buffering) - if a sudden burst of requests comes in, they just pile up in the queue instead of crashing your system. Workers process them at a steady pace.
  3. Retry on failure - if processing a message fails, it can be retried automatically, instead of the data being lost.
  4. Scaling workers independently - if processing is slow, just add more workers to consume from the queue faster (parallel processing).
  • Sending emails/SMS/notifications after an order is placed (without making the user wait for the email to actually send).
  • Processing uploaded videos (resizing, transcoding) in the background.
  • Handling a bulk file upload (processing thousands of rows without blocking the main app).
  • Smoothing out sudden traffic spikes - like a ticket-booking website during a big sale.
  • AWS SQS (Simple Queue Service) - simple, managed message queue.
  • RabbitMQ - very popular open-source message broker.
  • Apache Kafka - built for very high-throughput streaming of data (often used for logs, real-time analytics, event streaming - technically more of an “event streaming platform” than a plain queue, but often grouped in this category).

We covered the basics of Monolithic vs Microservices in an earlier note. Here’s a deeper look specifically at why teams choose microservices.

flowchart TB subgraph Micro["Microservices"] A[Auth Service] B[Orders Service] C[Payments Service] D[Search Service] end
  1. Independent Scaling - scale only the service that needs it (e.g., scale the Search service during a sale, without touching Payments).
  2. Independent Deployment - update/deploy one service without redeploying the entire application. Less risky, faster releases.
  3. Technology Freedom - each service can use a different programming language or database, whichever fits that specific job best (e.g., Search service uses Elasticsearch, Orders service uses PostgreSQL).
  4. Fault Isolation - if one service crashes (say, the Recommendation service), the rest of the app (Login, Orders, Payments) can keep working fine.
  5. Team Independence - different teams can own different services, work in parallel, and deploy without stepping on each other’s code.
  1. More operational complexity - now you have many services to deploy, monitor, and manage (this is where tools like Kubernetes and container orchestration become important).
  2. Network calls between services - services now talk to each other over the network (instead of simple in-code function calls), which is slower and can fail.
  3. Harder to debug - tracing a single user request across multiple services (this is often solved using “distributed tracing” tools) is much harder than debugging one single codebase.
  4. Data consistency challenges - since each service might have its own separate database, keeping data consistent across services is harder (this is a deep topic on its own, related to distributed transactions).

Sharding means splitting a large database into multiple smaller pieces (called shards), where each shard holds only a portion of the total data, and these shards are usually spread across different servers.

flowchart LR A[All Users A-Z] --> B[Shard 1: Users A-I] A --> C[Shard 2: Users J-R] A --> D[Shard 3: Users S-Z]

This is different from a Read Replica (which we covered earlier) - a replica holds a full copy of all the data (just for reading), while a shard holds only part of the data (and handles both reads and writes for that part).

When a single database becomes too large or gets too much traffic (reads + writes), even the most powerful single machine can’t handle it. Sharding lets you split both the data and the load across many machines.

  1. Range-Based Sharding - split data based on a range of values. Example: Users with names A-I go to Shard 1, J-R to Shard 2, S-Z to Shard 3.

    • Downside: can create “hotspots” - for example, if way more users have names starting with common letters, one shard gets overloaded while others sit idle.
  2. Hash-Based Sharding - apply a hash function to a key (like user ID) to decide which shard it goes to. This usually spreads data more evenly than range-based sharding.

    • Downside: harder to do range queries (like “get all users between ID 100-200”) since they might be scattered across many shards.
  3. Directory-Based Sharding - keep a separate lookup table (a “directory”) that tracks which shard holds which piece of data. More flexible, but the lookup table itself can become a bottleneck or single point of failure if not handled carefully.

  • Cross-shard queries/joins - if you need data that’s spread across multiple shards (e.g., “find all orders from users in Shard 1 and Shard 2”), it becomes slower and more complex than a simple single-database query.
  • Rebalancing - if one shard grows much bigger than others, you may need to redistribute data across shards, which is a complex, risky operation.
  • Choosing the right shard key - a poorly chosen shard key can lead to uneven load (hotspots) on some shards.

We touched on caching earlier - here’s a deeper look at strategies and challenges.

Caching means storing a copy of frequently-used data somewhere fast to access (usually in-memory, like RAM), so you don’t have to repeat expensive work (like a slow database query or a heavy computation) every single time.

flowchart LR A[Request] --> B{In Cache?} B -->|Yes - Cache Hit| C[Return from Cache - Fast] B -->|No - Cache Miss| D[Fetch from Database] D --> E[Store in Cache] E --> C
  1. Write-Through: every time you write data, you write it to both the cache and the database at the same time. Keeps cache always up-to-date, but writes are a bit slower (since you’re writing to two places).
  2. Write-Back (Write-Behind): you write to the cache first, and update the actual database later (in the background). Faster writes, but riskier - if the cache crashes before the database is updated, you can lose data.
  3. Write-Around: you write directly to the database, skipping the cache. The cache only gets populated later, when that data is actually read. Good for data that’s written often but rarely read again right away.

Cache Eviction Policies (What to Remove When Cache Is Full)

Section titled “Cache Eviction Policies (What to Remove When Cache Is Full)”

Since a cache has limited size (limited RAM), you need a strategy to decide what to remove when it’s full:

  • LRU (Least Recently Used): remove the item that hasn’t been accessed for the longest time. Most commonly used strategy.
  • LFU (Least Frequently Used): remove the item that has been accessed the fewest number of times overall.
  • FIFO (First In, First Out): remove the oldest item added to the cache, regardless of how often it’s used.

One of the trickiest problems in caching is knowing when to update or remove stale (old) data from the cache. If the actual data changes in the database but the old cached copy isn’t updated/removed, users will see outdated information. Common approaches:

  • Time-based expiry (TTL - Time To Live): automatically remove/refresh cached data after a fixed time (e.g., every 60 seconds).
  • Manual/explicit invalidation: when data is updated in the database, actively tell the cache to remove/update that specific entry.
  • Redis - a very popular, fast, in-memory data store used for caching (also supports more advanced data structures).
  • Memcached - another popular, simple in-memory caching system.

How to Avoid a Single Point of Failure (SPOF)

Section titled “How to Avoid a Single Point of Failure (SPOF)”

A Single Point of Failure is any one component in your system that, if it fails, brings down the entire system.

flowchart LR A[Users] --> B[Single Server - No Backup] B -->|If this crashes| C[Entire system goes down!]

Common Single Points of Failure (and How to Fix Them)

Section titled “Common Single Points of Failure (and How to Fix Them)”
  1. A single server: Fix by running multiple replicas of the server behind a Load Balancer, so if one goes down, others keep working.
  2. A single database: Fix using replication (keep multiple copies of the database - a primary plus replicas) so if the primary fails, a replica can be promoted to take over (this is called failover).
  3. A single Load Balancer: Even the load balancer itself can become a single point of failure! Fix by running multiple load balancers (often using DNS-level tricks or special networking setups to spread traffic across them).
  4. A single data center/region: Fix by deploying your application across multiple data centers or regions, so even if an entire region goes down (power outage, natural disaster, etc.), your system still runs elsewhere.
  • Active-Passive: One server/database (the “active” one) handles all traffic, while a backup (“passive”) sits idle, ready to take over instantly if the active one fails.
  • Active-Active: Multiple servers/databases handle traffic at the same time, all actively serving requests. If one fails, the others simply continue - no need to “switch over,” since they were already handling live traffic.
flowchart LR subgraph ActivePassive["Active-Passive"] A1[Active Server - handles all traffic] A2[Passive Server - standby, idle] end subgraph ActiveActive["Active-Active"] B1[Server 1 - handles traffic] B2[Server 2 - handles traffic] end

We introduced this earlier, but here’s a focused, deeper recap.

A CDN is a network of servers spread out geographically around the world, which store cached copies of your content (images, videos, static files, sometimes even whole web pages) close to where your users actually are.

flowchart LR U1[User in India] --> E1[Nearest Edge Server] U2[User in USA] --> E2[Nearest Edge Server] E1 -->|If not cached| M[Main / Origin Server] E2 -->|If not cached| M
  • Speed: users get content from a nearby server instead of a far-away one, drastically reducing load time (latency).
  • Reduced load on your main server: since most repeated requests are served directly from the CDN’s cache, your main (“origin”) server gets far fewer requests.
  • Better reliability during traffic spikes: since a lot of traffic is absorbed by the CDN’s edge servers instead of hitting your main servers directly.
  1. Static content (images, videos, CSS, JS files) - these rarely change, so they are perfect candidates for CDN caching.
  2. Dynamic content (personalized data, live data) - CDNs can sometimes still help here through more advanced techniques, but generally dynamic content is harder to cache effectively.

Popular tool name: AWS CloudFront, Cloudflare, Akamai, Google Cloud CDN. The generic term is just CDN.

We touched on this earlier in the fan-out architecture note - here’s a focused, standalone explanation.

In the Pub/Sub model, senders (Publishers) don’t send messages directly to specific receivers. Instead, they publish messages to a “topic” (a named channel), and any number of Subscribers can subscribe to that topic to automatically receive those messages.

flowchart LR P[Publisher] -->|Publishes to Topic| T[Topic: 'order-placed'] T --> S1[Subscriber 1: Email Service] T --> S2[Subscriber 2: SMS Service] T --> S3[Subscriber 3: Analytics Service]
  • Decoupling: the publisher doesn’t need to know who (or how many services) are listening. You can add new subscribers later without changing the publisher’s code at all.
  • One-to-many communication: a single event can trigger many different, independent actions (unlike a simple message queue, which is usually one-to-one between a message and a single consumer).
  • Scalability: you can add more subscribers as your system grows, without touching existing components.
  • AWS SNS (Simple Notification Service)
  • Google Cloud Pub/Sub
  • Apache Kafka (can also act like a powerful Pub/Sub system with topics)
  • Redis Pub/Sub (a simpler, lightweight version)

An Event-Driven System is an architecture style where different parts of your system communicate by producing and reacting to “events” (something that happened - like “OrderPlaced”, “PaymentCompleted”, “UserSignedUp”) rather than directly calling each other.

flowchart LR A[Order Service] -->|Emits Event: OrderPlaced| B[Event Bus / Broker] B --> C[Inventory Service - reduces stock] B --> D[Notification Service - sends email] B --> E[Analytics Service - logs event]

Instead of the Order Service directly calling the Inventory Service, Notification Service, and Analytics Service one by one (tight coupling), it just emits one event, and any interested service can react to it independently.

  1. Choreography: Each service listens for events and decides on its own what to do next - there’s no central “controller.” Services react independently, like dancers who know their own moves without a director telling them what to do (hence “choreography”).
  2. Orchestration: There IS a central coordinator (an “orchestrator”) that tells each service what to do and in what order - more like a conductor leading an orchestra.
flowchart TB subgraph Choreography["Choreography - no central controller"] A1[Order Service] --> E1[Event] --> B1[Service A] E1 --> C1[Service B] end subgraph Orchestration["Orchestration - central controller"] O[Orchestrator] --> D1[Service A] O --> D2[Service B] end
  • Loose coupling - services don’t need to know about each other directly.
  • Scalability - each service reacts and scales independently.
  • Real-time responsiveness - systems can react to things as they happen, instead of waiting for scheduled checks.
  • Harder to trace/debug - since actions are spread across many independent services reacting to events, it’s harder to follow the full flow of what happened for a given request.
  • Eventual consistency - since reactions happen asynchronously (not instantly), different parts of the system might be “out of sync” for a brief moment before catching up.
  • SQL (Relational) Databases (like MySQL, PostgreSQL) store data in structured tables with fixed columns, and enforce strict relationships between tables.
  • NoSQL Databases are more flexible - they don’t require a fixed table structure, and are built to scale easily across many servers (horizontal scaling).
  1. Key-Value Stores - the simplest type. Data is stored as a simple key → value pair, like a giant dictionary. Very fast for simple lookups.

    • Examples: Redis, DynamoDB (AWS).
  2. Document Databases - store data as flexible “documents” (usually in JSON-like format), where each document can have a different structure.

    • Examples: MongoDB, Firestore (GCP).
flowchart LR A["Document Example"] --> B["{ name: 'Alex', age: 25, hobbies: ['coding', 'music'] }"]
  1. Column-Family (Wide-Column) Databases - store data in columns rather than rows, optimized for very fast reads/writes on huge datasets, often across many machines.

    • Examples: Cassandra, HBase.
  2. Graph Databases - designed to store and query relationships between data (like a social network - who’s friends with whom). Great for highly connected data.

    • Examples: Neo4j, Amazon Neptune.
  • Flexible schema - no need to define a fixed structure upfront; good for rapidly changing data.
  • Built for horizontal scaling - many NoSQL databases were designed from the ground up to scale across many machines (unlike traditional SQL databases, which historically scaled vertically first).
  • Great for specific use cases - like caching (key-value), content management (document), huge time-series data (column-family), or social networks (graph).

When designing a distributed database, there’s a well-known trade-off called the CAP Theorem, which says you can only fully guarantee two out of three of these at the same time:

  • Consistency - every read gets the latest, most up-to-date data.
  • Availability - every request gets a response (even if it’s not the absolute latest data).
  • Partition Tolerance - the system keeps working even if there’s a network failure between servers.

Since network partitions can always happen in real distributed systems, in practice, most systems have to choose between prioritizing Consistency or Availability when a network issue occurs. Different NoSQL databases make different choices here based on their design goals.

API stands for Application Programming Interface. In simple words, it’s a defined way for two different programs/systems to talk to each other - like a menu at a restaurant: you (the client) pick something from the menu (the API), and the kitchen (the server) prepares and gives you what you asked for, without you needing to know exactly how it’s cooked.

flowchart LR A[Client - App/Website] -->|Sends Request| B[API] B -->|Talks to| C[Server / Database] C --> B B -->|Sends Response| A

REST (Representational State Transfer) is the most widely used style for designing web APIs. It’s based on a few simple ideas:

  1. Resources - everything is treated as a “resource” (like a user, an order, a product), usually identified by a URL. Example: /users/123 represents the user with ID 123.
  2. HTTP Methods - different actions use different standard HTTP methods:
    • GET - fetch/read data (e.g., GET /users/123 → get user 123’s info).
    • POST - create new data (e.g., POST /users → create a new user).
    • PUT / PATCH - update existing data (PUT usually replaces the whole resource, PATCH updates just part of it).
    • DELETE - remove data (e.g., DELETE /users/123).
  3. Status Codes - the server tells the client what happened using standard numeric codes:
    • 200 OK - success.
    • 201 Created - something new was successfully created.
    • 400 Bad Request - the client sent an invalid request.
    • 401 Unauthorized / 403 Forbidden - authentication/permission issues.
    • 404 Not Found - the requested resource doesn’t exist.
    • 500 Internal Server Error - something went wrong on the server’s side.
  1. Use clear, consistent naming - use plural nouns for resources (e.g., /users, not /getUser), and keep naming patterns consistent across your whole API.
  2. Versioning - as your API evolves, avoid breaking existing users by adding a version in the URL or headers (e.g., /v1/users, /v2/users), so old clients keep working while you improve the API.
  3. Pagination - when returning large lists of data (like thousands of orders), don’t return everything at once. Instead, return data in smaller “pages” (e.g., ?page=2&limit=20), so responses stay fast and lightweight.
  4. Authentication & Authorization - make sure only the right users can access the right data. Common approaches: API keys, OAuth tokens, JWT (JSON Web Tokens).
  5. Rate Limiting - (as covered earlier) limit how many requests a client can make in a given time, to protect your API from abuse.
  6. Idempotency - an operation is “idempotent” if doing it multiple times has the same effect as doing it once. For example, DELETE /users/123 should have the same end result whether called once or five times (the user is deleted either way). This matters especially for PUT and DELETE, and is important for safely retrying failed requests (e.g., due to network issues) without accidentally creating duplicate effects.
  7. Good error messages - when something goes wrong, return a clear, useful error message (not just a status code), so developers using your API can quickly understand and fix the issue.
TopicOne-Line Takeaway
Vertical ScalingMake one server bigger/stronger
Horizontal ScalingAdd more servers of the same size
Load BalancingSpread traffic evenly across multiple servers
Consistent HashingSmart way to map data to servers so adding/removing servers doesn’t reshuffle everything
Message QueueLet one part of the system hand off work to another part, without waiting
MicroservicesBreak one big app into small, independently scalable services
Database ShardingSplit a big database into smaller pieces spread across servers
CachingStore frequent results in fast memory to avoid repeating expensive work
Avoiding SPOFNever rely on just one of anything critical - add redundancy everywhere
CDNServe content from servers close to the user, worldwide
Pub/SubOne event, delivered to many independent subscribers
Event-Driven SystemsComponents react to events instead of calling each other directly
NoSQL DatabasesFlexible, horizontally-scalable databases for specific data needs
API DesignA clear, consistent, well-documented way for systems to talk to each other