This lab puts the whole module together. You'll get a brief with real numbers, and for each step you decide first, then compare with one way of reasoning it through. There's rarely a single right answer, but there are plenty of wrong ones, and they tend to show up at 8pm.
The brief
Marketing has planned Tiffin's biggest promotion yet: 50% off every order this Saturday, from 7pm to 10pm. They expect dinner traffic to roughly triple. It's Tuesday. You have four days.
Here's where Tiffin stands today:
| Fact | Number |
|---|---|
| Normal dinner peak | 3,600 req/s |
| Expected Saturday peak | 10,800 req/s (3x), traffic climbing from 7:00pm, peaking by 8:00pm |
| App servers | 5 servers, 8 cores each, in 2 availability zones |
| Per-server knee (from load testing) | 1,400 req/s |
| Autoscaling | On CPU at 60%, min 2, max 8 servers |
| New server ready in | about 4 minutes |
| Load balancer | Managed, layer 7, least outstanding requests |
| Database | One Postgres primary, 16 cores, at 35% CPU at normal peak, max_connections = 300 |
| Each app server | 8 worker processes, each with a database pool of 10 connections |
The goal: Saturday's dinner goes by with no outage, no lost orders, and p99 latency under 800 ms.
Step 1: How many servers?
Size the fleet
Using the numbers above, how many app servers do you need at 8pm on Saturday? Remember the comfortable utilization and the spare.
Show one answer
Keep each server at about 70% of its knee: 1,400 x 0.7 = 980 req/s per server.
10,800 / 980 ≈ 11 -> 11 servers for the load
+ 1 spare (N+1) -> 12 servers
Spread across 2 zones, that's 6 per zone. If a whole zone failed you'd be left with 6, which can't carry 10,800 req/s. Surviving a full zone loss at triple traffic would need 22 servers. For one evening, most teams accept "survive one server failure" and plan to shed load if a zone dies, but say that out loud and get agreement.
Also note the autoscaling maximum is 8. Left alone, it would stop at 8 servers, each carrying 1,350 req/s: 96% of its knee, with no spare. Latency would already be climbing, and one server failure or a slightly bigger crowd would push the whole fleet over the knee. Raise the maximum to at least 14.
The maximum-size setting is the kind of thing nobody remembers until it bites. Autoscaling limits, cloud account quotas on how many machines you can run, and IP address ranges in your network can all quietly cap you below what you need. Check all of them on Tuesday, not Saturday.
Step 2: When do the servers arrive?
Timing
Traffic starts climbing at 7:00pm and triples by 8:00pm. New servers take about 4 minutes to be ready. Is CPU-based autoscaling enough? What would you set up?
Show one answer
Not on its own. CPU-based autoscaling reacts after CPU rises, and then waits 4 minutes for new servers. A promotion going live at exactly 7pm, with a push notification to every customer, can produce a jump in seconds, not a smooth curve.
Use a scheduled scale-up: set the minimum to 12 servers at 6:30pm, so they're running, healthy and warm before the promotion starts. Keep CPU-based autoscaling on top, with the maximum raised, to handle anything above the forecast. Set the minimum back down at 10:30pm.
Managed load balancers scale themselves too, but they also take time to grow. For a jump of 3x that's usually fine. If you ever expect something like 10x in a minute, ask your cloud provider about pre-warming the load balancer.
Step 3: Find the state
You can't just add servers until you know they'll behave. The team searches the code and finds four places that keep state. Check each one.
What breaks with 12 servers?
For each item, decide: fine as is, or must change before Saturday? If it must change, where should the state go?
- Sessions are stored in Redis.
- The promotion has a rule: "50% off for the first 20,000 orders." A counter in each server's memory tracks how many have been used.
- After an order, the server generates a PDF receipt, saves it to local disk, and emails a download link.
- Each server keeps the delivery fee rules in an in-memory cache for 1 minute.
Show one answer
Fine. Sessions are already in a shared store.
Must change, and urgently. With 12 servers each counting separately, each one would allow 20,000 discounted orders, for 240,000 in total. That's a very expensive bug. Move the counter into the database and claim a slot atomically:
UPDATE promos
SET used = used + 1
WHERE code = 'SAT50' AND used < 20000
RETURNING used;
If no row comes back, the promotion is used up. The database handles concurrent updates safely. (Module 12 covers counting across machines in more depth.)
Must change. The download link only works if the request lands on the server that made the PDF, roughly 1 in 12 times. Save receipts to object storage and email a link to that, or generate the PDF in a background job (Module 7) that writes to object storage.
Fine, if you know the cost. Each server refreshes its own copy, so 12 servers mean 12 small queries a minute instead of one, which is nothing. A fee change may take up to a minute to reach every server, but checkout calculates the fee on the server, so nobody is charged a fee that isn't in force. This is the kind of data an in-process cache suits (Module 3).
Here's one discounted checkout on Saturday, now that the counter lives in the database. It doesn't matter which server the load balancer picks:
%% caption: Any of the 12 servers can take the order, because the promo counter is claimed in the shared database.
sequenceDiagram
autonumber
participant C as Customer app
participant L as Load balancer
participant S as Any app server
participant D as Database
C->>L: POST /orders with SAT50
L->>S: least outstanding requests
S->>D: claim a slot if used < 20000
alt a row comes back
D-->>S: slot claimed
S-->>C: order placed at 50% off
else no row
D-->>S: nothing returned
S-->>C: the promotion is used up
endStep 4: Look behind the servers
Adding app servers doesn't add database capacity. It moves pressure onto the database. Check it before Saturday does.
Will the database cope?
Work out two numbers for 12 servers at 8pm Saturday: the number of database connections, and the expected database CPU.
Show one answer
Connections:
12 servers x 8 processes x 10 connections = 960 connections
max_connections = 300
New servers will fail to connect once the database hits 300. Worse, it'll happen at the busiest moment, as autoscaling adds servers.
CPU: the database is at 35% at 3,600 req/s. If database work grows with traffic, 10,800 req/s means about 105%. It will fall over.
So the app tier is ready, and the database isn't. This is exactly where Module 5 starts. For this Saturday, a realistic plan is:
- Cut each process's pool from 10 to 2 connections. Even at the new autoscaling maximum of 14 servers, that's 14 x 8 x 2 = 224, safely under 300. Most of those connections sit idle most of the time anyway. Better still, put a connection pooler in front of the database, which the next module covers.
- Take more reads off the database. Menus are already in Redis, so look at the queries with the biggest total time (as in Module 3) and cache or index the top few. Every query removed before Saturday is headroom.
- Scale the database up from 16 to 32 cores on Thursday at 4am, when there are almost no orders. That brings Saturday's estimate down to about 53%. A few minutes of downtime then is much better than an outage on Saturday.
Step 5: Configure the front door
Load balancer settings
Checkout calls a payment provider, which can be slow under the extra load. The current health check runs SELECT 1 on the database. The load balancer retries any failed request once. What would you change?
Show one answer
- Health check: remove the database query. If the database gets slow on Saturday, a database health check would mark all 12 servers unhealthy at once and turn a slowdown into a total outage. The check should only confirm that the server process is up and ready.
- Retries: retry only
GETrequests. Retrying aPOST /ordersthat timed out could place the order twice and charge the customer twice. - Algorithm: keep least outstanding requests. If payments slow down, servers with many checkouts in progress will get fewer new requests, so menus stay fast.
- Timeouts: give the call to the payment provider a firm timeout, so a slow provider can't hold every worker. Module 12 covers timeouts properly.
Step 6: Deploys and rehearsal
The day itself
The promotion's code is ready on Wednesday. How would you release it, and how do you gain confidence before Saturday?
Show one answer
Release: deploy the promotion code on Wednesday behind a feature flag, turned off. Use the normal canary and rolling deploy, so any problem shows up midweek at normal traffic. Turn the flag on for staff accounts on Thursday to test the real flow, then for everyone at 7:00pm Saturday. If something goes wrong, turning the flag off is faster than any rollback.
Deploy freeze: no deploys from Saturday noon until Sunday morning, except to fix an incident.
Rehearse: on Thursday, load test a production-sized copy at 14,000 req/s, which is 30% above the forecast, because forecasts are wrong. Use the realistic traffic mix with more checkouts than usual. During the test, terminate one app server and check that nobody is logged out, no receipts break, and p99 stays under 800 ms.
The final design
%% caption: Tiffin for Saturday. Stateless app servers scaled ahead of time, state in shared stores, and a database that has just enough room for one night.
flowchart TD
CA(["Customer app"]) --> LB["Load balancer<br/>least outstanding, GET-only retries"]:::hot
LB --> fleet
subgraph fleet ["App servers: 12 at dinner, autoscale to 14"]
direction LR
S1["App server"] ~~~ S2["App server"] ~~~ S3["App server"]
end
fleet --> R[("Redis<br/>sessions, menus")]:::store
fleet --> DB[("Database<br/>orders, promo counter, 32 cores")]:::store
fleet --> OS[("Object storage<br/>receipts, photos")]:::storeAnd the plan in one list:
- Raise the autoscaling maximum to 14, check account quotas, and schedule 12 servers from 6:30pm to 10:30pm.
- Move the promo counter into the database with an atomic update. Move receipts to object storage.
- Cut database pool sizes so total connections stay under the limit even at the autoscaling maximum, take the heaviest reads off the database, and scale it up on Thursday morning.
- Health checks test only the server itself. The load balancer retries only safe requests.
- Ship the promotion behind a flag midweek, freeze deploys on Saturday, and rehearse at 130% of forecast with a server killed mid-test.
Notice where the real risk ended up: not in the app servers, which you can now add almost freely, but in the one database every server shares. Making that database scale is the whole of the next module.
Build it on AWS
The plan above is cloud-neutral. On AWS, Tiffin's app servers are tasks in an ECS service on Fargate, behind an Application Load Balancer, with RDS for PostgreSQL behind them (see the first version lab). Most of the plan maps straight across. What's interesting is where AWS's own limits, defaults and missing settings change the checklist. Prices are Mumbai (ap-south-1) on-demand list prices as of late 2026; check the AWS pricing calculator before quoting them.
The architecture
%% caption: Saturday on AWS. Capacity scheduled ahead, the database scaled up on Thursday, state in shared services.
flowchart TD
CA(["Customer app"]) --> ALB["Application Load Balancer<br/>least outstanding requests"]:::hot
SCH["Scheduled scaling<br/>12 tasks from 18:30 IST"] -.-> SVC
ALB --> SVC["ECS service on Fargate<br/>8 vCPU tasks, max 14"]
SVC --> VK[("ElastiCache<br/>sessions, menus")]:::store
SVC --> PG[("RDS for PostgreSQL<br/>32 vCPU for the weekend")]:::store
SVC --> S3[("S3<br/>receipts")]:::store| Generic box | On AWS | The setting that matters |
|---|---|---|
| App servers, autoscaling | ECS service on Fargate, 8 vCPU / 16 GB Graviton tasks | Target tracking on CPU at 60%, maximum raised to 14, plus scheduled actions for Saturday |
| Load balancer | Application Load Balancer | Least outstanding requests; a health check that doesn't touch the database |
| Sessions and menus | ElastiCache | Already shared, so nothing to change |
| Database | RDS for PostgreSQL, Multi-AZ | Scaled from db.m7g.4xlarge to db.m7g.8xlarge on Thursday at 4am |
| Receipts | S3, with a time-limited download link in the email | No files on any task's disk |
Argument 1: the quota you'll hit before the traffic
The lab warned that limits, not servers, cap you. On AWS, the first one is Fargate's account quota: by default, 6 on-demand vCPUs per Region. Tiffin's account has been raised since then, or five 8-vCPU tasks wouldn't run at all, but Saturday needs more than it has ever used:
14 tasks x 8 vCPU = 112 vCPUs at the autoscaling maximum
Fargate raises quotas automatically as usage grows, but only gradually. Request the increase on Tuesday, in the Service Quotas console, and check the answer before Thursday's rehearsal. The other Fargate limits are comfortable: a service can start 500 tasks a minute in Mumbai, and Tiffin needs seven.
Then the clock. A scheduled action is just "set the minimum to 12 at this time", and cloud schedules are often written in UTC. 18:30 in India is 13:00 UTC, so set the time zone on the schedule explicitly and check the first action fires when you expect. Also time how long a new task takes to become healthy. Most of it is pulling the container image, so a big image means a slow scale-out. ECS can lazy-load large images, which helps for images over a few hundred megabytes.
The load balancer scales itself, and a 3x rise over an hour is usually fine. For a sharper jump, an ALB can reserve capacity in advance: the minimum reservation is 100 load balancer capacity units, which in Mumbai costs about $0.80 an hour. That's cheap insurance for a four-hour window.
Argument 2: health checks and retries, the AWS way
Two of the lab's settings don't map straight onto AWS, and both are worth knowing.
Retries. The lab's load balancer retried failed requests once. An ALB has no such setting: none of its target group options retries a request against another target. So "retry only GETs" is enforced where retries actually happen, in the app's HTTP clients and the mobile app. The bug to hunt for is a client library with automatic retries switched on for every method, including POST /orders.
Health checks. The lab removed the SELECT 1 from the health check so a slow database couldn't mark every server unhealthy. On ECS that matters even more, but for a different reason than you might expect. If every target fails its health check, the ALB actually fails open and keeps sending traffic to all of them. ECS, however, replaces tasks that fail their load balancer health checks. A database-dependent health check on a slow Saturday therefore makes ECS stop and restart your app tasks at 8pm, the worst possible moment. Keep /healthz about the process only, and give new tasks a health check grace period, which defaults to zero, so a slow-starting task isn't killed before it's ready.
One more default: when a task is removed, the ALB keeps it draining for 300 seconds. At 22:30, when the schedule scales back down, that's fine. For deploys, set it a little above your slowest request (the payment call), not five minutes.
Argument 3: the database plan in RDS terms
Connections. The lab's database refused connections above max_connections = 300. On RDS for PostgreSQL the default comes from the instance's memory, capped at 5,000, and a db.m7g.4xlarge with 64 GiB gets the cap. So on default RDS settings, Saturday's 960 connections wouldn't be refused. Don't relax: each connection is a separate PostgreSQL process with its own memory, and a thousand of them, mostly idle, still eat RAM the database would rather use for caching. Cut the pools to 2 per process as the lab says. RDS Proxy does the pooling as a managed service, at $0.017 per database vCPU-hour in Mumbai, about $200 a month for this database. It's the next module's topic.
Scaling up. Moving from 16 to 32 vCPUs is an instance class change, and on a Multi-AZ database it triggers a failover: typically 60 to 120 seconds of connection errors while the database's DNS name moves to the upgraded standby. That's why it happens on Thursday at 4am, with the app's database driver set to reconnect. Then decide whether to keep it:
db.m7g.4xlarge, Multi-AZ: $3.832/hour
db.m7g.8xlarge, Multi-AZ: $7.664/hour (+$3.832/hour)
Thursday 4am to Monday 4am = 96 hours -> ~$370 for the weekend
Keeping it for good -> ~$2,800 more a month
Scale back down on Monday at 4am, with one more brief failover, unless the weekend's numbers say Friday dinners now need it too.
What it costs
| Line | For the promotion |
|---|---|
| 7 extra Fargate tasks for 4 hours, at $0.2324 an hour each | ~$7 |
| ALB capacity reservation, 100 units for 4 hours (optional) | ~$3 |
| Database at 32 vCPU from Thursday to Monday | ~$370 |
| Thursday's rehearsal: 14 tasks for 2 hours, plus a restored database copy for 3 hours | ~$20 |
| Total | ~$400 |
That's 2 cents per discounted order. At an average order of ₹400, the discounts themselves come to about ₹40 lakh, so the extra infrastructure is a rounding error. The expensive mistakes are the free ones: a quota nobody raised, or a health check that restarts the fleet.
The same design on GCP and Azure
| Role | AWS | Google Cloud | Azure |
|---|---|---|---|
| Containers that autoscale | ECS on Fargate | Cloud Run | Container Apps |
| Load balancer | ALB | Cloud Load Balancing | Application Gateway |
| Managed PostgreSQL, scaled up | RDS | Cloud SQL | Azure Database for PostgreSQL |
The checklist carries over: every platform has account quotas, minimum and maximum instance settings, and its own health check behaviour. Find each one on Tuesday.
The rehearsal that failed
Thursday's load test reaches 9,000 requests a second and then stops climbing. Latency is fine, task CPU is 50%, the database is at 40%, yet the service shows only 9 running tasks out of a desired 14, and its event log keeps reporting tasks that failed to launch. What's wrong, and why is it good news?
Show one answer
ECS wants 14 tasks but can only run 9. With healthy CPU and database numbers, the cluster isn't overloaded, so look for a cap. Nine tasks of 8 vCPUs is 72 vCPUs: the account's Fargate vCPU quota, raised once, months ago, to 72. ECS keeps trying to launch the other five tasks, and each attempt fails with a vCPU limit error.
It's good news because it happened on Thursday. Request the quota increase now (and check the answer), and add the quota to the launch checklist with its current value next to it. On Saturday the same cap would have shown up as rising latency at 8pm, with the service stuck below its target and nobody sure why.
What to remember
- Size the fleet from the per-server knee, a comfortable utilization, and a spare. Then check every limit that could cap it: autoscaling maximums, quotas and network ranges.
- For predictable peaks, scale on a schedule before the traffic arrives. Autoscaling on CPU is for surprises.
- Before adding servers, hunt for state kept on them. A counter in memory can be a very expensive bug.
- More app servers means more database connections and more database load. Check both.
- Health checks test the server, not shared dependencies. Retries are only for safe requests.
- Ship risky features behind flags early, freeze deploys on the big day, and rehearse above the forecast with a failure injected.
- On AWS, check account quotas first: Fargate's default is 6 vCPUs per Region, and a big night may need over 100. Schedule capacity in an explicit time zone.
- Know your platform's defaults: an ALB has no retry setting (retries live in clients), it fails open when every target is unhealthy, and ECS replaces tasks that fail health checks. A database check in
/healthzrestarts your fleet at the worst moment.