System Design for Scale: Patterns That Actually Matter in Production

Santheepkumar

Santheepkumar / July 10, 2024

8 min read––– views

Why Most System Design Advice Misses the Point

System design interviews ask you to design YouTube in 45 minutes. The framing implies that scaling is primarily about clever architecture - sharding, CDNs, message queues placed in the right boxes. Real scaling problems are messier. They are about understanding your actual bottleneck, not the theoretical one. They are about operational complexity. They are about what your team can build, run, and debug at 2am.

This is a guide to the patterns that come up repeatedly in production systems - not the ones that look impressive on a whiteboard.

Know Your Bottleneck Before You Optimize

The first rule of scaling: measure before you architect. Most systems that struggle with scale are not bottlenecked where the engineers think they are.

Common bottlenecks, roughly in order of how often they actually appear:

  1. N+1 database queries - the most common cause of slow APIs. You load a list of 50 users and then make 50 separate queries to get their profile photos. Fix: eager loading or batching.
  2. Missing database indexes - a query that scans 10 million rows instead of using an index is almost always slower than network latency.
  3. Chatty synchronous calls - a single API response that fans out to 8 downstream services, each waiting on the next, stacks latency additively.
  4. Unbounded query results - a database query with no LIMIT that worked fine at 1,000 rows starts timing out at 1 million.
  5. Synchronous work that could be async - sending a confirmation email should not be on the critical path of a checkout request.

Sharding, read replicas, and caching are the right answer for very few of these. Fixing the query is almost always faster, cheaper, and less operationally complex.

Caching: When It Helps and When It Hurts

Caching is the correct answer to a narrow set of problems: data that is expensive to compute, read far more often than it changes, and where staleness is acceptable. Outside of that definition, caching introduces consistency bugs that are hard to reproduce and worse to debug.

Where caching works well:

  • Static or slowly-changing reference data (country lists, feature flags, configuration)
  • Computed aggregates that are expensive to recalculate (a user's total spend, a product's average rating)
  • Rendered HTML or API responses for unauthenticated content

Where caching causes problems:

  • User-specific data where stale reads cause incorrect behavior
  • Financial data where a stale balance could cause double-spend
  • Any place where cache invalidation logic becomes complex enough to have bugs

The cache invalidation problem is real. "Explicit invalidation on write" sounds simple but requires every write path to know about every cache that might hold stale data. Miss one, and you have a hard-to-reproduce bug that only appears under specific timing conditions.

A pattern that avoids many invalidation problems: time-to-live (TTL) caching with short expiry. Accept up to 30 seconds of staleness, and let entries expire naturally. This works for most content and eliminates the invalidation coordination problem entirely.

The Read Replica Pattern

Most production databases are read-heavy. A 10:1 read-to-write ratio is common. The primary database is bottlenecked on writes; reads could be served elsewhere. Read replicas - secondary databases that receive async replication from the primary - let you scale reads independently.

The key constraint: replicas lag. Data written to the primary may not appear on a replica for milliseconds to seconds. This means:

  • Never read from a replica immediately after writing and expect to see the write. (Read-your-writes consistency requires going to the primary or using sticky sessions.)
  • Do not use replicas for operations that must reflect the latest state - payment processing, inventory checks, authentication.
  • Do use replicas for reporting queries, data exports, analytics, and read-heavy features where slight staleness is acceptable.

A clean pattern: annotate your data access layer with read/write intent, and route accordingly. db.readOnly().query(...) goes to a replica. db.query(...) goes to the primary.

Event-Driven Architecture: The Right Reasons

Event-driven systems are powerful and routinely over-applied. The right reasons to introduce a message queue:

Decoupling producers from consumers. If Service A needs to notify five other services when something happens, direct HTTP calls means A knows about all five. An event lets A publish once and the consumers subscribe independently. New consumers can be added without changing A.

Absorbing traffic spikes. A checkout system that sends order confirmation emails can drop the emails onto a queue instead of processing them inline. A spike of 10,000 orders does not overwhelm the email service - it just grows the queue, which drains at a steady rate.

Reliability under partial failure. If the email service is down, emails buffered in a queue will deliver when it recovers. A synchronous call fails permanently.

The cost: eventual consistency and operational complexity. Messages can be processed out of order. They can be delivered more than once (at-least-once delivery is the default for most queues). Your consumers need to handle both. The queue itself is a new system to operate, monitor, and debug.

Before adding a queue, ask: what specific failure mode does this solve? If the answer is vague ("it will be more scalable"), the cost is probably not worth it.

Database Sharding: Last Resort, Not First Move

Sharding - splitting your data across multiple database instances by some partition key - is one of the most expensive architectural changes you can make. It complicates every query that crosses shards, makes transactions across shard boundaries impossible or complex, and adds significant operational overhead.

Exhaust these options first, in roughly this order:

  1. Query optimization and indexing
  2. Read replicas for read scaling
  3. Caching for hot data
  4. Vertical scaling (a bigger database instance is often cheaper than sharding)
  5. Archiving old data to reduce working set size
  6. Table partitioning (native to Postgres, MySQL - splits a table across storage units while keeping a single logical view)

Only when all of these are insufficient does application-level sharding make sense. At that point, the decision of shard key is critical and permanent. A user ID as shard key is common and generally good. A timestamp is usually bad (all writes go to the latest shard). An arbitrary hash distributes evenly but makes range queries impossible.

API Gateways and the BFF Pattern

As frontends multiply - web, iOS, Android, third-party integrations - the problem of "who owns the API shape" becomes acute. A single backend API designed for one client ends up serving all of them poorly: mobile clients want fine-grained endpoints to minimize payload; web clients want aggregated responses to minimize round trips; third-party clients want stable, versioned contracts.

The Backend for Frontend (BFF) pattern addresses this: a thin API layer per client type that translates the client's needs into calls to underlying services. The iOS BFF can return compact payloads optimized for mobile. The web BFF can aggregate and transform data into the exact shape the React app needs. Neither constrains the other.

The cost: another service to maintain per frontend team. This is only worth it when frontend and backend teams are large and move at different speeds. For small teams, a single flexible API with good query parameters is usually simpler.

Observability at Scale

As systems grow, debugging by reading logs on a single server stops working. The patterns that replace it:

Structured logging with correlation IDs. Every request gets a UUID at the edge (or the client). Every log line emitted during that request includes the ID. When something goes wrong, you can retrieve every log line from every service for that single request, across the entire system.

Distributed tracing. A trace records the full call tree for a request - which service called which, how long each step took, where errors occurred. Tools like Jaeger, Zipkin, or Honeycomb make this visible as a timeline. Indispensable for diagnosing latency problems in service meshes.

Alerting on symptoms, not causes. Alert on high error rate and high latency for user-facing endpoints. Do not alert on CPU usage or memory unless you have empirically demonstrated that they cause user-facing symptoms. Noise in alerting leads to alert fatigue and missed real incidents.

Conclusion

Scaling is mostly about understanding the system you have before designing the system you think you need. The patterns above - read replicas, caching with clear TTLs, async via queues, structured observability - solve the problems that actually appear in production. The elaborate distributed architectures on whiteboards mostly solve problems at scale levels that most systems will never reach.

Build simply, measure carefully, and scale the parts that are actually bottlenecked. That is the engineering judgment that separates a system that is pleasant to operate from one that generates incidents.

Subscribe to the newsletter

Get emails from me about web development, tech, and early access to new articles.

- subscribers