The company behind Tiffin already has thousands of riders on scooters, a payments setup and a team that has learned Part 1 the hard way. Now it wants to launch ride-hailing in Bengaluru: people request a car or an auto-rickshaw, a nearby driver accepts, both watch each other on the map, and the fare is charged at the end. Call it Tiffin Go.
You get a blank page. No existing code for this product, no diagram to start from. This lab runs the six steps from the start of this module, and every part of Part 1 shows up somewhere. Decide each step yourself before reading on.
The brief
From the product and business teams:
- One city at launch, Bengaluru. Aim for 300,000 rides a day within the first year.
- Busiest hour: 15% of the day's rides, during the evening commute.
- About 40,000 drivers online at the busiest time. Each driver app sends its location every 4 seconds.
- An average trip lasts 25 minutes. Drivers take about 5 minutes to reach a pickup.
- Payment by card or UPI at the end of the trip, or cash.
Step 1: Requirements
Write the requirements
List the functional requirements, the non-functional ones with numbers, and what's out of scope.
Show one answer
Functional:
- A passenger enters pickup and drop, sees a fare estimate and requests a ride.
- A nearby available driver is offered the ride and can accept or decline.
- Both see each other's position live until pickup. The passenger sees the car during the trip.
- The trip ends, the fare is calculated and charged, and both get a receipt.
- Both can rate each other.
Non-functional:
- A driver is found within 30 seconds for 95% of requests at peak.
- Positions on the map are no more than about 5 seconds old.
- A driver is never assigned two rides at once. A ride is never assigned to two drivers.
- A passenger is never charged twice for one trip.
- A ride request, once accepted by the server, is never lost.
Out of scope for now: shared rides, scheduled rides, surge pricing logic (we'll take a multiplier from a config), driver onboarding and documents.
The two italic lines are the ones that will shape the deep dives. Notice they're about correctness, not speed.
Step 2: Numbers
Estimate the load
Work out: ride requests per second at peak, trips in progress at peak, location writes per second, live connections, and ride data per year.
Show one answer
Ride requests: 15% of 300,000 is 45,000 in the peak hour. 45,000 / 3,600 is about 12.5 rides a second. Fare estimates happen more often than bookings, say 3x, so ~40 estimates a second.
Trips in progress: 12.5 a second x (5 min pickup + 25 min trip = 1,800 s) is about 22,500 rides active at once.
Location writes: 40,000 drivers / 4 s = 10,000 writes a second, all day long at varying levels.
Live connections: 40,000 drivers, plus about 22,500 passengers watching a car. Roughly 60,000 open connections at peak.
Ride data: 300,000 rides a day x ~2 KB per ride with its events and payment = 600 MB a day, about 220 GB a year.
The numbers tell a clear story. Rides themselves are a modest load: 12.5 a second is close to Tiffin's dinner peak. Locations are the big number: 10,000 small writes a second that only matter for a few seconds each. That's the same lesson as Module 9's lab, and it means locations must not go into the main database.
Steps 3 and 4: API and data
The API
POST /fare-estimates { pickup, drop, vehicle_type } -> { estimate_id, fare, expires_at }
POST /rides { estimate_id } Idempotency-Key: ... -> 201 { ride_id, status: "searching" }
GET /rides/{id} -> ride with status and driver
POST /rides/{id}/cancel
POST /driver/location { lat, lng, heading } (or over the WebSocket)
POST /driver/offers/{id}/accept
POST /driver/offers/{id}/decline
POST /rides/{id}/arrived | /start | /complete (driver app)
WebSocket /live server pushes: offer, driver_position, status_change
Three choices worth saying out loud:
- The ride request references an estimate. The passenger agreed to a fare. Re-quoting at request time and surprising them is worse.
- The ride request carries an idempotency key. Phones on mobile networks retry. A retried request must not book two cars. Module 12 covers idempotency keys properly; for now, the server stores the key with the ride and returns the existing ride if it sees the key again.
- Driver location can go over the WebSocket that's already open for offers, which is cheaper than 10,000 separate HTTP requests a second.
The data
Model the data
What lives in Postgres, and what doesn't? Sketch the main tables and the ride's states.
Show one answer
In Postgres (source of truth, needs transactions):
drivers (id, name, vehicle_type, vehicle_number, status, current_ride_id, rating_avg)
rides (id, passenger_id, driver_id, status, pickup, drop, fare_estimate_paise,
fare_final_paise, idempotency_key UNIQUE, requested_at, completed_at)
ride_events (ride_id, status, at) -- the history of each ride
payments (id, ride_id UNIQUE, amount_paise, method, provider_ref, status)
drivers.status is one of offline, available, offered, on_ride.
Not in Postgres: live driver locations. They go in Redis with its geo commands, keyed by vehicle type, overwritten every 4 seconds. Trip route history is batched to object storage for fare disputes and support.
A ride moves through clear states, and only certain moves are allowed:
%% caption: A ride's main states. Every change is a conditional update, so two events can't move it in conflicting directions. flowchart TD S([searching]):::hot --> DA[driver_assigned] --> AR[driver_arrived] --> OT[on_trip] -- "fare charged" --> C([completed]) S -- "no driver, or passenger cancels" --> X([cancelled])
Step 5: High-level design
Draw it
Using Part 1's building blocks, draw the design. Trace every box to a requirement or a number.
Show one answer
See the final design at the end of this lab. The key parts: stateless API servers behind a load balancer; Postgres primary with replicas; Redis for locations and pub/sub; WebSocket servers for live updates; a queue and workers for payments and receipts; rate limits at the edge.
Walk a ride through it:
- The passenger app asks for an estimate. The API server reads pricing config (cached, Module 3) and calculates a fare.
- The passenger requests the ride. The server writes a
searchingride to Postgres and, in the same transaction, a "match this ride" job on the Postgres-backed queue (Module 7). Either both are saved or neither is, which is what "a ride request is never lost" needs. The response returns at once. - A matching worker asks Redis for the nearest drivers within 3 km of the pickup, closest first. Drivers who go on a trip are removed from that geo set, but it can lag by a moment, so the deep dive below makes the final check in Postgres.
- It offers the ride to the best driver through the WebSocket servers. Because drivers are spread over many WebSocket servers, the message goes through Redis pub/sub to whichever server holds that driver's connection (Module 6).
- The driver accepts within 15 seconds, or the offer moves to the next driver.
- During pickup and the trip, each driver location update is published to a channel for that ride, and the passenger's WebSocket server pushes it to the passenger.
- At the end, the driver taps Complete. The final fare is computed and a payment job is queued. Receipts go out from a worker.
Steps 2 to 4 as a request flow:
%% caption: The request returns as soon as the ride and its job are saved. Matching happens behind it. sequenceDiagram autonumber participant P as Passenger app participant A as API server participant PG as Postgres participant M as Matching worker participant R as Redis P->>A: request a ride A->>PG: searching ride + match job A-->>P: searching M->>PG: claim the match job M->>R: nearest within 3 km R-->>M: driver 512 first M->>R: publish offer to 512 Note over R: pushed to her socket
The edge gets rate limits (Module 8): a limit per passenger on ride requests stops a buggy app or a bot from booking 50 cars, and a limit on fare estimates stops scrapers.
Step 6: Deep dives
One driver, one ride
Two matching workers are handling two rides near the same street. Both ask Redis for the nearest available driver. Both get driver 512. Both offer her a ride.
Make assignment safe
How do you make sure driver 512 is offered, and assigned, only one ride at a time?
Show one answer
Make the database the referee with a conditional update. A worker only offers a ride after it wins this:
UPDATE drivers
SET status = 'offered', current_ride_id = $ride_id
WHERE id = $driver_id AND status = 'available';
If it updates one row, this worker owns the offer. If it updates zero rows, someone else got there first, so the worker moves on to the next driver. The database runs both updates one after the other on that row, so exactly one wins.
Acceptance works the same way: UPDATE rides SET status = 'driver_assigned', driver_id = ... WHERE id = ... AND status = 'searching', and the driver moves to on_ride in the same transaction. If the passenger cancelled in the meantime, the update matches zero rows, the driver goes back to available, and the app tells her the ride is gone.
If the driver neither accepts nor declines within 15 seconds, a delayed job sets her back to available with WHERE status = 'offered' AND current_ride_id = $ride_id, so it can't undo a newer offer by mistake.
Notice what we didn't need: a distributed lock service. One row, one conditional update, in the database we already have. Module 12 explains why distributed locks are trickier than they look.
One trip, one charge
The trip completes. A payment worker picks up "charge ride 81 for ₹312". It calls the payment provider, the provider charges the card, and the worker crashes before recording success. The queue delivers the job again.
Make the payment job safe to run twice
How do you stop the second run from charging the passenger again?
Show one answer
Three layers, from Module 7's idempotent jobs:
- One payment row per ride.
payments.ride_idis unique. The worker creates the row inpendingstate first. A second job finds it and doesn't create another. - Pass an idempotency key to the provider. Use the payment row's ID. Payment providers commonly accept a key like this and return the original result if they see it again, instead of charging twice.
- Check before charging. If the payment row is already
succeeded, the job just exits.
A declined card is not worth retrying: mark the payment failed and ask the passenger to pay another way. Retries are for failures that might pass, like a provider timeout. If those keep failing, the job lands in a dead letter queue for someone to look at (Module 7).
Then: what breaks first as it grows
Plan the growth
Tiffin Go expands to five cities and 2 million rides a day. What breaks first, and what's the trigger to act?
Show one answer
Locations first. 2 million rides a day suggests ~250,000 drivers online across cities, so ~60,000 location writes a second. One Redis node gets uncomfortable. Split locations by city across Redis instances: rides never cross cities, so this is natural. Trigger: Redis CPU over 50% at peak.
WebSocket servers scale out by adding more (they hold connections, but the routing goes through pub/sub). Trigger: connections per server near the tested limit.
Postgres at ~80 rides a second peak is still fine for writes. Archive old rides after 12 months and partition by month (Module 9). Shard by city only if the primary's write load becomes the limit.
The final design
%% caption: Tiffin Go at launch. Every part comes from Part 1. The hard parts are correctness: one driver per ride, one charge per trip.
flowchart TD
PA([Passenger app]) --> LB["Load balancer<br/>rate limits"] --> API["API servers<br/>stateless"]:::hot
DR([Driver app]) -- "offers, live positions" --> WS[WebSocket servers]
PA --> WS
API --> PG[("Postgres primary<br/>rides, drivers, payments")]:::store
PG --> RR[("Read replicas<br/>history, receipts")]:::store
API --> Q[[Queue]]
Q --> MW[Matching workers]:::hot
Q --> PW["Payment workers<br/>idempotent"] --> PP[Payment provider]
MW --> RD[("Redis<br/>locations, pub/sub")]
WS --> RDBuild it on AWS
Tiffin Go is a new product, so it gets a fresh start on AWS: the same building blocks Tiffin uses (ECS on Fargate, an Application Load Balancer, PostgreSQL, ElastiCache for Valkey), in Mumbai (ap-south-1) for a Bengaluru launch. The interesting decisions are the ones a blank page forces: where the product lives in the company's AWS estate, and what the driver-location path needs that Tiffin's rider path didn't. Prices are us-east-1 list prices as of late 2026, fine for comparing options; Mumbai's differ, so check the AWS pricing calculator before quoting them.
The architecture
%% caption: Tiffin Go on AWS, in its own account. Locations live in Valkey; rides, drivers and payments in PostgreSQL.
flowchart TD
PA([Passenger app]) --> ALB["Application Load Balancer<br/>AWS WAF attached"]
DR([Driver app]) -- "WebSocket" --> ALB
ALB --> API["API service<br/>ECS on Fargate"]:::hot
ALB --> WS["WebSocket service<br/>ECS on Fargate"]
API --> PG[("RDS for PostgreSQL<br/>Multi-AZ, one read replica")]:::store
WS --> VK[("ElastiCache for Valkey<br/>GEO, pub/sub")]:::hot
MW["Matching and payment workers<br/>ECS on Fargate"] --> PG
MW --> VK| Generic box | AWS service | The setting that matters |
|---|---|---|
| Load balancer with rate limits | ALB with AWS WAF rate-based rules | Per-IP seatbelts at the edge; per-passenger limits stay in the app |
| API and WebSocket servers | Two ECS services on Fargate | Separate services, so an API deploy never drops 60,000 sockets |
| Postgres and the job queue | RDS for PostgreSQL, Multi-AZ, plus a read replica | The queue stays in Postgres, as in Step 5's design |
| Redis | ElastiCache for Valkey, primary and replica in two zones | A sweeper for drivers who vanish (Argument 2) |
| Trip route history | Firehose to S3, batched | Many pings per record, as in the ten-cities lab |
Argument 1: a new product, a new AWS account
The tempting move is to deploy Tiffin Go into Tiffin's existing account: the VPC, the pipelines and the people are already there. Most companies at this point create a separate account under AWS Organizations instead, and the reason is that many AWS limits belong to the account, per Region:
- API Gateway's default throttle (10,000 requests a second in most Regions) is shared by every API in the account.
- Lambda's default concurrency (1,000 per Region) is shared by every function.
- SES and SMS reputation and spending quotas belong to the account.
In one shared account, a Tiffin Go launch-week bug that retries in a loop can eat a quota that Tiffin's checkout depends on. In a separate account, the blast radius stops at Tiffin Go. Its bill is also its own, which the business will ask for on day one.
The cost of separation is plumbing: networking between the accounts (Tiffin Go reuses Tiffin's payment setup, for instance), shared CI/CD, and single sign-on. That's a few days of platform work. It's worth it for a product with its own P&L and its own launch risk.
Argument 2: the drivers who never left
Step 3 keeps driver locations in Valkey with geo commands, one key per vehicle type, overwritten every 4 seconds. On ElastiCache, two details turn that sentence into an incident if you miss them.
Geo members don't expire. A geo set is a sorted set, and you can't set a TTL on one member. A driver whose phone dies at 8pm stays in drivers:auto at their last position, forever. The matcher offers that ghost a ride, waits 15 seconds for an accept that never comes, and the passenger waits longer. Keep a second sorted set of last-seen times (ZADD drivers:auto:seen <now> 512 on every ping), and run a sweeper every few seconds that finds members older than 30 seconds and removes them from both sets. The matcher should also skip any driver whose last-seen time is stale, in case the sweeper falls behind.
One key lives on one shard. All 10,000 location writes a second for Bengaluru's autos and cars land on two or three keys. Cluster mode can't spread a single key across shards, so adding shards won't help. At launch, one node handles it; load-test it to know the ceiling. The plan for five cities is already in the lab: keys per city, which spreads the load naturally. If one city outgrows a node, split by area cell, as the Uber lab does.
Argument 3: does 60,000 connections change the load balancer?
The Uber lab holds a million driver connections, and there the load balancer and its listener type are real lines on the bill. At Tiffin Go's launch size, they aren't:
ALB, active-connection dimension: 60,000 connections / 3,000 per LCU = 20 LCU at peak
averaging ~8 LCU over the day x $0.008 x 730 h ~ $47
+ $0.0225 x 730 h ~ $16
~ $63 a month
Keep the ALB. It already does path routing, holds the WAF, and terminates TLS. Revisit at the five-city stage, when connections are several times larger.
What it costs
At the first-year target (300,000 rides a day in Bengaluru):
| Line | Monthly, roughly |
|---|---|
RDS for PostgreSQL: Multi-AZ db.r7g.large with 300 GB, plus one read replica |
~$630 |
| WebSocket service: 6 Fargate tasks (2 vCPU, 4 GB) | ~$430 |
ElastiCache for Valkey: one cache.r7g.large primary + one replica |
~$256 |
| API service: 3 Fargate tasks (1 vCPU, 2 GB) | ~$108 |
| Matching and payment workers: 2 Fargate tasks (1 vCPU, 2 GB) | ~$72 |
| AWS WAF on the ALB: about 130 million API requests | ~$90 |
| Application Load Balancer | ~$65 |
| Firehose and S3 for trip routes (batched, 30 days) | ~$30 |
| Total | ~$1,700 |
That's about 19 cents per thousand rides. Payment provider fees will be far larger than the whole AWS bill, which is normal for a marketplace at launch.
The same design on GCP and Azure
| Role | AWS | Google Cloud | Azure |
|---|---|---|---|
| Account per product | AWS Organizations accounts | Projects in a resource hierarchy | Subscriptions under management groups |
| Services | ECS on Fargate | Cloud Run | Container Apps |
| Database | RDS for PostgreSQL | Cloud SQL for PostgreSQL | Azure Database for PostgreSQL |
| Locations and pub/sub | ElastiCache for Valkey | Memorystore | Azure Managed Redis |
| Edge limits | AWS WAF | Cloud Armor | Azure WAF |
Each cloud has its own unit of isolation, but the reason to use it is the same: quotas, billing and blast radius.
Launch week, day ten
Tiffin Go launched in its new AWS account and the first week went well. On day ten, ride receipts stop arriving, and the OTP texts for new passengers start failing in the evening. Nothing was deployed that day. What's the most likely cause?
Show one answer
The new account came with new-account limits, and nobody raised them. SES starts every account in the sandbox: 200 emails in 24 hours, one a second, and only to verified addresses. End User Messaging SMS starts with a $1.00 monthly spending quota. During testing and a quiet first week, the team either used Tiffin's main account for some sends or stayed under the limits. On day ten, the real traffic crossed them.
The fix is a launch checklist for every new account: request SES production access with a sending rate sized for the peak, raise the SMS spending quota, and check the other quotas the product depends on. The API limits lab and receipts lab cover both services. A separate account buys a separate blast radius, and also a separate set of starting limits.
What to remember
- Start from a blank page with the same six steps: requirements, numbers, API, data, design, deep dives.
- The numbers find the hot spot. For ride-hailing it's driver locations, which belong in memory, not the main database.
- Mark the requirements that are about correctness. They choose your deep dives.
- A conditional update in the database is often all the locking you need: one row, one winner.
- Payments need layers: a unique payment per ride, an idempotency key for the provider, and a check before charging.
- Part 1's building blocks (stateless servers, caches, replicas, live updates, queues and rate limits) are enough for a large single-city system.
- On AWS, give a new product its own account: many quotas and reputations are per account, so separation limits the blast radius. Then raise the new account's starting limits before launch.
- Geo sets in Valkey don't expire members. Track last-seen times and sweep, or the matcher will offer rides to drivers who left hours ago.