← Blog
·12 blog.minutes

System Design Fundamentals Every Developer Should Know

Load balancing, caching, sharding, CDNs, message queues, CAP theorem, rate limiting — demystified with real-world examples and configs you can actually use.

Every backend developer eventually hits a wall. The application works fine on your laptop. It works fine in staging with three concurrent users. Then you deploy to production, a thousand people show up at once, and the database falls over. The API starts returning 5xx errors, the frontend hangs, and somewhere in Slack someone posts the screenshot that makes your phone vibrate at 2 AM.

System design is what lives on the other side of that wall. It is the set of patterns, tradeoffs, and infrastructure decisions that separate a toy application from a production system that can handle real traffic without collapsing. The good news is that you do not need to be a senior infrastructure engineer to understand the fundamentals. You need to know about eight concepts, how they interact, and when to reach for each one.

This article covers the system design patterns that matter most for working backend developers. Each section explains the concept, shows a practical example, and describes the tradeoffs you need to evaluate before adopting it. These are not abstract textbook concepts — they are tools you will reach for every time you build something that needs to scale.

Load Balancing — Keeping Every Server Busy, but Not Too Busy

Load balancing is the simplest and most impactful system design pattern you can implement. The idea is straightforward: instead of sending all traffic to a single server, you distribute incoming requests across a pool of servers. This gives you two things at once: higher availability (if one server dies, the others keep serving) and higher throughput (multiple servers share the work).

The most common load balancing algorithms are round-robin, least connections, and IP hash. Round-robin cycles through the server list in order — simple, predictable, but unaware of how busy each server actually is. Least connections sends each request to the server with the fewest active connections, which handles uneven request loads better. IP hash uses the client's IP address to deterministically pick a server, which matters when you need sticky sessions — ensuring the same client always hits the same server.

In practice, most production setups use a combination. A layer 4 load balancer (operating at the TCP level) distributes raw connections across a pool of reverse proxies or API gateways, which then perform layer 7 load balancing (operating at the HTTP level) to route requests to specific application instances. This layered approach keeps the data plane fast and the routing logic flexible.

# nginx.conf — simple round-robin load balancing across three app servers
upstream app_cluster {
    round-robin;
    server app1.internal:3000 max_fails=3 fail_timeout=30s;
    server app2.internal:3000 max_fails=3 fail_timeout=30s;
    server app3.internal:3000 max_fails=3 fail_timeout=30s;
}

server {
    listen 443 ssl;
    location / {
        proxy_pass http://app_cluster;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

The critical detail most developers miss is health checking. A load balancer is only useful if it knows which servers are healthy. If app2 crashes but the load balancer keeps sending traffic to it, your error rate spikes by roughly a third. Always configure active health checks — the load balancer periodically pings each server and removes unresponsive ones from the pool automatically.

Caching — Speed, but at What Cost?

Caching is the single highest-leverage performance optimization available to any developer. A single cache hit can turn a 200 millisecond database query into a 2 millisecond memory lookup. Two orders of magnitude. Applied across millions of requests, that difference is the difference between a $10,000 monthly database bill and a $500 one.

The canonical caching stack has three layers, each with different characteristics. Application-level caching (in-memory caches like Redis or Memcached) stores the results of expensive computations or database queries. CDN caching stores static and semi-static assets at edge locations close to users. HTTP caching uses Cache-Control and ETag headers to let browsers and proxies cache responses without involving your servers at all.

The hardest part of caching is not setting it up — it is invalidating the cache when the underlying data changes. The industry has settled on a few reliable patterns. Cache-aside (also called lazy loading) means the application checks the cache first, falls back to the database on a miss, and populates the cache with the result. Write-through cache means every write goes to both the cache and the database simultaneously. Write-behind cache means writes go to the cache first and are asynchronously flushed to the database.

There are only two hard things in computer science: cache invalidation and naming things. Cache invalidation is harder because you cannot just rename it and hope it goes away.

For most applications, cache-aside with a short time-to-live is the right default. Set a TTL that matches your tolerance for stale data — 60 seconds for user profiles, 5 minutes for product listings, 24 hours for reference data. When you need stronger consistency, use a write-through cache but accept the higher write latency. When you need maximum read throughput and can tolerate eventual consistency, use cache-aside with a generous TTL.

import redis.asyncio as aioredis
import json

cache = aioredis.Redis.from_url("redis://cache:6379")

CACHE_TTL = 300  # 5 minutes

async def get_user_profile(user_id: str) -> dict:
    key = f"profile:{user_id}"
    cached = await cache.get(key)
    if cached:
        return json.loads(cached)
    profile = await db.fetch_user(user_id)
    if profile:
        await cache.setex(key, CACHE_TTL, json.dumps(profile))
    return profile

A word of warning: caching can mask problems rather than solve them. If your database queries are slow because you are missing an index, adding a cache hides the symptom but the underlying query is still slow. The cache evicts, the slow query runs, the user waits. Always profile and optimize your slow paths first, then add caching on top of the optimized version.

Database Sharding — Splitting the Work So No Single Database Drowns

Sharding (horizontal partitioning) is what you reach for when a single database instance cannot handle your write throughput or dataset size. The idea is to split your data across multiple database instances, where each instance (shard) holds a subset of the data. The application determines which shard to query based on a shard key — typically a hash of the user ID, a geographic region, or a time range.

The shard key is the single most important decision in any sharded system. A good shard key distributes data evenly across shards and aligns with your query patterns. A bad shard key creates hot shards — a few shards that handle most of the traffic while the rest sit idle. For example, sharding by creation timestamp sounds reasonable until you realize that today's shard handles all writes while last year's shards handle none.

Consistent hashing solves the rebalancing problem that plagues naive sharding. In a simple modulo-based sharding scheme (shard = hash(key) % N), adding a new shard requires reshuffling nearly all data. Consistent hashing maps both keys and shards onto a hash ring; when you add a shard, only the keys in the immediate neighborhood of the new shard need to move. This makes scaling up and down much less painful.

  • Hash-based sharding — distribute by hashing the shard key; simple and even but resharding is expensive
  • Range-based sharding — split by value ranges (e.g., users with IDs 1–10000 on shard A, 10001–20000 on shard B); efficient for range queries but prone to hot spots
  • Directory-based sharding — maintain a lookup table mapping keys to shards; flexible but adds a lookup hop and a single point of failure if the directory goes down
  • Geographic sharding — split by user region; excellent for latency but awkward if users travel or if data needs to be global

The tradeoff you accept with sharding is that cross-shard queries become expensive or impossible. If you shard your users table by user_id and your orders table by user_id, a query for all orders from the last 30 days must hit every shard. Applications that need global analytics or cross-shard joins often use a secondary read replica (or a dedicated analytics database) that aggregates data from all shards asynchronously.

The CAP Theorem — You Can Only Pick Two

The CAP theorem states that a distributed data store cannot simultaneously provide more than two of three guarantees: Consistency (every read receives the most recent write), Availability (every request receives a response, even if it is not the most recent data), and Partition Tolerance (the system continues to operate despite network failures between nodes).

In practice, partition tolerance is not optional. Networks fail. Packets get dropped, connections time out, data centers lose power. So the real choice is between CP (consistency + partition tolerance) and AP (availability + partition tolerance). A CP system like etcd or Zookeeper will refuse to serve reads if it cannot guarantee consistency across nodes. An AP system like Cassandra or DynamoDB will serve reads from whatever node is reachable, even if that node has stale data.

This is not an academic distinction. When you design a system that spans multiple data centers, you must decide what happens when the network link between them goes down. Do you keep serving requests with potentially stale data (AP)? Or do you stop serving until the network recovers (CP)? The answer depends on your application. A content delivery network should be AP — stale content is better than no content. A payment processing system should be CP — you never want to double-charge a customer because two nodes accepted the same payment while partitioned.

Message Queues — Turning Synchronous Pain into Asynchronous Grace

Message queues are the backbone of asynchronous processing in distributed systems. They let one service (the producer) send a message to a queue without waiting for the consumer to process it. The consumer picks up the message when it is ready, processes it, and acknowledges completion. This decouples the producer from the consumer in both time and space — they do not need to run at the same speed, or even at the same time.

Every non-trivial backend system should use a message queue somewhere. The canonical example is sending emails. When a user registers on your platform, you do not want the HTTP response to wait for the email delivery service to render a template, connect to SendGrid, and deliver the message. Instead, your API pushes a send_email event to a queue and returns a 201 Created response immediately. A separate worker picks up the event, sends the email, and marks the task as complete.

The two dominant message queue models are publish-subscribe (pub/sub) and work queues. In pub/sub, each message is broadcast to all subscribers. This is useful for event-driven architectures where multiple services need to react to the same event — a new user registration might trigger a welcome email, a CRM update, and an analytics event simultaneously. In a work queue, each message is delivered to exactly one consumer. This is useful for distributing work across a pool of workers — each image upload goes to exactly one thumbnail generator.

# docker-compose.yml — minimal RabbitMQ setup for local development
version: "3.8"
services:
  rabbitmq:
    image: rabbitmq:3-management-alpine
    ports:
      - "5672:5672"   # AMQP port for producers/consumers
      - "15672:15672" # Management UI
    environment:
      RABBITMQ_DEFAULT_USER: app
      RABBITMQ_DEFAULT_PASS: dev-only-password
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq

volumes:
  rabbitmq_data:

The tricky part of message queues is handling failures gracefully. What happens if the consumer crashes halfway through processing a message? RabbitMQ and Amazon SQS handle this with delivery acknowledgments — the consumer must explicitly acknowledge that a message was processed successfully. If the consumer disconnects without acknowledging, the message is re-queued and delivered to another consumer. This at-least-once delivery guarantee means your consumers must be idempotent: processing the same message twice must produce the same result as processing it once.

Dead letter queues are another essential pattern. When a message cannot be processed after several retries (the downstream service is down, the data is malformed, the business rule changed), the message is moved to a dead letter queue instead of being retried forever. An operator monitors the dead letter queue, investigates the root cause, and either fixes the message and re-queues it or discards it after confirming it is safe to ignore.

CDNs and Rate Limiting — The Frontline Defenses

Content delivery networks and rate limiters serve different purposes but they share a common trait: they are the first line of defense between your users and your servers. A CDN keeps static assets and cached responses close to the user, reducing latency and offloading traffic from your origin servers. A rate limiter prevents any single user or client from overwhelming your system with requests.

CDNs work by distributing your content across a global network of edge servers. When a user in Tokyo requests an asset, the CDN serves it from the nearest edge location rather than routing the request all the way to your origin server in Virginia. This cuts latency from 200 milliseconds to 10 milliseconds for static assets. Modern CDNs go further — they can cache API responses, terminate TLS connections, and even execute serverless functions at the edge.

Rate limiting protects your system at multiple levels. Global rate limiting caps the total requests your entire system can handle per second, protecting against traffic spikes and DDoS attacks. Per-user rate limiting ensures one abusive tenant cannot starve other users of resources. Endpoint-level rate limiting applies different limits to different routes — a login endpoint might allow 5 requests per minute while a read-only search endpoint allows 100 requests per minute.

The sliding window algorithm is the industry standard for rate limiting because it is both accurate and efficient. Instead of resetting counters at fixed intervals (which allows bursts at the boundary), a sliding window considers requests over a rolling time window. Redis is the natural choice for implementing this — use a sorted set with timestamps as scores, trim entries outside the window, and count the entries that remain. The memory cost is minimal (a few bytes per request) and the time complexity is logarithmic.

// Redis-backed sliding window rate limiter (TypeScript)
import { createClient } from "redis";

const redis = createClient({ url: "redis://ratelimit:6379" });

async function checkRateLimit(
  key: string,
  limit: number,
  windowMs: number
): Promise<{ allowed: boolean; remaining: number }> {
  const now = Date.now();
  const windowStart = now - windowMs;

  const multi = redis.multi();
  multi.zRemRangeByScore(key, 0, windowStart);
  multi.zAdd(key, { score: now, value: `${now}` });
  multi.zCard(key);
  multi.expire(key, Math.ceil(windowMs / 1000));

  const [, , count] = await multi.exec() as [any, any, number];
  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
  };
}

Both CDNs and rate limiters share an important operational principle: fail open or fail closed? If the CDN edge cannot reach the origin, should it serve a stale cached response (fail open) or return an error (fail closed)? If the rate limiter's Redis cluster goes down, should all requests pass through (fail open — risk of overload) or should all requests be rejected (fail closed — guaranteed downtime)?

There is no universal answer, but a good default for most systems is: fail open for reads, fail closed for writes. A stale product listing page is acceptable. A lost purchase order is not. Document this decision explicitly in your runbook so the on-call engineer knows what behavior to expect when the infrastructure buckles.

Putting It All Together — Thinking in Tradeoffs

System design is not about memorizing patterns. It is about understanding tradeoffs and recognizing which pattern fits your constraints. Every decision involves a tradeoff between consistency and availability, between read throughput and write latency, between operational complexity and raw performance. The best engineers are not the ones who know the most patterns — they are the ones who can look at a problem and identify which constraints are fixed and which are negotiable.

Here is a quick decision framework for when you face a new system design problem. Start by listing your non-functional requirements: expected traffic volume, latency targets, consistency requirements, budget for infrastructure costs, and team familiarity with the technology. Then work through the patterns in this article and ask whether each one moves you closer to or further from your requirements.

If latency is your primary concern, start with caching and a CDN — they give the biggest improvement for the least complexity. If availability is critical, use load balancing with health checks, design your services to be stateless, and choose AP over CP where the business allows it. If you are dealing with write-heavy workloads, evaluate sharding and message queues early — they are much easier to introduce before you have millions of rows of data. If you are dealing with a public API that third-party developers will consume, implement rate limiting on day one. Adding it later means versioning your API or breaking existing clients.

The most important skill in system design is knowing what you do not need. Most applications do not need sharding. Most applications do not need a message queue. Premature distribution is the root of all evil in system design — every distributed system introduces failure modes that a single-server system simply does not have. Add complexity only when the metrics tell you to, not because the pattern sounds impressive in a job interview.

Start simple. Measure everything. Add one pattern at a time. Verify the improvement before moving on. The systems that survive are not the ones with the most sophisticated architecture — they are the ones that are easy to understand, easy to operate, and easy to change when the next bottleneck appears.