System Design Case Studies 2

From One User to Millions: Scaling a Web App on AWS

Ten scaling stages with trigger, change and cost, the metric that tells you, AWS services in plain words, and a queueing arithmetic demo.

Lesson 1120 min read#scaling#aws#capacity-planning

On this page

  1. Words used in this lesson
  2. The rule: change only what a number tells you to
  3. The stages at a glance
  4. Stage by stage
  5. How you know: metrics that decide each move
  6. The capacity demo
  7. Mapping to AWS, in plain words
  8. Failure modes and trade-offs
  9. How to say it in an interview (60 seconds)
  10. Interview questions
  11. Key takeaways
  12. What's next

In one line: a web app does not start as a "scalable system". It starts as one box, and you change it one step at a time, each time a specific number says the last design is running out. This lesson walks the stages (split the database, add a cache, add a load balancer and stateless app servers, read replicas, a CDN, queues and workers, sharding, multi-AZ, multi-region), shows what breaks and which metric tells you, and gives each change a plain cost.

This is lesson 11, the last lesson of System Design Case Studies, Part 2. Earlier lessons went deep on single parts: the key-value store (Lesson 1) on partitioning and replicas, the CDN (Lesson 4) on edge caches, and multi-data-centre systems (Lesson 7) on what changes across regions. This lesson puts the stages in order, like the system-design-primer exercise solutions/system_design/scaling_aws ("Design a system that scales to millions of users on AWS"). For the build-it-yourself AWS steps, I link to the DevOps series at each stage instead of repeating them.

What I ran / checked. I read the primer's scaling_aws page on GitHub (donnemartin/system-design-primer) for its stages, its assumptions (10 million users, 1 billion writes and 100 billion reads a month) and its method (benchmark, profile, fix the bottleneck, repeat). I wrote one TypeScript file, capacity.ts, plus demo.ts, and ran them on Node 22.22.0 with type stripping and no packages. The outputs below are real. They are arithmetic and queueing formulas (Little's law, the M/M/1 and M/M/c queue models, Erlang C), not measurements of a real system, and the model assumes random arrivals and equal jobs, which real traffic does not have. I did not deploy anything on AWS, and I quote no prices: they change, so use the vendor's calculator. AWS service descriptions are from my general knowledge and the DevOps lessons in this series; check the current AWS documentation for limits and features before you rely on them.

Words used in this lesson

  • Vertical scaling means a bigger machine (more CPU, memory, disk). Horizontal scaling means more machines. See System Design Lesson 7.
  • Stateless means a server keeps nothing between requests that another server would need. Then any server can answer any request.
  • Bottleneck is the one resource that runs out first and limits everything else.
  • Utilisation is how busy a resource is, as a fraction (0.8 = 80% busy).
  • Latency is time for one request. Throughput is requests per second. p99 is the time that 99% of requests beat. See System Design Lesson 8.
  • Read replica is a copy of the database that serves reads and follows the primary with a small delay. Replication lag is that delay.
  • Cache hit ratio is the share of reads answered from the cache.
  • Availability Zone (AZ) is one data centre (or group of them) in an AWS region, with separate power and network. A region is a geographic area with several AZs. See System Design Lesson 57.
  • Failover is switching to a standby when the main one fails.
  • Shard is one part of a split database. Each shard holds some of the rows.
  • Little's law: the average number of requests in the system equals the arrival rate times the average time each spends in the system.
  • SLO is a target such as "99% of requests under 300 ms". See System Design Lesson 47.

1. The rule: change only what a number tells you to

The primer's method is four steps, repeated:

  1. Load test (send realistic traffic at a copy of the system).
  2. Profile (find where the time or memory goes).
  3. Fix the bottleneck, and compare options with their trade-offs.
  4. Repeat.

Every stage below follows this. Each stage has a trigger (the number that moved), a change, and a cost (the new thing you must now run, secure, pay for and understand). Skipping stages is a common mistake: a team that starts with sharding and microservices for 200 users has paid every cost and gets none of the benefit. The opposite mistake is also real: waiting until the single database is on fire.

The numbers I use come from the primer: 10 million users, 1 billion writes a month, 100 billion reads a month, 1 KB per write. With 2.5 million seconds in a month that is about 400 writes per second and 40,000 reads per second on average, and 1 TB of new data per month (36 TB in three years). Peaks are higher.

2. The stages at a glance

#StageTrigger (what you see)ChangeCost of the change
0Single boxDay one, under about 100 usersWeb server, app and database on one EC2 instance, an Elastic IP, DNSCheapest and simplest. A reboot or a crash is downtime. No failover
1Split the databaseDatabase memory and CPU grow, disk fills, you cannot scale web and DB separatelyMove the database to RDS, move files to S3, put the DB in a private subnetNetwork hop per query, more security rules, a second thing to back up and patch
2CacheDatabase CPU high on repeated, identical reads. Read p95 climbs with trafficAdd Redis (ElastiCache) for hot reads and sessionsStale data, invalidation bugs, a new failure mode (cold cache stampede)
3Load balancer + stateless app serversCPU of the one app server above about 60 to 70% at peak, or one deploy takes the site downSeveral app servers behind an ALB, sessions moved out of memoryThe LB is a new component; deploys, config and health checks become real work
4Read replicasPrimary database read load is high even with a cache, read-heavy (the primer's 100:1)Add replicas; send reads to them, writes to the primaryReplication lag: users may not see their own write. App must route queries
5CDNBandwidth and app-server load from static files, or users far away see slow loadsCloudFront in front of S3 and the siteCache invalidation, cache-key rules, stale files
6Queues and workersSlow work (email, thumbnails, imports) makes requests slow or time out; spikes overload the databaseSQS queue and a worker group (EC2 or Lambda)Eventual completion, retries, duplicates, dead-letter handling, monitoring of queue depth
7AutoscalingTraffic has a daily shape; you pay for idle servers or fall behind in spikesAuto Scaling groups with min and max, driven by a metricScale-up is not instant; flapping; need for fast boot and good health checks
8Shard or split the write pathPrimary database write rate or size passes what one machine does with headroomShard by a key, or move some data to a NoSQL store, or move old data to a warehouseLarge: cross-shard queries, resharding, hot shards, more operations. Hard to undo
9Multi-AZOne data-centre fault must not cause an outageSpread app servers across AZs; RDS Multi-AZ standbyA small cost in cross-AZ traffic and in running spare capacity
10Multi-regionUsers on several continents, a regional outage must not stop you, or law requires local dataSecond region, global DNS routing, cross-region data copyThe biggest: data consistency across regions, double operations, and a hard disaster-recovery test

A note on order: it is not strict. Multi-AZ (stage 9) is cheap insurance, and many teams do it at stage 3. A CDN (stage 5) is often the first and cheapest thing to add for a content site. Read the "trigger" column, not the stage number. The primer's own order is: single box, split DB and object store, load balancer and replication and CDN, cache and read replicas, autoscaling and automation, then queues, sharding and NoSQL.

3. Stage by stage

Stage 0. One box

One EC2 instance (a virtual server) runs the web server, the app and the database, plus an Elastic IP (a fixed public address) and Route 53 (DNS) pointing the domain at it. Open only ports 80, 443 and (from your own IP only) 22. This is where most products should start. A 2-user app does not need more.

Stage 1. Split the database and the files

Move the database off the box to RDS (managed relational database: AWS does backups, patching, and optional standby). Move user files, images, JS and CSS to S3 (object storage, see System Design Lesson 17). Put the database in a private subnet so it has no public address, and only the app can reach it (DevOps Lesson 6 (VPC)).

  • What breaks if you do not: database and web compete for resources; disk fills with uploads; you cannot size them separately.
  • Metric: database memory and CPU on the same host as the web server; disk used percent; the share of requests that are for static files.
  • Cost: each query now crosses the network (about a millisecond inside a region; measure yours). New security groups and IAM roles must be right (DevOps Lesson 5). RDS is a convenience you pay for compared with running the database yourself, but you also stop doing backups by hand.
  • Learn it: DevOps Lesson 8 (RDS), DevOps Lesson 11 (S3 and CloudFront).

Stage 2. Add a cache

Put Redis (on ElastiCache, AWS's managed Redis and Memcached) next to the app. Cache hot, repeated reads (a profile page, a product, a feed) and move sessions (who is logged in) there, so servers do not keep them in memory. Read the cache first; on a miss, read the database and fill the cache ("cache-aside", System Design Lesson 16). The primer's advice is to first try the database's own cache settings and an index fix before adding a cache.

The size of the win depends on the hit ratio. With 40,000 reads per second and a cache in front (from my demo.ts):

TEXT
hit 0%  -> db reads/s 40000hit 50% -> db reads/s 20000hit 90% -> db reads/s 4000hit 95% -> db reads/s 2000hit 99% -> db reads/s 400

Going from 90% to 99% cuts the database load by 10 times again. That is why cache hit ratio is the first number to watch.

  • What breaks: stale data (the cache says the old name), a stampede (the cache is cold or a hot key expires, and thousands of requests hit the database together), and a cache outage that sends everything to the database.
  • Metrics: hit ratio, evictions, cache latency, and database read QPS.
  • Learn it: DevOps Lesson 16 (Redis), System Design Lesson 15 and Lesson 16.

Stage 3. Load balancer and stateless app servers

Run several app servers behind a load balancer (an ALB, Application Load Balancer, in AWS; see System Design Lesson 12). It spreads requests, checks health, and ends TLS (DevOps Lesson 4). This only works if the app is stateless: no sessions in local memory, no files written to local disk that later requests need. Put sessions in Redis, files in S3. Build the app as an image (DevOps Lesson 9 (Docker)) so every server is the same.

How many servers? Queueing maths gives a surprising answer. A single server with 50 ms of work per request, in the M/M/1 model (random arrivals, one worker):

TEXT
util 0.50 -> 100.0 msutil 0.70 -> 166.7 msutil 0.80 -> 250.0 msutil 0.90 -> 500.0 msutil 0.95 -> 1000.0 msutil 0.99 -> 5000.0 ms

The 50 ms request takes 100 ms at 50% busy and 5 seconds at 99% busy. The formula is response = service time / (1 - utilisation). This is why you add servers before they are full, and why "CPU is 70%" is already a warning.

Now the number of servers. Say the site gets 2,000 requests per second, each needs 50 ms of server time, and we want the mean response at or under 75 ms. The work offered is 2000 x 0.05 = 100 busy servers. The M/M/c model (Erlang C) gives:

TEXT
servers: 102  (offered load 100 busy servers, utilisation 98%)  101 servers -> mean 94.2 ms, P(wait) 88.3%  105 servers -> mean 55.2 ms, P(wait) 51.6%  110 servers -> mean 51.2 ms, P(wait) 23.7%  125 servers -> mean 50.0 ms, P(wait) 1.0%  150 servers -> mean 50.0 ms, P(wait) 0.0%lose 1 of 102: mean 94.2 ms

Two lessons. First, the model says 102 servers meets a mean of 75 ms, but at 98% utilisation almost every request waits, and one failure moves the mean to 94 ms. Means hide the tail (System Design Lesson 8 on p99). A sane plan is about 125 servers (80% utilisation), which leaves room for failures and bursts. Second, the real 50 ms is rarely constant, so real queues are worse than this model. Always load test.

  • What breaks: a deploy or crash on the one server takes the site down; one slow instance drags responses; sticky sessions pile load onto one box.
  • Metrics: app CPU and p95/p99 latency per instance, the LB's healthy-host count, 5xx rate, and requests in flight (Little's law: 1,000 requests per second at 0.2 s each means 200 requests in flight, from my demo, so a server pool needs room for that many concurrent requests).
  • Learn it: DevOps Lesson 12 (ALB and Auto Scaling), DevOps Lesson 10 (Nginx), System Design Lesson 13.

Stage 4. Read replicas

With the primer's 100:1 ratio, reads dominate. Add one or more read replicas of the RDS database. Send writes to the primary and read queries to replicas (the app, or a proxy, decides). Replicas are copies that apply the primary's changes a little later (System Design Lesson 24).

After a 90% cache hit ratio the database sees 4,000 reads per second (from the table above). Spread over replicas:

TEXT
1 replicas -> 4000 reads/s each2 replicas -> 2000 reads/s each4 replicas -> 1000 reads/s each8 replicas -> 500 reads/s each
  • What breaks: replication lag. A user saves a profile, the next page reads from a replica that has not caught up, and the old value shows ("read your own writes" fails). Fixes: read from the primary for a short time after a write, or for the user's own data. Also, replicas do not help writes: every write still goes to the primary.
  • Metrics: replica lag (seconds), primary CPU and write IOPS, per-replica read load, and connection counts.
  • Cost: query routing in the app, lag handling, and more instances to run and pay for.
  • Learn it: DevOps Lesson 8 (RDS), System Design Lesson 28 (consistency models).

Stage 5. CDN

Put CloudFront (AWS's CDN) in front of S3 and the site. Static files (JS, CSS, images, video) are served from caches near the user, and your servers never see most of those requests. Some pages and API answers can be cached for seconds or minutes too. Read Lesson 4 (the CDN case study) for how hit ratio, cache keys and purges work, and System Design Lesson 14 for the basics.

  • What breaks without it: bandwidth and CPU spent on files; far-away users see slow loads.
  • Metrics: CDN hit ratio, origin request rate, bytes served from origin, and time to first byte by region.
  • Cost: stale files after a deploy (use hashed file names), cache-key mistakes (a cookie in the key kills the hit ratio), and purge delays.
  • Learn it: DevOps Lesson 11 (S3 and CloudFront).

Stage 6. Queues and workers

Anything slow or bursty should leave the request path. The app puts a job on a queue (SQS in AWS) and answers at once; workers (EC2 or Lambda) take jobs off the queue. The primer's example: a photo upload goes to the app, the app queues a "make thumbnail" job, and a worker makes the thumbnail, updates the database, and stores it in S3. See System Design Lesson 18 (queues) and Lesson 38 (background jobs, retries, dead-letter queues).

A queue absorbs a burst, then drains. I ran a burst of 3,000 jobs per second for 60 seconds with 10 workers that each do 100 jobs per second:

TEXT
capacity 1000/s, backlog after burst 120000, drain time after burst (no new arrivals) 120 swith 40 workers: capacity 4000/s, backlog 0

With 10 workers the queue grows by 2,000 jobs every second for a minute, which leaves 120,000 jobs waiting, and takes another 120 seconds to clear even if no new work arrives. Whether that is fine depends on the job: a thumbnail two minutes late is fine, a password reset email is not. With 40 workers there is no backlog. The right answer is a decision about how late is acceptable, then enough workers for that.

  • What breaks: the queue grows without limit; a "poison" job fails forever; duplicates (queues deliver at least once, so workers must be idempotent; see Lesson 10 and System Design Lesson 37).
  • Metrics: queue depth, age of the oldest message (the best alarm), worker error rate, and dead-letter queue size.
  • Cost: work is now eventually done, not done on return. You need retries, a dead-letter queue, and a user interface for "processing".

Stage 7. Autoscaling

An Auto Scaling group keeps a number of servers between a minimum and a maximum, and adds or removes servers when a metric moves (CPU, request count per server, or queue depth for workers). The primer's reason: traffic that peaks in business hours, so you stop paying for idle servers at night and keep up with spikes.

  • What breaks: scale-up takes minutes (boot, pull the image, pass health checks), so a sudden spike still hurts; the group can flap up and down if thresholds are too tight; a wrong max can bankrupt you, a wrong min can leave you thin.
  • Metrics: the scaling metric itself, instance launch time, healthy count against desired count, and the cost trend.
  • Cost: you now need fast, repeatable instance builds and good health checks. Do it with infrastructure as code so the setup is not by hand (DevOps Lesson 15 (Terraform)) and ship by pipeline (DevOps Lesson 14 (CI/CD)).
  • Learn it: DevOps Lesson 12, System Design Lesson 58 (deployment strategies).

Stage 8. Shard, split, or move data

When the primary database's writes or size pass what one machine can do with headroom, replicas and caches no longer help (they handle reads). The primer lists the options: federation (split by function: users in one database, orders in another), sharding (split one table's rows by a key), denormalization, SQL tuning, and moving suitable data to NoSQL such as DynamoDB, or old data to a warehouse such as Redshift.

An example with the primer's numbers: 400 writes per second on average is easy, but peaks might be 10 times that. If one shard comfortably takes 1,500 writes per second and I want 50% headroom, then 5,000 writes per second needs:

TEXT
-- 7. shards: 5000 writes/s, one shard takes 1500 writes/s, keep 50% headroom7 shards

The 1,500 figure is my assumption; measure your own database. See System Design Lesson 25 (sharding), Lesson 26 (consistent hashing) and Lesson 1 of this series (key-value store). For per-category "rank" queries after sharding see Lesson 9.

  • What breaks: one hot shard; queries that cross shards (joins, global counts); resharding when you add shards; unique IDs across shards (Lesson 6 (unique ID generator)).
  • Metrics: primary write IOPS and CPU, replication lag during write peaks, table and index size against memory, p99 of the slowest queries, and per-shard load balance.
  • Cost: the largest so far, and the hardest to undo. Before doing it, try the cheap steps: indexes, better queries, archiving old rows, bigger instance, and batching writes.

Stage 9. Multi-AZ

An AZ can fail. Spread the app servers over at least two or three AZs (the ALB and the Auto Scaling group do this), and use RDS Multi-AZ, which keeps a standby copy in another AZ and switches to it if the primary fails. The primer puts multi-AZ in from the load balancer step on.

Stage 10. Multi-region

A second region gives lower latency for far users, survival of a whole-region failure, and a place to keep data that the law says must stay local. It is the biggest jump in complexity. The hard part is data: one region holds the writable copy (simple, but far users pay the latency and failover needs a promotion plan) or both regions accept writes (fast, but conflicts must be resolved). See Lesson 7 (multi-data-centre systems), System Design Lesson 27 (CAP and PACELC) and Lesson 28. Global DNS routing (Route 53 policies) sends users to the nearest healthy region (DevOps Lesson 13).

  • What breaks: stale or conflicting data across regions; a failover that nobody has ever tested; config drift between regions.
  • Metrics: cross-region replication lag, recovery time and data-loss window measured in a drill (RPO and RTO, System Design Lesson 45), and latency by user region.
  • Cost: double the infrastructure and double the operations. Most products never need this. Many get most of the benefit from a CDN (stage 5) plus multi-AZ (stage 9).

4. How you know: metrics that decide each move

MetricHealthyWarning signUsually points to stage
App CPU at peakUnder about 60%Above about 70 to 80%, p99 rising3 (more servers), 7 (autoscaling)
p95 / p99 latencyWithin your SLORising while CPU is flat (waiting on a dependency)2 (cache), 4 (replicas), 6 (queue)
Database CPU / IOPSUnder about 60%High on repeated identical reads2 (cache), 4 (replicas)
Database write IOPS / lagReplica lag near zeroLag grows at write peaks8 (shard or split)
Cache hit ratioAbove 90% for hot pathsFalling, or evictions rising2 (bigger cache, better keys)
CDN hit ratio / origin bytesHigh hit ratioOrigin serving static files5
Queue age of oldest messageSecondsMinutes and growing6 (more workers), 7
Healthy hosts per AZBalancedOne AZ holds most9
Disk usedUnder about 70%Rising towards full1, 8 (archive or split)
Error rate (5xx)Under your SLOSpikes after deploys3 (health checks), System Design Lesson 58

The thresholds are rules of thumb, not laws. Your SLO and your own load tests decide. For setting up the dashboards and alarms, see DevOps Lesson 17 (CloudWatch) and System Design Lessons 46 to 48.

5. The capacity demo

The numbers above come from capacity.ts (about 50 lines, shortened here):

JavaScript
// Little's law: requests in flight = arrival rate x time each spends in the systemexport const inFlight = (reqPerSec: number, secPerReq: number) => reqPerSec * secPerReq;// M/M/1: mean response = service / (1 - utilisation)export const mm1Response = (serviceMs: number, util: number) => (util >= 1 ? Infinity : serviceMs / (1 - util));// Erlang C: chance a request must wait with c servers and offered load a (in "busy servers")export function erlangC(c: number, a: number): number {  if (a >= c) return 1;  let term = 1, sum = 1;  for (let k = 1; k < c; k++) { term *= a / k; sum += term; }  const last = (term * a / c) * (c / (c - a));  return last / (sum + last);}export const mmcWait = (c: number, lambdaPerMs: number, serviceMs: number) => {  const a = lambdaPerMs * serviceMs;  return erlangC(c, a) * serviceMs / (c - a);};export const dbReadsAfterCache = (reads: number, hit: number) => reads * (1 - hit);export const readsPerReplica = (reads: number, replicas: number) => reads / replicas;export const shardsFor = (writes: number, perShard: number, headroom = 0.5) => Math.ceil(writes / (perShard * headroom));

demo.ts also starts from the primer's monthly totals:

TEXT
-- 0. traffic from the primer's numbers (2.5M s per month)reads/s 40000  writes/s 400  ratio 100:1-- 2. Little's law: 1000 req/s x 0.2 s = in flight200 requests at once

Run node demo.ts next to capacity.ts. Both files use only the standard library.

Queueing formulas assume random, independent arrivals and similar job sizes. Real traffic comes in bursts, jobs vary a lot, and a slow dependency adds correlation. The formulas are good for seeing the shape (latency explodes as utilisation nears 100%), and bad for exact capacity. Load test before you buy.

6. Mapping to AWS, in plain words

NeedAWS serviceWhat it is, plainly
A serverEC2A virtual machine you rent and manage
Fixed public addressElastic IPAn address that stays when you replace the machine
Domain namesRoute 53DNS, plus routing by health or location
Private networkVPC, subnets, security groupsYour own network with firewall rules
Relational databaseRDS (and Aurora)A managed database; backups, patching and a standby are handled for you
FilesS3Object storage; cheap and very durable
CDNCloudFrontCaches near users
CacheElastiCacheManaged Redis or Memcached
Spread trafficELB / ALBA load balancer that checks health
Grow and shrinkAuto ScalingKeeps a number of servers based on a metric
QueueSQSA durable queue; consumers pull jobs
Run code without serversLambdaA function that runs when called; you pay per run
NoSQLDynamoDBA managed key-value and document store
WarehouseRedshiftA database built for big analytical queries
Watch itCloudWatchMetrics, logs and alarms
Who can do whatIAMUsers, roles and permissions

The names change and new ones appear, but the roles stay. The point of the primer's exercise is the principles, not the product list.

7. Failure modes and trade-offs

ProblemEffectHandling
Scaling the wrong thingMoney spent, no gainProfile first; change one thing and measure
Cache stampedeDatabase falls over when the cache is coldWarm the cache, add jitter to expiry, coalesce misses
Replica lagUsers see old dataRead your own writes from the primary; show lag in dashboards
Sessions in server memoryUsers logged out when scalingSessions in Redis, or signed cookies
Autoscaling too slowSpike causes errorsPre-warm, lower the threshold, keep a higher minimum, shed load
Queue backlogLate jobsAlarm on oldest-message age, scale workers, drop or defer low-value jobs
Poison messageWorker loops foreverRetry limit and a dead-letter queue
A shard gets hotUneven loadBetter shard key, split the hot key
Untested failoverThe standby does not work when neededRun failover drills
Everything in one AZOne outage takes the site downSpread across AZs, test losing one
Cost surpriseBill grows faster than usersBudgets and alerts, tag resources, review (DevOps Lesson 19)

Trade-offs to say aloud

  • Simplicity vs headroom: every stage adds a part to run. Add a stage when a number says so, not before.
  • Vertical vs horizontal: vertical is quick and capped; horizontal is not capped but needs stateless servers.
  • Cache vs freshness: speed against stale data.
  • Replicas vs consistency: read capacity against lag.
  • Queue vs immediate answer: smooth load against "done later".
  • Managed vs self-run: less work and less control. Managed services cost more per unit and save you people time.
  • Multi-region vs everything else: do it last, and only if you must.

8. How to say it in an interview (60 seconds)

"I would start with one box and say why: it is cheap and fast to build. Then I scale by the bottleneck, and name the metric for each step. When the database competes with the web server for memory I move it to RDS and move files to S3. When identical reads dominate database CPU I add Redis; at 40,000 reads a second, a 90% hit ratio leaves the database 4,000. When one app server gets past about 70% CPU I add a load balancer and make the app stateless, with sessions in Redis. Queueing maths says latency explodes near full utilisation: a 50 ms request takes 500 ms at 90% busy, so I keep servers about 80% busy. Then read replicas for the 100-to-1 read ratio, accepting replica lag; a CDN for static files; SQS and workers for slow jobs, alarming on the age of the oldest message; autoscaling for the daily curve. Only when write rate or size passes one machine do I shard, and I try indexes and archiving first. Multi-AZ comes early for availability; multi-region last, because data consistency across regions is the hard part. At every step I load test, profile, and write down the cost I just took on."

Interview questions

How would you scale a web app from 1 user to 10 million? (Must know)

I start with one box and say that is correct for day one. Then I follow the bottleneck: split the database and files, add a cache, add a load balancer with stateless servers, add read replicas, a CDN, queues for slow work, autoscaling, and only then sharding. For each step I name the metric that triggers it and the cost it adds. I also say I load test and profile before each change, as the primer does.

What does "stateless" mean and why does it matter? (Must know)

A stateless server keeps nothing that a later request, possibly on another server, needs. Sessions go to Redis or a signed cookie, uploaded files go to S3. Then the load balancer can send any request to any server, I can add or remove servers freely, and a crash loses nothing. Without it, scaling out causes logouts and missing files.

Why not keep buying a bigger server? (Must know)

It is the right first move, because it needs no code change. But it has a ceiling, the price rises faster than the capacity at the top, and it leaves one point of failure. Vertical scaling also does not separate the database and the web tier, so they still fight for resources. I use it while it is cheap and move to horizontal scaling when one of these three limits bites.

A cache has a 90% hit ratio. Is that good? (Senior signal)

It depends on what is behind it. With 40,000 reads per second, 90% leaves 4,000 reads for the database and 99% leaves 400, a tenfold difference. So the last few points matter a lot. I would check which keys miss (one-off keys, bad cache keys, too-short expiry), the eviction rate, and what a miss costs. I would also protect the database from a cold cache or a hot key expiring all at once.

Why do response times jump as servers get busy? (Senior signal)

Because requests queue. For a simple one-server model the mean response is service time divided by (1 - utilisation). In my run a 50 ms request took 100 ms at 50% busy, 500 ms at 90% and 5,000 ms at 99%. So I plan for about 70 to 80% utilisation at peak, add servers before they fill, and watch p99, not the mean. The model is idealised, so I confirm it with a load test.

Read replicas helped reads but users see old data after saving. Why, and what do you do? (How would you debug)

Replication lag: the write went to the primary and the next read hit a replica that had not applied it yet. I would confirm by reading the lag metric and the timestamps. Fixes: after a write, read that user's data from the primary for a short window, or pin the session to the primary, or return the new value from the write response. Replicas never help write throughput, so if writes are the problem I need another stage.

When do you add a queue? (Must know)

When work is slow, bursty or not needed before the answer: emails, thumbnails, imports, webhooks. The request writes a job and returns, and workers process it. A queue turns a spike into a backlog that drains. In my example 10 workers made a 120,000-job backlog that took 120 seconds to clear, while 40 workers had none. The costs are retries, duplicates (so workers must be idempotent), a dead-letter queue and a "processing" state in the UI. I alarm on the age of the oldest message.

When would you shard, and what do you try first? (Senior signal)

Only when write rate or data size passes what one primary can do with headroom, since replicas and caches do not help writes. First I try indexes, query fixes, archiving old rows to a warehouse or S3, a bigger instance, batching writes, and moving suitable data to NoSQL. If I must shard I pick a key that spreads load and keeps common queries on one shard, plan for resharding and cross-shard queries, and use an ID scheme that works across shards. I treat it as one-way.

What is the difference between multi-AZ and multi-region? (Must know)

Multi-AZ spreads one region's servers and database standby over separate data centres, so one fault does not cause an outage. It is cheap, low latency, and the database failover is managed. Multi-region protects against a whole-region failure and gives low latency to far users, but the data must be copied across regions with lag, or written from two places with conflicts. It doubles operations. I add multi-AZ early and multi-region only for a stated need.

What does autoscaling not solve? (Trap)

It does not solve a database bottleneck, because adding app servers can send more load to the same database. It is not instant, so sudden spikes still hurt. It needs fast boots and correct health checks. A bad max can cost a lot. And scaling on CPU can miss problems that show as latency or queue age. So I scale app servers on a request or CPU metric, workers on queue age, and I keep headroom and load shedding for the first minutes.

Which metrics would you put on the first dashboard? (Senior signal)

Latency p50, p95 and p99 and the error rate for each endpoint group; CPU and memory per tier; database CPU, connections, write IOPS and replica lag; cache hit ratio and evictions; queue depth and oldest message age; healthy hosts per AZ; and CDN hit ratio. Each matches a stage's trigger in my table. I alert on SLO burn and on queue age, not on every CPU blip.

How do you do the capacity estimate for this system? (Must know)

I convert monthly numbers to per-second ones (2.5 million seconds a month): 100 billion reads is 40,000 per second and 1 billion writes is 400 per second, a 100:1 ratio. Then I add a peak factor, compute the data per month (1 KB x 1 billion = 1 TB), and use Little's law and the utilisation target to size app servers. I say the assumptions out loud, and I treat peaks and failures as the real sizing input.

Key takeaways

  • Start simple (one box). Add a stage when a metric says the current one is running out: the trigger column is the useful part.
  • Each stage fixes one bottleneck and adds one cost. Write the cost down.
  • Stateless servers (sessions in Redis, files in S3) are what allow a load balancer and autoscaling.
  • A cache fixes repeated reads, and hit ratio is the key number: 90% to 99% cut database reads from 4,000 to 400 per second in my example.
  • Latency rises sharply near full utilisation: 50 ms became 500 ms at 90% busy and 5,000 ms at 99%. Plan for 70 to 80%.
  • Replicas add read capacity and replication lag. They do nothing for writes.
  • Queues smooth bursts. Alarm on the age of the oldest message.
  • Shard last (after indexes, archiving and a bigger instance), and multi-region last of all.
  • These formulas show the shape of the problem. Load test to get the numbers.

What's next

This is the last lesson of Part 2. To go back and fill gaps, the DevOps series shows how to build each AWS stage by hand, and System Design Lesson 59, a 5-step framework for any system design problem turns what you practised here into a repeatable interview method. If you want another pipeline-style case first, re-read Lesson 10 (the personal-finance data pipeline).

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series