Design a Ride-Sharing Dispatch Service
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | One city's taxi app: riders request, nearby drivers accept | A national ride-hailing app with surge pricing | A global marketplace: many cities, pooled and scheduled rides, safety, regulators |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Volume | 5,000 drivers online at peak: 1,250 pings/s; 5 ride requests/s planned | 2M drivers online at peak: 500,000 pings/s; 10M trips/day; 2,500 requests/s and 12,500 rider searches/s planned | 5M drivers online summed over regional peaks: 1.25M pings/s; 25M trips/day |
| Footprint | 1 region, 3 AZs | 1 region, 3 AZs | 6 regions in 3 pairs; cities are the unit of deployment and failover |
| Targets | First offer out in < 2 s; a driver found in < 10 s for a typical request; never two drivers on one trip; 99.9% | Dispatch P99 < 1 s after the batch closes; zero double-booking of trips or drivers; 99.99% | Per-city SLOs; survive a region loss without stranding trips in progress; 99.99% per region |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Loop Opener: What Is Ride Dispatch?
You Already Know One: a Taxi Dispatcher With a Radio and a Map
Picture an old taxi office. A dispatcher sits in front of a big city map with pins for every cab. The phone rings: "Pick me up at 5th and Main." The dispatcher looks at the map, picks the closest free cab, and calls it on the radio: "Car 12, pickup at 5th and Main, can you take it?" Car 12 says yes, the dispatcher moves its pin to "busy", and tells the caller a cab is coming. Only one car gets the job, even if three drivers shout "mine!" at the same time, because there is only one dispatcher.
A ride dispatch service is that office in software. Drivers' phones report where they are every few seconds. Riders ask for a ride. The service picks a driver, offers the trip to that driver's phone, and the driver accepts or declines.
A few words we'll use all page:
| Word | What it means on this page |
|---|---|
| Ping | One location report from a driver's phone: position, heading, speed and a timestamp. |
| Online / available | Online: the driver's app is on and pinging. Available: online and not on a trip or holding an offer. |
| Offer | A trip proposed to one driver, with a countdown (15 s here). The driver accepts or declines; silence means no. |
| Match | A trip and a driver bound together: the driver accepted and the system recorded it. |
| Cell | A small area of a fixed grid on the map (a geohash cell or an H3 hexagon). We index drivers by cell. |
| ETA | Estimated time of arrival. Here usually the pickup ETA: how long the driver needs to reach the rider by road. |
| Surge | A price multiplier (1.0 = normal) that rises where demand outruns supply. |
What Makes It Hard
- Everything moves. A place in a proximity search sits still for years. A driver moves 30 m between two pings. Our index is rewritten hundreds of thousands of times a second at national scale.
- Exactly one. Two drivers can tap "accept" in the same millisecond; two riders' requests can pick the same driver at the same moment. The answer must be one driver per trip and one trip per driver, every time.
- Nearest isn't best. The nearest car by straight line may be across a river. Giving every rider the nearest car in arrival order can strand the next rider.
- It's a market. When a concert ends, 20,000 people want rides from one block. Prices have to rise there, smoothly, without making prices jump from one street to the next.
The Question the Whole Loop Answers
How do we match each rider to exactly one good driver in seconds, fairly, while everyone is moving?
The answer grows every round:
- Round 1: keep live driver locations in memory by cell, find nearby available drivers, and make the offer and the acceptance conditional writes so a trip can only be taken once.
- Round 2: match in small batches instead of first-come-first-served, rank by road ETA, lock the driver as well as the trip, fence late acceptances, and add surge pricing.
- Round 3: turn the service into a marketplace of city cells across regions, with pooled and scheduled rides, a fairness objective, safety paths and a region failover that doesn't strand anyone mid-trip.
This loop reuses ideas from three earlier loops. The spatial index basics (cells, neighbors, exact distance) are taught in the proximity service loop; road ETAs come from the maps and routing loop; charging the rider and paying the driver belong to the payment processing loop. We link them instead of re-teaching them.
Round 1 · Mid-level · "Dispatch for One City's Taxi Company"
~35 min · SDE II (L5) · 1 region, 3 AZs · 5,000 drivers online at peak · 1,250 pings/s · 5 requests/s planned · first offer < 2 s · 99.9%
R1.1 Establish Design Scope
The interviewer says: "A taxi company in one city wants an app. Riders request a ride; a nearby driver gets it. Design the backend." Before drawing anything, we ask.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How often do drivers send their location? | Every 4 seconds while the app is on. | 15 pings a minute per driver. We must absorb a steady write stream, and a missing driver is noticed within seconds. |
| How big is the fleet? | About 5,000 drivers; at the evening peak nearly all are online, on an average day about 1,000 at a time. | 1,250 pings/s at peak and 250/s averaged over the day (R1.7). |
| How far do we search for a driver? | Up to 5 km from the pickup. | The spatial search needs a radius cap, and a way to widen step by step. |
| One offer at a time, or several drivers at once? | One at a time. | Sequential offers; a declined or ignored offer moves to the next driver. |
| How long does a driver have to accept? | 15 seconds. | We need a timer we can trust, and a rule for an accept that arrives at 15.02 s. |
| Pricing? | Fixed meter prices for now. | No surge this round. |
| Payments? | Out of scope. | Charging and payouts are the payment loop. |
Out of scope for this round: surge pricing, batching several requests together, pooled rides, and scheduled rides.
R1.2 Functional Requirements, Derived Step by Step
| Phrase from the problem | Operation |
|---|---|
| "Drivers' phones report where they are" | ping(driver, lat, lon, heading, speed, ts) every 4 s over a long-lived connection |
| "A rider asks for a ride" | requestTrip(rider, pickup, dropoff) returns a trip ID and its status |
| "A nearby driver gets it" | Find nearby available drivers; offer to the best one; push the offer to that phone |
| "The driver accepts or declines" | accept(trip, driver) / decline(trip, driver); silence for 15 s counts as a decline |
| "Rider sees the car coming" | Trip status changes (matched, arriving, in trip, completed) and the driver's position pushed to the rider |
| "Go online / offline" | setAvailability(driver, online or offline) |
Not yet: surge pricing, batch matching, pooled rides, scheduled rides.
R1.3 Non-Functional Requirements: the Questions
We name each quality in words first; the numbers come in R1.7.
- Time to match. A rider watching a spinner gives up fast. The first offer must leave our servers quickly, so that the common case ("the first driver accepts after a few seconds") ends in a match within about 10 seconds.
- Never two drivers on one trip. If two drivers both think they have the rider, one drives across town for nothing and both lose trust in the app. This is the correctness bar of the round.
- Availability. A taxi company's app being down at rush hour is lost income for every driver.
- Location write volume. Every online driver writes every 4 seconds, whether anything happens or not. Writes outnumber ride requests by more than 200 to one.
R1.4 The API
Drivers and riders keep one WebSocket each: a long-lived, two-way connection over TLS, so the server can push an offer or a status change the instant it happens instead of waiting for the phone to ask. Actions that need a clear answer (request, accept, decline) are ordinary HTTPS calls. See WebSocket, SSE and Long Polling.
Driver location (a WebSocket message, every 4 s)
json{ "type": "loc", "seq": 18231, "lat": 40.748412, "lon": -73.985661, "heading": 182, "speed_mps": 7.4, "ts": "2026-09-28T18:04:12.250Z" }
seq increases by one per ping on the phone, so the server can drop a ping that arrives after a newer one (phones reconnect, and old messages can arrive late). The phone's clock is not trusted for ordering; seq is.
Request a trip
httpPOST /v1/trips HTTP/1.1 Host: api.citytaxi.example Authorization: Bearer <rider token> Idempotency-Key: 6f1c2d0e-7b5a-4d0c-9a51-3c2b8e1f0a77 Content-Type: application/json { "pickup": { "lat": 40.748412, "lon": -73.985661 }, "dropoff": { "lat": 40.761432, "lon": -73.977622 } }
json{ "trip_id": "t_7f3a91", "status": "REQUESTED", "created_at": "2026-09-28T18:04:13.020Z" }
The Idempotency-Key makes a retry safe: the trip is stored under the rider and that key, so a phone that lost the response and sends again gets the same trip back, not a second trip. If the retry finds the trip already stored, the server also makes sure the trip is still being matched (step 1.4's sweeper picks up any trip that isn't); a retry that only says "already have it" and does nothing else could leave a trip that nobody is working on.
The offer (pushed to one driver's WebSocket)
json{ "type": "offer", "trip_id": "t_7f3a91", "pickup": { "lat": 40.748412, "lon": -73.985661 }, "distance_m": 640, "expires_at": "2026-09-28T18:04:28.060Z" }
Accept and decline
httpPOST /v1/trips/t_7f3a91/accept HTTP/1.1 Authorization: Bearer <driver token>
httpPOST /v1/trips/t_7f3a91/decline HTTP/1.1 Authorization: Bearer <driver token>
Status codes
| Code | Meaning |
|---|---|
201 Created / 200 OK | Done. A retried accept that finds the trip already matched to this driver also gets 200, with the same body. |
400 Bad Request | Bad coordinates |
404 Not Found | No such trip |
409 Conflict | The offer is no longer yours: it expired, was declined, or the trip was cancelled |
422 Unprocessable Entity | Pickup outside the service area |
429 Too Many Requests | A client over its rate limit |
Recap
- Pings every 4 s over a WebSocket, ordered by
seq; offers pushed on the same socket. - Trips requested with an idempotency key; accept and decline are HTTPS calls;
409when the offer isn't yours any more. - One offer at a time, 15 s to answer, 5 km search radius.
R1.5 Design Evolution: From a SQL Table to Conditional Offers
Each step is a problem, your turn to think, the answer, and what it costs us.
Step 1.0: The Baseline
A PostgreSQL table drivers(driver_id, lat, lon, status, updated_at) with a spatial index. Every ping runs UPDATE drivers SET lat = ..., lon = ... WHERE driver_id = .... A ride request runs "nearest available driver within 5 km" as a spatial query (PostGIS ST_DWithin, ordered by distance), sets the trip's driver, and sends the offer.
It works for a demo, and at 5,000 drivers it would even keep up for a while. The weaknesses show up as soon as we look at what the table does all day.
Step 1.1: 250 GPS Writes a Second Into SQL, and It Only Grows
The problem: 250 location updates a second on an average day, 1,250 at the evening peak, all into one table with a spatial index. The company plans to triple the fleet. And every nearest-driver query runs against rows that are being rewritten every 4 seconds. What would you do?
Primitive: Geospatial Indexing: Geohash, Quadtree and S2
Step 1.2: Find Available Drivers Near the Pickup
The problem: a rider at (40.748412, −73.985661) requests a trip. We have 5,000 drivers in memory, some in the rider's block, some 20 km away, some on trips. We need the closest few available ones, up to 5 km away. What would you do?
Step 1.3: Two Drivers Accepted the Same Trip
The problem: the offer to driver A timed out at 18:04:28 and the trip moved on to driver B. A's phone was on a slow network and its "accept" reached us at 18:04:29, just as B also tapped "accept". The service read the trip, saw it was open, and wrote "driver = A"; a moment later another request did the same for B. Two cars are heading to one rider. What would you do?
Primitive: Distributed Locks and Leases
Step 1.4: The Driver Never Answered
The problem: we offered the trip to driver A at 18:04:13.060. A's phone is in a pocket. Nothing comes back. The rider is watching a spinner. What would you do?
Step 1.5: The Trip Jumped From Requested to Completed
The problem: a bug in the driver app sent "complete" for a trip that was never started. Another bug let a cancelled trip be accepted. The status column is a free-form string that any service can overwrite.
What would you do?
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | Driver rows in SQL, spatial query per request | Heavy writes; bloat |
| 1.1 | GPS writes into SQL | Live locations in Valkey by H3 cell, 15 s TTL, keep-alive re-add; trips in DynamoDB | Live data lost on a crash (refills in 4 s) |
| 1.2 | Find nearby drivers | H3 resolution 8, k-rings 2 → 4 → 6 → 7, stop only when provably enough | Status must be kept in the index |
| 1.3 | Two drivers accepted | Conditional write on the trip: OFFERED to me and not expired | Losers get 409 |
| 1.4 | Driver never answers | Expiry timestamp + SQS delay message as a timer; sweeper | A queue; up to 16 s per ignored offer |
| 1.5 | Illegal state jumps | Trip state machine as conditional updates | – |
R1.6 Architecture v1
Synthesizing vector architecture diagram...
Pings flow left to right into memory; decisions flow through DynamoDB's conditional writes. The index learns each driver's status from DynamoDB's own change stream, never from a second write by the dispatch service.
Why the index gets status from the change stream. The dispatch service could write the driver's status to DynamoDB and then to Valkey. That's a dual write: a crash between the two leaves the index saying "available" for a driver who is on a trip (or the reverse), with no record that anything was missed. Instead, DynamoDB Streams (an ordered, per-item log of every change to the table, kept 24 hours) feeds a small Lambda function that copies status into the driver's hash as status_hint, with the item's version so an older change never overwrites a newer one. The hint can be a second behind. That's fine, because the hint only filters candidates; the conditional write in DynamoDB is what decides.
The same stream drives the rider's notifications: when a trip item changes to MATCHED, the Lambda pushes "driver on the way" to the rider's gateway connection. A crash can't lose that message, because the stream re-delivers until the function succeeds; the rider's app ignores a duplicate.
The car on the rider's map. Once a trip is MATCHED, the gateway holding the driver's socket forwards that driver's pings to the trip's channel (Valkey sharded pub/sub, SPUBLISH trip:<id>), and the gateway holding the rider's socket, subscribed to that channel, pushes them on. At the evening peak about 4,200 trips are in progress (R1.7), so about 1,050 position updates a second go to riders.
Tables
| Table | Key | Main attributes |
|---|---|---|
trips | trip_id | rider_id, status, pickup, dropoff, offer_driver, offer_expires_at, declined (list of driver IDs), driver_id, searching_since and search_shard (only while REQUESTED; a sparse GSI on them), timestamps, version |
trip_requests | rider_id + idempotency_key | trip_id; written with attribute_not_exists so a retry finds the same trip |
drivers | driver_id | state (OFFLINE, AVAILABLE, ON_TRIP), current_trip, vehicle, version |
A trip item stays under 1 KB. Breadcrumbs (the pings along a trip) aren't stored in it.
Tracing a request that gets matched
Synthesizing vector architecture diagram...
The offer left about 40 ms after the request; the driver took about 6 s to tap. The timer still fires later and finds nothing to do.
Tracing an offer that times out
- 18:10:02.000: trip
t_91c0e2is offered to driverd_3310withoffer_expires_at18:10:17.000; a timer message is queued with a 16 s delay. - The driver doesn't respond.
- 18:10:18.000: the timer message becomes visible; a worker picks it up about 20 ms later.
- The worker runs
SET status = REQUESTED, declined = [d_3310]ifstatus = OFFERED AND offer_driver = d_3310. It succeeds. - It takes the next driver from the stored candidate list (re-checking the index for availability, since 16 seconds have passed), queues a new timer, writes the new offer, and pushes it. The rider has now waited about 16 s longer than in the happy path.
- If
d_3310's accept arrives at 18:10:18.050, its condition (status = OFFERED AND offer_driver = d_3310) fails:409 Conflict, "This ride is no longer available."
R1.7 Numbers
Targets
| Quality | Target | Why this number |
|---|---|---|
| Availability | 99.9% | 0.1% of a 30.4-day month: 43,776 min × 0.001 ≈ 44 minutes. |
| First offer out | P99 < 2 s from the request to the offer written to the driver's socket | The interviewer's "match in under 10 s" leaves room for the driver's few seconds of thinking only if the offer leaves fast. |
| Match | A driver accepts within 10 s for a typical request | Depends on drivers; we measure it, and step 1.4 bounds the damage when they don't answer. |
| Correctness | Never two drivers on one trip | Step 1.3. |
Traffic
| Item | Math | Result |
|---|---|---|
| Pings at peak | 5,000 drivers ÷ 4 s | 1,250/s |
| Pings, daily average | 1,000 drivers online on average ÷ 4 s | 250/s |
| Trips per day | we assume each online driver completes about 2 trips an hour: 1,000 average online × 24 h × 2 | 48,000 |
| Peak trips | 5,000 online × 2 an hour = 10,000 an hour ÷ 3,600 s | 2.8/s |
| Peak requests | we assume 1.25 requests per completed trip (cancellations, no driver found): 2.8 × 1.25 = 3.5; we plan for | 5/s |
| Offers | we assume 1.5 offers per request (some declines and timeouts) | 7.5/s planned peak |
| Trips in progress at peak | 2.8 trips/s × 25 min (5 to pick up, 20 riding) × 60 s (Little's law) | about 4,200 |
| Car positions pushed to riders | 4,200 ÷ 4 s | about 1,050/s |
Memory
| Item | Math | Result |
|---|---|---|
| Driver hash | 7 fields, key and overhead | about 200 B |
| Cell set membership | one resolution | about 60 B |
| Total | 5,000 × 260 B | about 1.3 MB |
The index is tiny. We use a cache cluster for its speed and its failover, not its size.
Storage
| Item | Math | Result |
|---|---|---|
| Trip item | IDs, points, status, timestamps | under 1 KB |
| Per day | 48,000 × 1 KB | 48 MB |
| Per year | 48 MB × 365 | about 17.5 GB |
Latency budget: request to offer on the socket (dependent steps add):
| Step | P99 |
|---|---|
| ALB and WAF | 3 ms |
| Idempotency record (conditional) | 10 ms |
| Put trip (conditional) | 10 ms |
| Index reads: two pipelined round trips (cell sets, then hashes) | 3 ms |
| Distance, filter, rank | 1 ms |
| Queue the timer message | 10 ms |
| Offer write (conditional) | 10 ms |
| Push through the gateway | 5 ms |
| Total | 52 ms, far under 2 s; a widened search (k = 7) adds about 3 ms |
The phone's own network adds 100 ms to a second or more on top; that's why the target leaves room.
Monthly cost (us-east-1 on-demand list prices, 730 hours a month; check the AWS Pricing Calculator before quoting):
| Item | Math | Monthly |
|---|---|---|
| Fargate (ARM) | gateways 3 × (2 vCPU × $0.03238 + 4 GB × $0.00356)/h × 730 h ≈ $173; dispatch 3 × (1 vCPU + 2 GB) ≈ $87 | ≈ $260 |
| Valkey | 2 × cache.r7g.large (primary and replica in two AZs) × $0.1748/h × 730 h | ≈ $255 |
| DynamoDB (on-demand) | about 10 write units per completed trip (idempotency record, create, 1.5 offers, 0.5 expiries, accept, 3 progress writes, 2 driver-state changes) and 5 per unmatched request: (48,000 × 10 + 12,000 × 5) × 30.4 ≈ 16.4M × $0.625/million ≈ $10; reads ≈ $1; storage (17.5 GB after a year) ≈ $4 | ≈ $15 |
| SQS | 90,000 offers/day × 3 requests (send, receive, delete) × 30.4 ≈ 8.2M, minus the 1M free × $0.40/million | ≈ $3 |
| Network Load Balancer | $0.0225/h × 730 h ≈ $16, plus about 1 capacity unit × $0.006/h × 730 h ≈ $4 | ≈ $20 |
| ALB | $16 fixed + about 1 capacity unit × $0.008/h × 730 h ≈ $6 | ≈ $22 |
| AWS WAF | $5 per web ACL + a few rules + about 10M requests × $0.60/million ≈ $6 | ≈ $15 |
| Lambda (stream consumers) | a few million short invocations | ≈ $5 |
| NAT gateway | $0.045/h × 730 h, plus a little data | ≈ $35 |
| Data transfer out, CloudWatch, logs | estimates | ≈ $40 |
| Total | ≈ $670/month |
About 1.46M trips a month for $670: under a twentieth of a cent per trip.
R1.8 Trade-Offs
Geohash vs H3 for driver cells
| Geohash | H3 | |
|---|---|---|
| Cell shape | Rectangles; 8 neighbors, the diagonal ones √2 times further | Hexagons; 6 neighbors at equal distance |
| Rings around a point | A square block of cells (3×3, 5×5); the corners are far from the circle | A k-ring is close to a circle |
| Cell size | Changes with latitude (widths shrink with cos φ) | Roughly equal area worldwide, with some variation |
| Keys | Short strings; a prefix is a parent cell | 64-bit integers; parents and children by function (children cover the parent only approximately) |
| Aggregating supply and demand per area | Uneven cells distort counts | Even cells; the reason ride-hailing and delivery companies like it |
Both would work for one city. We chose H3 because Round 2 counts supply and demand per cell for surge, and equal-distance rings make "widen the search by one ring" and "smooth across neighbors" simple. The geohash mechanics (prefixes, the 3×3 block, cell sizes by latitude) are worked through in the proximity loop.
In-memory index vs a database-backed index
| Valkey cells (chosen) | PostGIS or DynamoDB with a geo library | |
|---|---|---|
| Write cost per ping | One short script in memory | A durable write, index maintenance, replication, backup |
| Durability | None needed: refills from the next ping | Paid for, but wasted on 4-second-old data |
| Query | Ring of cells, then hashes, then exact distance in our code | One spatial query (PostGIS) |
| What breaks first | Nothing at this size | Write amplification and vacuum churn (PostGIS); hot partitions on busy cells (DynamoDB) |
For one city either works; the in-memory index wins because the data is disposable and the writes never stop.
R1.9 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| The Valkey primary fails | A few seconds of write errors; ElastiCache promotes the replica | Replication is asynchronous, so the new primary may miss the last few milliseconds of pings and set changes. Hashes fix themselves on the next ping (4 s); cell memberships through the 30-second keep-alive re-add. If both nodes are lost, the cluster comes back empty, and every driver reappears within one ping; for those 4 seconds, searches find fewer drivers and widen their rings. |
| A driver's app goes offline mid-offer | No accept, no decline | The timer expires the offer at 16 s and moves on (step 1.4). The driver's hash expires after 15 s without pings, so they stop getting offers. When the app reconnects, the gateway asks the dispatch service whether this driver has a live offer and re-sends it if so, since the push may have been lost with the old connection. |
| The push path is slow | Offers reach phones late; drivers see only a few seconds on the countdown | The offer carries expires_at, and the app shows the time actually left. We alarm on push latency (gateway write time minus offer time) and on the timeout rate. A driver who taps at the last second and gets 409 sees "This ride is no longer available", not an error. |
| Two requests pick the same driver | Both offer to driver A within the same second | Not prevented this round: the index shows A as available until A's status changes. A can see two offers and accept both. At 5 requests a second it's rare, but real; Round 2 locks the driver as well as the trip (step 2.3). |
| The dispatch service crashes mid-request | A trip stuck in REQUESTED or OFFERED | OFFERED trips are moved on by their timer; REQUESTED ones are found by the sweeper within 10 s (5 s threshold, 5 s scan). |
| A DynamoDB or SQS error | Requests fail | Retries with backoff and jitter; conditional writes make every retry safe. |
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | Every service in three AZs; Valkey with a replica in a second AZ and self-refilling data; durable timers in SQS; a sweeper for stuck trips REL 10 · REL 11 |
| Performance Efficiency | Live locations in memory, H3 rings widened only when needed, 52 ms from request to offer PERF 1 · PERF 3 |
| Security | Riders and drivers authenticate with tokens; a driver can accept only an offer made to them (the condition checks offer_driver); WAF rate limits on the public API SEC 3 · SEC 5 |
| Cost Optimization | About $670 a month; durable storage only for trips, not for pings COST 6 |
| Operational Excellence | Skipped this round: basic alarms on offer timeout rate, time to first offer and push latency. |
| Sustainability | Skipped this round: a handful of small ARM tasks and two cache nodes. |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Keeps disposable, fast-changing positions in memory and durable trips in a database, and says why.
- Uses a cell index with rings, knows how far a ring reaches, and widens it only when needed.
- Refuses "check, then set", and makes the accept a single conditional write.
- Knows that DynamoDB TTL is not a timer, and builds a durable timer with an expiry check.
- Enforces a trip state machine on the server.
- Names the gap it leaves: a driver can still receive two offers at once.
Follow-up questions
-
"Why not put a TTL on each member of the cell set instead of on the driver's hash?" Answer: Valkey sets have no per-member TTL; only whole keys expire. The hash carries the TTL, readers skip members whose hash is gone or names another cell, and the reader removes those members. The 30-second keep-alive re-add covers the opposite problem, a member missing after a failover.
-
"The rider cancels at the same moment the driver accepts. What happens?" Answer: both are conditional writes on the same item. If the accept lands first, the trip is
MATCHED, and the cancel (allowed fromMATCHED) moves it toCANCELLED, and the driver is told. If the cancel lands first, the trip isCANCELLED, and the accept's condition (status = OFFERED) fails with409. There is no state in which both "succeed" inconsistently. -
"Why 16 seconds on the timer and not 15?" Answer: the offer expires at 15 s by the offer's own timestamp. The extra second gives an accept tapped at 14.9 s on a slow network time to arrive and win the conditional write before the timer moves the trip on. The timer's own condition (
OFFEREDto this driver) keeps it harmless either way.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Every ping into SQL" | Durable writes and index churn for data that's stale in 4 seconds. |
| "Search a 1-ring, it's enough" | A 1-ring guarantees only about 0.5 km around a pickup at a cell's edge; a 5 km search needs k = 7. |
| "Read the trip, then write the driver if it's open" | Two requests read "open" and both write. |
| "DynamoDB TTL expires the offer" | Deletion happens typically within a few days, never on a deadline. |
| "Write the status to the database and then to the cache" | A dual write; a crash between them leaves the index wrong with no record. |
Round 2 · Senior · "1M Drivers, Surge, and No Double-Booking"
~40 min · Senior SDE (L6) · 1 region, 3 AZs · 2M drivers online at peak · 500,000 pings/s · 10M trips/day · 2,500 requests/s and 12,500 searches/s planned · dispatch P99 < 1 s · 99.99%
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We built dispatch for one city's taxi company: 5,000 drivers pinging every 4 seconds, 1,250 pings a second at peak, about 5 ride requests a second. Live positions live in Valkey, not SQL: a hash per driver with a 15-second TTL, and a set per H3 resolution-8 cell, with a keep-alive re-add so a lost set entry heals within 30 seconds. To find drivers we read k-rings around the pickup, 19 cells first and up to 169 for the full 5 km, and stop only when enough drivers lie inside the ring's guaranteed reach. Trips and driver state live in DynamoDB. The accept is one conditional write:
MATCHEDonly if the trip is stillOFFEREDto this driver and not expired, so a second driver gets409. Offers expire by a timestamp in that condition plus an SQS message delayed 16 seconds, never by DynamoDB TTL, and every trip move is a conditional update in a state machine. The index learns driver status from DynamoDB's change stream, never from a dual write. About $670 a month. Open costs: first-come-first-served matching, straight-line ranking, no surge, and a driver can still be offered two trips at once."
Architecture v1, compact
Synthesizing vector architecture diagram...
Round 1 in one picture: positions in memory, decisions in conditional writes, timers in a queue.
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | GPS writes into SQL | Valkey hashes (15 s TTL) and H3 cell sets | Refill after a loss |
| 1.2 | Nearby drivers | k-rings 2 → 4 → 6 → 7, provable stop rule | Status kept in the index |
| 1.3 | Two drivers accepted | Conditional write on the trip | 409 for losers |
| 1.4 | No answer | Expiry timestamp + SQS delay timer + sweeper | Up to 16 s per ignored offer |
| 1.5 | Illegal jumps | State machine as conditional updates | – |
Open costs: greedy matching; straight-line distance; driver can hold two offers; no prices that respond to demand.
R2.1 The Scope Raise
Interviewer: "We're now a national ride-hailing app. At a normal busy hour about a million drivers are online, and on the busiest nights of the year about two million. We do 10 million trips a day. At rush hour some riders wait 15 minutes while cars sit a few streets away. We need prices that rise where demand beats supply, without flickering from one street to the next. Last week a driver got two trips at once and both riders were furious. Rank pickups by real driving time. And dispatch has to feel instant."
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How many drivers, and how often? | 1M online at a busy hour, 2M at the weekly peak, about 500,000 averaged over a day; every 4 s. | 250,000 pings/s busy, 500,000 at peak, 125,000 on average: a stream in front of the index, and storage sized by the average (step 2.6). |
| How many trips, and how peaky? | 10M a day; the busiest minutes of the week run at about 10 times the daily average; events spike on top. | 116 trips/s on average, about 1,160/s at peak; we plan requests at 2,500/s (R2.6). |
| Why do some riders wait 15 minutes? | Each request takes the nearest free car the moment it arrives. | Matching becomes an assignment problem over a short batch (step 2.1). |
| How should prices react? | Rise where demand outruns supply, per neighborhood, but not jump at street corners; the rider must see the price before confirming, and keep it. | Surge per cell, smoothed across neighbors, with a locked quote (step 2.5). |
| How did a driver get two trips? | Two requests offered the same driver at once and they accepted both. | Lock the driver as well as the trip (step 2.3), and fence late accepts (step 2.4). |
| Rank by what? | Pickup ETA by road, not straight-line distance. | Calls to a routing service, with caching (step 2.2). |
| What's "instant"? | Dispatch P99 under 1 second. | A latency budget per batch; we define exactly where it's measured (R2.6). |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Drivers online | 5,000 at peak | 1M busy hour, 2M peak, 500K daily average |
| Pings | 1,250/s peak | 500,000/s peak, 125,000/s average |
| Trips | 48,000/day | 10M/day; 2,500 requests/s planned |
| Rider searches ("cars near me") | a few per second | 12,500/s planned (5 per booking) |
| Matching | Nearest first, one at a time | Batch assignment every 2 s per zone |
| Ranking | Straight line | Road ETA |
| Pricing | Fixed | Surge per H3 cell, smoothed, quote locked |
| Correctness | One driver per trip | One driver per trip and one trip per driver |
| Targets | First offer < 2 s; 99.9% | Dispatch P99 < 1 s after the batch closes; 99.99% (4.4 min/month) |
The "Not yet" list from R1.2 comes back: surge pricing and batch matching are now in scope. Pooled and scheduled rides stay out until Round 3.
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | How it fails at the new scope |
|---|---|
| Greedy, nearest-first matching | Each request grabs the closest car, even when that car was the only one that could reach the next rider in time. Riders who arrive a second later, or live at the edge of a busy area, wait far longer. |
| Straight-line ranking | The nearest car by distance can be 10 minutes away across a river or on the wrong side of a motorway. |
| A trip-level lock only | The trip condition stops two drivers taking one trip, but nothing stops one driver holding two offers, from two requests that both saw them as available. |
| No surge | When demand spikes, requests pile up with no cars, and nothing pulls drivers toward the spike. |
| Gateways writing straight to the index | 500,000 pings a second from 180 gateway tasks straight into Valkey, with no buffer; a slow shard backs up into the phones' connections. |
| No telemetry history | Surge, ETA training and trip disputes all need past pings; at this rate storage must be planned, and sized by the average rate, not the peak. |
The order we fix it in: matching first (2.1), because every later piece plugs into the batch; then ETAs as its cost function (2.2); then the two correctness fixes (2.3, 2.4); then surge (2.5); and last the ingest pipeline that everything reads (2.6).
R2.3 New Requirements and API Additions
A fare quote with the surge multiplier and its expiry
httpPOST /v1/quotes HTTP/1.1 Authorization: Bearer <rider token> Content-Type: application/json { "pickup": { "lat": 40.748412, "lon": -73.985661 }, "dropoff": { "lat": 40.761432, "lon": -73.977622 }, "product": "standard" }
json{ "quote": "q1.eyJmIjoxNjIwLCJtIjoxLjM1LCJ4IjoiMjAyNi0wOS0yOFQxODowNjoxM1oifQ.9vK2...", "fare_cents": 1620, "surge_multiplier": 1.35, "pickup_eta_s": 240, "expires_at": "2026-09-28T18:06:13Z" }
The quote is a signed token: fare, multiplier, pickup and dropoff cells, product and expiry, with an HMAC signature from a key only the pricing service holds. The app can show it but can't change it, and we don't store a row per quote (a rider looks at many prices and books few). It's valid for 120 seconds. POST /v1/trips now carries the quote; the trip service checks the signature and the expiry against its own clock, and stores the fare in the trip. A used_quotes item keyed by the quote ID is written with attribute_not_exists, in the same transaction as the trip, so one quote can create only one trip.
Offers now carry an offer number
json{ "type": "offer", "trip_id": "t_7f3a91", "epoch": 4, "pickup": { "lat": 40.748412, "lon": -73.985661 }, "pickup_eta_s": 210, "fare_estimate_cents": 1180, "expires_at": "2026-09-28T18:04:28.060Z" }
httpPOST /v1/trips/t_7f3a91/accept HTTP/1.1 Authorization: Bearer <driver token> Content-Type: application/json { "epoch": 4 }
The driver status model, stored in the drivers table and shown in the driver app:
| State | Meaning | Gets offers? |
|---|---|---|
OFFLINE | App off or on a break | No |
AVAILABLE | Online, free | Yes |
OFFERED | Holding one offer (trip, epoch, expiry) | No |
ON_TRIP | Matched, arriving or in trip | No |
Offer cascades. A decline or a timeout sends the rider back into the next matching batch for that zone, with more priority each time (step 2.1), and with the drivers who already said no excluded. After 90 seconds without a match, the rider is told there are no cars nearby and offered a wider search.
Recap
- Quotes are signed, expire after 120 s, and are locked into the trip at booking.
- Every offer has an
epoch; the accept must present it. - Drivers are
OFFLINE,AVAILABLE,OFFEREDorON_TRIP, and onlyAVAILABLEdrivers get offers.
R2.4 Design Evolution: From Nearest Car to a Fair Market
Step 2.1: Greedy Matching Strands Some Riders
The problem: three riders request within the same two seconds, in the order R1, R2, R3. Three free drivers are nearby. Pickup ETAs in minutes:
| D1 | D2 | D3 | |
|---|---|---|---|
| R1 | 2 | 3 | 7 |
| R2 | 3 | 9 | 12 |
| R3 | 8 | 5 | 15 |
Nearest-first in arrival order gives R1 → D1 (2), R2 → D2 (9), and R3 is left with D3 (15). What would you do?
Synthesizing vector architecture diagram...
The batch loop replaces Round 1's fixed cascade: a rider whose offer fails simply joins the next batch with a bigger boost.
Step 2.2: The Nearest Car Is 10 Minutes Away Across a River
The problem: a rider on the east bank of a river. The nearest car by straight line is 600 m away on the west bank; the nearest bridge is 3 km downstream, so it's 10 minutes away. A car 1.5 km away on the same bank would arrive in 4. What would you do?
Step 2.3: A Driver Accepted Two Trips at Once
The problem: zone A's matcher and zone B's matcher both saw driver D (parked near the zone border) as available. Each offered D a different trip in the same second. D's app showed both, and D tapped accept on both. Each trip's conditional write succeeded, because each trip was, correctly, OFFERED to D.
What would you do?
Primitives: Distributed Locks and Leases · Two-Phase Commit and Saga Orchestration
Step 2.4: A Late Accept After the Offer Expired
The problem: the trip was offered to D1 as epoch 3 and expired; the next batch offered it to D2 as epoch 4. Then D1's accept, delayed on a train with bad signal, arrives. Separately, an accept handler for D2 read its clock at 14.9 s, paused for 2 seconds in garbage collection, and then sent its write with that old :now.
What would you do?
Drill: The GC pause that corrupted shared storage (why a lease that expires by time doesn't protect the store is the second wrong answer and the late-arrival table above; why optimistic concurrency in the store instead of an external lock manager, and its contention cost, is the paragraph "Why conditional writes in the store")
Step 2.5: Demand Spikes in One Area
The problem: a concert ends. In one resolution-8 cell, 60 people request rides within 2 minutes, and 10 drivers are available there. The six cells around it each have 6 requests and 10 drivers. With one price everywhere, the requests pile up, most riders wait 20 minutes, and nothing tells drivers across town that they're needed. What would you do?
Synthesizing vector architecture diagram...
Caps join at the last step, on every quote, so no snapshot, fallback or restart can drop them.
Step 2.6: 500,000 Location Writes a Second
The problem: 2 million drivers at the weekly peak, 500,000 pings a second, into an index that also serves 12,500 searches and 2,500 requests a second. On New Year's Eve, thousands of drivers and tens of thousands of riders crowd into a few cells around Times Square. Surge needs counts, and trip disputes need the history of pings. What would you do?
Synthesizing vector architecture diagram...
One stream, two readers: the index gets positions within about half a second; the lake and surge get the same pings without touching the index.
Primitive: Message Queues vs Event Streams
Drill: The ride-hailing hotspot on New Year's Eve (why one fixed cell size fails when density varies a thousandfold is point 3, with R1's ring widening for sparse areas; why not GEORADIUS/GEOSEARCH on one key instead of our own cell index is the second wrong answer; the in-process spatial index alternative is in R2.7)
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Greedy strands riders | 2 s batches per zone; assignment (Hungarian, O(n³)); wait-time boost with a leave-unmatched option | Up to 2 s of batching; some riders get a worse pickup |
| 2.2 | Nearest by distance is far by road | Road ETA matrix from the routing service, cached per resolution-9 cell pair | A routing call; a fallback path |
| 2.3 | Driver on two trips | Transaction: driver lock + trip + hold check; stale-offer takeover | 6 write units per offer |
| 2.4 | Late accepts | Epoch fencing on both items; retries re-publish | "Offer expired" at the last second |
| 2.5 | Demand spike | Surge per cell, smoothed in space and time; complete snapshots; caps as a separate layer; locked quotes | A pricing pipeline; policy questions |
| 2.6 | 500K pings/s | Gateways → Kinesis → updaters → Valkey (two complete resolutions); Flink; lake at the average rate | A stream; half a second of lag |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
Pings flow down the left into memory and the lake. Requests flow down the right into zone matchers, which lock drivers and trips in DynamoDB and push offers through the gateways. Everything that follows a decision (status hints, rider notices) comes from DynamoDB's own change streams.
Zone matchers. Each city is cut into matching zones of a few dozen square kilometers. Each zone is owned by one matcher task through a lease item in DynamoDB (renewed every 2 seconds, a fencing epoch on each renewal). If two tasks briefly both think they own a zone (a paused owner, a slow renewal), both may make offers; the driver and trip conditions still allow only one of any conflicting offers, so the lease only needs to be mostly right.
Tracing a batch match in zone nyc-mid-14, batch closing at 18:04:12.000:
Synthesizing vector architecture diagram...
Offers go out in parallel, so the batch pays for one offer's latency, not three. The lost lock on D2 costs R3 one more batch, 2 seconds.
Tracing a double-accept race. Trip t_7f3a91 is offered to D2 at epoch 4. D1 (whose epoch-3 offer expired) and D2 both tap accept within the same 10 ms.
- D1's transaction checks the trip:
offer_driver = D1? No (it's D2);offer_epoch = 3? No (it's 4). Cancelled. D1 gets409, "This ride is no longer available." - D2's transaction checks the trip (
OFFEREDto D2 at epoch 4, not expired) and the driver (OFFERED, tript_7f3a91, epoch 4). Both hold: tripMATCHED, D2ON_TRIP.200. - The trips table's change stream delivers the
MATCHEDchange (epoch 4) to the notifier Lambda, which pushes "driver on the way" to the rider. - The timer for epoch 4 fires at 16 s, finds
MATCHED, and does nothing.
Even if D1's request had arrived first, the result would be the same: its conditions fail on the driver and epoch, not on timing.
Tracing a surge quote
- 18:03:00: Flink closes the 18:01–18:03 window. The concert cell
882a100d63fffffhas D = 60, S = 10; smoothing gives 1.35 (step 2.5). The time smoothing starts from the previous minute's 1.00: 0.3 × 1.35 + 0.7 × 1.00 = 1.105, so this minute publishes 1.11. - 18:03:04: Flink writes the city's snapshot, version 52,918, and flips the pointer.
- 18:04:00: the next window gives 1.35 again; 0.3 × 1.35 + 0.7 × 1.105 = 1.18. Snapshot 52,919 at 18:04:04.
- 18:04:07: a rider in the cell asks for a quote. The pricing service's 10-second cache holds snapshot 52,918 (loaded at 18:03:58), so the multiplier is 1.11; the cap table has no cap for the city. Base fare $12.00 → $13.32. The signed quote expires at 18:06:07.
- The rider books at 18:05:30 (within 120 s): the trip stores $13.32 and 1.11, whatever the snapshot says by then.
Losing an AZ. Gateways, matchers, updaters and routing servers run in three AZs; DynamoDB, Kinesis and SQS are regional. Drivers connected to the lost AZ's gateways reconnect to the others with jittered backoff; their hashes live on for 15 s, and a driver who takes longer simply reappears on their next ping. A reconnecting driver's gateway asks whether they hold a live offer and re-sends it. Valkey promotes replicas in the surviving AZs; the keep-alive re-add heals set entries lost to asynchronous replication within 30 s.
R2.6 Numbers and Cost
Traffic
| Item | Math | Result |
|---|---|---|
| Pings, busy hour | 1M ÷ 4 s | 250,000/s |
| Pings, weekly peak | 2M ÷ 4 s | 500,000/s |
| Pings, daily average | 500,000 ÷ 4 s | 125,000/s |
| Ping bandwidth at peak | 500,000 × about 100 B on the wire (45 B of fields plus WebSocket, TLS and TCP framing) = 50 MB/s | 400 Mbps |
| Trips | 10M ÷ 86,400 s | 115.7/s average |
| Trip peak | we assume the busiest minutes run at 10× the daily average | about 1,157/s |
| Requests | 1.25 per completed trip (as in Round 1): 12.5M/day, 145/s average, about 1,450/s at peak; we plan for | 2,500/s (room for an event on top of the weekly peak) |
| Rider searches | 5 per booking at planned peak: 2,500 × 5 | 12,500/s |
| Offers | 1.5 per request: 2,500 × 1.5 | 3,750/s at planned peak; 18.75M a day |
| Trips in progress | average: 115.7/s × 1,500 s (25 min) ≈ 174,000; busiest hour, we assume 3×: | about 520,000 |
| Car positions to riders | 520,000 ÷ 4 s at the busiest hour; 174,000 ÷ 4 on average | 130,000/s; 43,500/s |
Live state
| Item | Math | Result |
|---|---|---|
| Per driver | hash about 200 B + two set memberships about 60 B each | about 320 B |
| At 1M drivers | 1M × 320 B | 320 MB (200 MB of hashes, the figure usually quoted) |
| At the 2M peak | 2M × 320 B | about 640 MB |
Memory isn't the constraint; commands per second are.
Valkey commands at peak (planning estimates):
| Source | Math | Per second |
|---|---|---|
| Ping scripts | 1 per ping | 500,000 |
| Cell changes | a car at 30 km/h moves about 33 m per ping: it leaves a resolution-8 cell about every 28 pings (0.92 km) and a resolution-9 cell about every 11 (0.35 km); 2 commands each: (1/28 + 1/11) × 2 × 500,000 | 127,000 |
| Keep-alive re-add | 2 commands every 8th ping | 125,000 |
| Status hints | from the change stream | 20,000 |
| Rider searches | 12,500 × about 40 commands (cell sets, hashes), minus about 30% from candidate caches | 350,000 |
| Matchers | 2,500 × 40 | 100,000 |
| Car positions to riders | SPUBLISH per on-trip ping | 130,000 |
| Total | writes on primaries about 900,000; reads on replicas about 450,000 | about 1.35M |
We plan 200,000 commands a second per cache.r7g.xlarge node (4 vCPU, 26 GiB; an assumption to load-test). 8 shards × 3 nodes = 24, 8 per AZ. Writes: 900,000 ÷ 8 primaries = 112,500 each, 56% busy; the replicas carry reads. After losing an AZ, every shard still has a primary (promoted where needed) and at least one replica: primaries stay at 56%, and 450,000 reads on 8 remaining replicas are 28%.
DynamoDB write units per trip (transactional items cost 2 units; the hold check is billed like one)
| Event | Units |
|---|---|
Idempotency record (1) + create trip with its used_quotes item in one transaction (2 + 2) | 5 |
| 1.5 offers × (driver 2 + trip 2 + hold check 2) | 9 |
| 0.5 releases × (trip 1 + driver 1) | 1 |
| Accept (driver 2 + trip 2) | 4 |
| Arriving, started | 2 |
| Completed (trip 2 + driver back to available 2) | 4 |
| Per completed trip | 25 |
| Per unmatched request (5 + 9 + 1.5 releases × 2) | 17 |
Per day: 10M × 25 + 2.5M × 17 = 292.5M write units; a month (30.4 days) is 8.89 billion.
At the planned peak, the trips table takes about 27,000 write units a second (about 24,000 before trip creation became a transaction with the quote check) and drivers about 17,000; both are under the default on-demand limit of 40,000 per table, but not by much, so we raise both quotas and set warm throughput before launch.
Storage
| Item | Math | Result |
|---|---|---|
| Trips | 10M × 1 KB | 10 GB/day, 3.65 TB/year |
| In DynamoDB | the last 90 days: 900 GB; older trips exported to S3 and then removed (TTL deletes them eventually, which is fine for clean-up) | 900 GB |
| Telemetry lake | step 2.6: 125,000 pings/s × 45 B × 86,400 ≈ 486 GB/day raw, about 162 GB/day in Parquet, 30 days | about 4.9 TB |
Latency budget: dispatch, from the batch closing to the offer on the driver's socket (dependent steps add; the three offers of a batch run in parallel, so they count once):
| Step | P99 |
|---|---|
| Rings and hashes from Valkey (two pipelined round trips) | 4 ms |
| ETA matrix (cache, then one routing call) | 40 ms |
| Assignment solve (n up to 200) | 20 ms |
| Queue the timer | 10 ms |
| Offer transaction | 25 ms |
| Push through the gateway | 10 ms |
| Total | 109 ms, under the 1 s target |
The target is measured from the batch closing. What the rider feels is up to 2 s of batch window plus this: about 2.1 s to the first offer at P99, plus the phone networks on both ends. The 1 s target leaves room for a slow routing call or a retried transaction.
Latency budget: rider search ("cars near you"): WAF and ALB 3 ms + index 4 ms + one ring widening 3 ms + ETA for 3 cars 30 ms + serialize 2 ms = 42 ms at P99.
Location freshness, from a ping reaching the gateway to the index: gateway buffer ≤ 100 ms + PutRecords about 30 ms + the reader's wait for its next poll (two readers share a shard's 5 reads a second, so each polls about 2.5 times a second) ≤ 400 ms + script and set writes about 5 ms = ≤ 535 ms. Add up to 4 s since the last ping: a position is at most about 4.5 s old, 63 m for a car at 50 km/h.
Availability. 99.99% of a 30.4-day month is 43,776 min × 0.0001 ≈ 4.4 minutes. We don't claim "five nines": 99.999% allows 5.26 minutes a year, less than one bad deploy or a single regional incident.
Monthly cost (us-east-1 on-demand list prices, 730 hours a month):
| Item | Math | Monthly |
|---|---|---|
| Fargate (ARM, 2 vCPU/4 GB at $57.67 a task-month) | gateways: 25,000 sockets and 5,000 messages/s per task (an assumption); peak 3M sockets (2M drivers, about 1M riders) needs 120 tasks, 180 so two AZs carry the sockets; with an AZ lost, the remaining 120 carry about 5,400 messages/s each, a little above the assumed 5,000, which we accept for the minutes Fargate takes to add tasks; about 45 on average. API, search, trips and matchers about 20 on average; index updaters about 8. 73 × $57.67 | ≈ $4,210 |
| Valkey | 24 × cache.r7g.xlarge × $0.3496/h × 730 h | ≈ $6,125 |
| DynamoDB | 8.89B write units × $0.625/million ≈ $5,557; reads ≈ $500; 900 GB × $0.25 ≈ $225; point-in-time recovery 900 GB × $0.20 ≈ $180 | ≈ $6,460 |
| Kinesis | pings: 40 shards × $0.015/h × 730 h ≈ $438, plus PUT units ≈ $46; demand events 6 shards ≈ $100 with PUTs; DynamoDB change data to Kinesis ≈ $250 | ≈ $835 |
| Flink | about 12 processing units on average × $0.11/h × 730 h ≈ $964, plus application storage | ≈ $1,020 |
| SQS | 18.75M offers/day × 3 requests × 30.4 ≈ 1.71B × $0.40/million | ≈ $685 |
| Routing servers | 6 × c7g.4xlarge × $0.578/h × 730 h | ≈ $2,530 |
| Network Load Balancer | $16 + about 61 NLCU (pings in, positions out: about 17 MB/s = 61 GB/h) × $0.006 × 730 | ≈ $285 |
| ALB | $16 + about 15 LCU × $0.008 × 730 | ≈ $105 |
| AWS WAF | about 1,400 REST requests/s on average ≈ 3.7B a month × $0.60/million, plus the ACL and rules | ≈ $2,230 |
| Data transfer out | about 9 MB/s (REST responses, car positions, offers) ≈ 23.7 TB: 10 TB × $0.09 + 13.7 TB × $0.085 | ≈ $2,065 |
| S3 lake and trip archive | 4.9 TB × $0.023, plus requests | ≈ $150 |
| Cross-AZ traffic | updaters writing to primaries in other AZs: about 2/3 of 12.5 MB/s ≈ 22 TB × $0.02/GB ($0.01 out of the sender plus $0.01 into the receiver) ≈ $440 (reads are AZ-local); gateway-to-service traffic across AZs, an estimate, ≈ $280 | ≈ $720 |
| Lambda stream consumers | reads from DynamoDB Streams by Lambda triggers are free; invocations only | ≈ $200 |
| VPC endpoints, NAT, CloudWatch, logs | estimates | ≈ $2,300 |
| Total | ≈ $29.9K/month |
That's 304 million trips a month for about $29.9K: under a tenth of a cent per trip ($0.098 per thousand). The biggest lines are Valkey, DynamoDB and the gateways, in that order.
R2.7 Trade-Offs
Greedy vs batch vs continuous re-dispatch (figures are rough, for comparison only)
| Greedy, nearest first | Batch assignment (chosen) | Continuous re-dispatch | |
|---|---|---|---|
| Idea | Each request takes the best free driver at once | Pair all requests in a zone every 1–5 s | Keep re-optimizing; may swap a driver's pending pickup for a better one |
| Added delay | None | Up to the window (2 s here) | None up front; changes later |
| Total wait | Worst | Better; how much depends on overlap and density, measured by replay | Best in theory |
| Starvation | Riders at the edge of busy areas wait longest | Wait-time boost decides who gets scarce cars | Needs the same boost |
| Complexity | Trivial | A solver per zone, O(n³) worst case | Much more: reassignment, driver trust ("my pickup was taken away") |
| When to use | Quiet areas, or as the fallback when the solver is slow | Busy areas | Specialist settings (pooling, fleets you control) |
We batch in busy zones and fall back to greedy per zone when a batch has fewer than 2 riders or the solver misses its deadline.
The batch window length
| Window | Rider sees | Pairs to choose from | Our view |
|---|---|---|---|
| 0.5 s | Almost instant | Few overlapping requests: close to greedy | Only in very dense zones |
| 2 s (chosen) | A short spinner | Enough overlap at peak in dense zones | Default |
| 5 s | Noticeable | More, but drivers move 70 m and riders get impatient | Only in very sparse zones, where the extra requests matter most |
Spatial index
| H3 (chosen) | Geohash | S2 | Quadtree in each process | |
|---|---|---|---|---|
| Rings | Equal-distance k-rings | 3×3, 5×5 blocks; corners far | Coverings of mixed-size cells | Tree walk |
| Counting per area (surge) | Even hexagons | Uneven, varies with latitude | Near-square cells | Leaves of varying size |
| Keys in Valkey | 64-bit IDs | Strings | 64-bit IDs | n/a |
| Density | Two fixed resolutions + density map | Precisions + density map | Mixed levels | Adapts by splitting |
Valkey index vs an in-process index per matcher. Each matcher could keep its own city's drivers in memory (an S2 or quadtree library), fed straight from the stream: no network hop, and lookups in microseconds. The costs: every process must consume every ping for its area and rebuild on start, and search tasks and matchers would each need their own copy. At one region with shared search and matching fleets, a shared index is simpler. Round 3's city cells make the in-process option more attractive.
Step Functions vs SQS for offer timers. Step Functions can run the offer cascade as a state machine with a 15-second wait. Standard workflows cost $0.025 per 1,000 state transitions; at about 10 transitions per request and 12.5M requests a day, that's about $95,000 a month. Express workflows are cheaper per request but bill for their whole duration while they wait (up to 5 minutes per execution). An SQS delay message per offer costs about $685 a month, and our conditional writes already hold the state machine. We chose SQS.
R2.8 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| Two drivers accept at the same instant | Two accepts for one trip within milliseconds | Only the one matching the trip's current driver and epoch can succeed; the other gets 409 (R2.5 trace). |
| A phantom acceptance after a timeout | An accept arrives after the timer moved the trip on | Epoch fencing rejects it (step 2.4). We alarm when the late-accept rate exceeds 2% of offers: usually a push-latency problem. |
| Surge whack-a-mole at cell edges | Adjacent cells' multipliers oscillate; drivers circle between them | Neighbor smoothing, the time average and the 0.1-per-minute fall limit; an alarm on oscillation (sign changes of the difference between neighbors per 10 minutes). |
| The surge job stalls | Snapshot age grows | Quotes fall back to 1.0 after 3 minutes; caps still apply; page on-call. |
| A driver disconnects mid-trip (a tunnel) | Pings stop; the hash expires after 15 s | The trip stays IN_TRIP in DynamoDB; nothing times out a trip in progress. The app stores breadcrumbs and uploads them when it reconnects; the fare is computed from them at completion (charging is the payment loop's job). |
| A zone matcher dies | Its zone's requests wait | Another task takes the lease within about 6 s (3 missed 2-second renewals); requests left in memory are found by the sweeper (still REQUESTED) and re-batched. |
| The routing service is down | ETA calls fail | Straight-line distance × a per-zone detour factor learned from past trips; alarm; matches get worse, not stuck. |
| Losing an AZ | A third of the sockets drop | Reconnects with jittered backoff; offers re-sent on reconnect; Valkey replicas promoted; capacity sized so two AZs carry the peak. |
| A hot cell (New Year's Eve) | One cell's keys take most reads | Resolution 9 by density, per-cell candidate caches with single-flight, AZ-local replicas (step 2.6). |
Primitive: Circuit Breaker, Bulkhead and Fault Tolerance
R2.9 Production Gotchas
| Gotcha | Why it hurts | What we do |
|---|---|---|
| Every GPS ping to SQL | Hundreds of thousands of durable writes a second for data that's stale in 4 s | Memory for live positions, a stream for history, Parquet in S3 |
| Broadcasting an offer to 100 drivers ("first to tap wins") | One driver wins; 99 tap for nothing and stop trusting offers; a burst of accepts hits the API at once | One offer per driver, one driver per offer, chosen by the batch |
| Hard-edged surge boundaries | 3× on one side of a street, 1× on the other; riders walk, drivers circle | Smoothing across neighbors and over time; locked quotes |
| DynamoDB TTL as a timer | Expired items are deleted typically within a few days, not at the expiry | Expiry timestamps in conditions + SQS delay messages |
| Clearing a hold with a status write | A suspended driver's next ping or status change marks them available again | Holds in their own table, checked in every offer transaction |
| Surge published as "changed cells" with TTLs | A steady surge is never republished and silently expires | Complete snapshots every minute; stale snapshot → 1.0 and a page |
R2.10 Pillar Check
| Pillar | What Round 2 covers |
|---|---|
| Reliability | Three AZs; Valkey replicas and self-healing memberships; DynamoDB transactions for one-driver-one-trip; durable timers; zone leases with takeover; routing fallback REL 10 · REL 11 · REL 5 |
| Performance Efficiency | Batch assignment per zone; road ETAs with a cell-pair cache; two index resolutions chosen by density; dispatch P99 about 109 ms after the batch closes PERF 1 · PERF 3 |
| Security | Signed quotes the client can't alter; accepts only by the offered driver with the current epoch; holds enforced server-side; WAF on the REST API SEC 3 · SEC 5 |
| Cost Optimization | About $29.9K a month, under a tenth of a cent per trip; SQS instead of Step Functions saves about $94K a month; telemetry sized by the average rate COST 5 · COST 6 |
| Operational Excellence | Alarms on dispatch latency, late-accept rate, failed conditions above 2%, surge snapshot age, location lag and lease takeovers; a per-zone switch to greedy OPS 8 |
| Sustainability | Light this round: Graviton throughout; gateways and API scale with the daily curve SUS 2 |
R2.11 Round 2 Rubric and Follow-Ups
What a strong senior (L6) answer shows
- Replaces greedy matching with a batch assignment, works a small example by hand, knows the Hungarian algorithm's O(n³), and sees that minimizing the total can hurt one rider.
- Explains why a wait-time boost matters only when riders outnumber drivers.
- Locks the driver as well as the trip, in one transaction, before the offer goes out.
- Fences offers with an epoch, and shows that a condition without server time is still safe because status and epoch decide, not the clock.
- Designs surge with spatial and temporal smoothing, locked quotes, complete snapshots and caps as a separate layer.
- Sizes storage by the average rate and memory by the peak, and knows which of the two limits each component.
Follow-up questions
-
"Why not offer each trip to the 3 nearest drivers at once to save time?" Answer: with the driver lock, each of the 3 drivers would be locked for this trip and unavailable to anyone else for up to 15 s, although only one can win: we'd take 3 drivers off the market to fill 1 trip. Without the lock we're back to one driver holding several offers. Sequential offers chosen by the batch, with a short timeout, are the better trade.
-
"A matcher crashes between the offer transaction and the push. What happens?" Answer: the timer message was queued before the transaction, so 16 s later the timer worker finds the offer still
OFFERED, expired and unanswered, releases the driver and the trip, and the rider goes back into the next batch. If the matcher's own retry runs first, it finds its offer already recorded and re-sends the push rather than skipping it. -
"How do you know surge is working as intended?" Answer: we measure outcomes, not multipliers: completion rate and pickup wait in surging cells versus nearby cells, how fast supply arrives after a surge starts, and how often neighboring cells oscillate. And we replay past days through a changed pricing formula before shipping it.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Nearest driver first is optimal" | Only for whoever asks first; the zone as a whole waits longer. |
| "A boost for waiting riders fixes starvation" (in a square batch) | If everyone gets matched, a per-rider constant changes nothing; it works through who gets left unmatched. |
| "The trip lock prevents double-booking" | It stops two drivers per trip, not two trips per driver. |
| "The condition checks the expiry, so the clock keeps us safe" | A paused handler sends a stale :now; the status and epoch are what make it safe. |
| "Delete expired surge by TTL" | A steady surge that isn't republished vanishes. |
| "Size the lake at peak × 30 days" | 27 times too much storage; use the daily average and the stored format. |
Round 3 · Architect · "Global Marketplace: Fairness, Pooling, Safety"
~45 min · Principal (L7) · 6 regions in 3 pairs · 5M drivers online summed over regional peaks · 1.25M pings/s · 25M trips/day · per-city SLOs · 99.99% per region
R3.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 3. If you're starting here, it's everything you need from Rounds 1 and 2.
Round 2 in 60 seconds. "We run national ride-hailing from one region: 2 million drivers at the weekly peak, 500,000 pings a second, 10 million trips a day, planned for 2,500 requests and 12,500 searches a second. Gateways batch pings into Kinesis; updaters write Valkey hashes and H3 cell sets at two complete resolutions, so a missing key always means empty; Flink computes surge and writes a 30-day Parquet lake sized by the average rate, about 4.9 TB. Matching runs in 2-second batches per zone as an assignment problem, Hungarian in O(n³), on road ETAs from the routing engine, with a wait-time boost that decides who gets scarce cars. An offer is a DynamoDB transaction that locks the driver and the trip and checks a separate holds table; every offer has an epoch, and accepts must present it, so late or paused accepts can't win. Timers are SQS delay messages. Surge is per cell, smoothed across neighbors and over time, published as complete snapshots, capped by a separate caps table, and locked into a signed quote. Dispatch P99 is about 109 ms after the batch closes. About $29.9K a month. Open costs: one region serves the country, one rider per car, no scheduling, no fairness measure, and nothing for safety."
Architecture v2, compact
Synthesizing vector architecture diagram...
Round 2 in one picture: positions flow through a stream into memory; decisions are DynamoDB transactions; everything downstream follows DynamoDB's change streams.
Round 2 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Greedy strands riders | 2 s batch assignment per zone, wait boost | Batch delay; some riders pay |
| 2.2 | Straight line misleads | Road ETA matrix, cell-pair cache | A routing dependency |
| 2.3 | Driver on two trips | Transaction: driver + trip + hold check | 6 write units per offer |
| 2.4 | Late accepts | Epoch fencing; retries re-publish | Last-second 409s |
| 2.5 | Demand spikes | Smoothed surge, snapshots, caps layer, locked quotes | Pricing pipeline |
| 2.6 | 500K pings/s | Kinesis → Valkey at two resolutions; lake at average rate | Half a second of lag |
Open costs: one region; one rider per car; no reservations; fairness unmeasured; no safety paths.
R3.1 The Scope Raise
Interviewer: "We're global now: hundreds of cities in dozens of countries, 5 million drivers online across our regions' peaks. Every city has its own rules: airport queues, licensing, which products are allowed. Product wants shared rides and rides booked for tomorrow morning. Drivers are posting that the algorithm is unfair. Riders want to share their trip with family and get help fast in an emergency. And last quarter a regional outage stranded riders mid-trip; that can't happen again."
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How different are cities? | Very. Airports run first-in-first-out driver queues; some cities cap surge or require specific licenses; products differ. | Each city gets its own configuration and its own matching and pricing deployment: a city cell (step 3.1). |
| How much pooling? | We expect about 15% of requests to choose a shared ride. | A new matching problem: inserting a rider into a route in progress, within detour limits (step 3.2). |
| Scheduled rides: what's promised? | A car at the pickup at the booked time, most of the time; about 3% of requests. | Hold the reservation and match ahead of time from forecasts, with a fallback (step 3.3). |
| What do drivers call unfair? | Some wait an hour between trips while others get back-to-back rides; they can't see why. | Fairness becomes a measured objective in the matching cost, with audits (step 3.4). |
| What safety features? | Share-my-trip for riders, an emergency button for both sides, and records we can trust after an incident. | Signed expiring share links, an emergency path that skips normal queues, and trip audit trails kept longer than raw pings (step 3.5). |
| What does "don't strand anyone" mean? | A trip in progress must finish and be billed correctly even if its region goes down; new requests in that city must work again within minutes. | Trips continue on the phones and resync; each city has a standby region with its dispatch state replicated (step 3.6). |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Drivers online | 2M peak, one country | 5M summed over regional peaks |
| Pings | 500,000/s peak | 1.25M/s summed peaks, 312,500/s average |
| Trips | 10M/day | 25M/day; 6,250 requests/s planned over the regions' peaks |
| Deployment | One region | 6 regions in 3 pairs; hundreds of city configs in about 60 city cells |
| Products | One rider per car | Standard, pool, scheduled |
| Fairness | Unmeasured | Measured and part of the objective |
| Safety | None | Share links, emergency path, audit trail |
| Targets | Dispatch P99 < 1 s; 99.99% | Per-city SLOs; 99.99% per region; trips in progress survive a region loss |
R3.2 What Breaks in the Round 2 Design
| Round 2 choice | What breaks at the new scope |
|---|---|
| One matcher fleet and one config for the country | City rules (airport queues, surge caps, licensing) become if city == ... branches in shared code; one bad config or deploy hits every city at once. |
| One rider per car | Pooling needs routes with several stops and a way to check detours; a batch of single pickups can't express it. |
| Matching only when a request arrives | A 7 am reservation matched at 6:59 fails whenever supply is thin at dawn. |
| Minimize total pickup ETA, nothing else | The same drivers keep winning because they sit in the best spots; others wait an hour. Nobody can explain an outcome afterwards. |
| No safety paths | An emergency press would sit behind normal traffic, rate limits and load shedding; raw pings expire after 30 days, perhaps before an incident report arrives. |
| One region | A regional outage stops new trips everywhere and leaves trips in progress with no server. |
R3.3 New Requirements and API Additions
Product types in the request
httpPOST /v1/trips HTTP/1.1 Authorization: Bearer <rider token> Idempotency-Key: 0c9d7e21-43a8-4f5b-b1e2-7d6c5a4b3f21 Content-Type: application/json { "quote": "q1.eyJmIjo5NDAsIm0iOjEuMCwicCI6InBvb2wi...", "product": "pool", "seats": 1, "pickup": { "lat": 51.507351, "lon": -0.127758 }, "dropoff": { "lat": 51.515419, "lon": -0.141099 } }
A scheduled ride adds "pickup_at": "2026-09-29T07:00:00+01:00", and the response has status: RESERVED.
City configuration (one document per city, versioned, loaded by that city's cell):
yamlcity: london home_region: eu-west-1 standby_region: eu-central-1 products: [standard, pool, scheduled] matching: batch_window_s: 2 max_pickup_eta_min: 15 fairness_weight: 0.1 # minutes of ETA per idle minute (step 3.4) surge: max_multiplier: 2.5 pool: max_detour_pct: 40 max_detour_min: 8 max_pickup_wait_min: 8 airport_queues: - name: LHR dispatch: first_in_first_out licensing: required_documents: [private_hire_licence]
Share my trip
httpPOST /v1/trips/t_4be1c2/share HTTP/1.1 Authorization: Bearer <rider token>
json{ "url": "https://share.rides.example/s/st1.eyJ0IjoidF80YmUxYzIiLCJ4IjoxNzkwNjIyMzAwfQ.K3q...", "expires_at": "2026-09-28T19:05:00Z" }
Emergency
httpPOST /v1/trips/t_4be1c2/emergency HTTP/1.1 Authorization: Bearer <rider or driver token> Content-Type: application/json { "lat": 51.511201, "lon": -0.133004, "kind": "rider_safety" }
Returns 202 Accepted with an incident ID. The phone also offers the local emergency number directly; our API never replaces it.
R3.4 Design Evolution: A Marketplace of City Cells
Step 3.1: Hundreds of Cities, Each With Its Own Rules
The problem: London needs an airport queue at Heathrow, a licensing check and a surge cap of 2.5×. São Paulo allows motorbike rides. Mumbai's traffic makes 15-minute pickups normal. A config typo for one city last month broke dispatch for the whole country. What would you do?
Primitive: Circuit Breaker, Bulkhead and Fault Tolerance
Step 3.2: Pooled Rides
The problem: rider A is in a pool car, 10 minutes from their dropoff. Rider B requests a pool ride 3 minutes ahead on roughly the same way. Should B join this car, and in what order? What would you do?
Step 3.3: Scheduled Rides for 7 am
The problem: a rider books a 7:00 am airport pickup the evening before. At 6:55 there are two drivers online in their suburb, both on trips. What would you do?
Step 3.4: Drivers Say It's Unfair
The problem: in the same zone and evening, some drivers get back-to-back trips while others wait over an hour. Drivers can't see why, and neither can we. What would you do?
Step 3.5: Share My Trip, Get Help
The problem: a rider wants a family member to follow the car. A driver presses an emergency button at 2 am while the region is at peak load and the API is shedding requests. An incident report arrives 45 days after a trip, and the raw pings expired at 30. What would you do?
Step 3.6: A Region Is Down Mid-Trip
The problem: eu-west-1 stops answering at 18:00 on a Friday. About 200,000 trips are in progress in London, Paris and Madrid. New requests fail. What would you do?
Primitive: Cloud Disaster Recovery and Multi-Region Active-Active
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | Hundreds of cities, own rules | City cells with config; cell-by-cell deploys; city map; forward to the owner | ~60 deployments |
| 3.2 | Pooled rides | Route insertion within detour and wait limits; route-version condition | More ETA pairs |
| 3.3 | Scheduled rides | Reserve, forecast, offer early to willing drivers, fall back to batches | Forecasting; incentives |
| 3.4 | Unfairness | Fairness metrics; capped idle-time term in the cost; audit and replay | Slightly worse total ETA |
| 3.5 | Safety | Signed share links; an emergency service outside load shedding; locked audit copies | Retention decisions |
| 3.6 | Region down mid-trip | Global tables per pair, one writer per city with owner epochs; trips continue on phones; resync; standby consumers | Partner capacity; seconds of RPO |
R3.5 Global Architecture
Synthesizing vector architecture diagram...
Each region runs its home cities and holds warm replicas of its partner's. Front doors forward by the city map, not by DNS, so a phone that lands in the wrong region still reaches the city's owner.
Tracing a pooled match in London
Synthesizing vector architecture diagram...
The insertion is checked on times from the routing matrix, and committed only if the car's route hasn't changed since the matcher read it.
Tracing a scheduled ride
- 21:14 the evening before: a rider books a 07:00 pickup in a suburb. The trip is
RESERVED, fare locked. - 04:00: the forecast shows 3 available drivers expected in that resolution-7 cell in the 06:45 slot; below the threshold of 5, so the reservation offer shows a £4 early-booking bonus (an illustrative amount; the policy sets it).
- 06:00 (T − 60 min): offered to 2 opted-in drivers forecast nearby. One accepts at 06:04 and becomes
RESERVED_FORthis trip. - 06:30 (T − 30 min): that driver's batch offers are limited to trips ending within 10 minutes' drive of the pickup by 06:55.
- 06:52: the driver is 6 minutes away; the trip moves to
MATCHEDand thenARRIVINGas normal. - Had nobody accepted by 06:40 (T − 20 min), the trip would have entered the normal batches with the maximum boost.
Tracing a regional failover mid-trip: eu-west-1 fails at 18:00:00
Synthesizing vector architecture diagram...
About two minutes from failure to new matches: 30 s of detection, a minute of confirmation, then reconnects. Trips in progress never stopped.
R3.6 Numbers and Cost
Drivers and traffic per region (each region's own peak; the pairs are in overlapping time zones)
| Region | Partner | Drivers online at its peak | Pings/s at peak | Planned requests/s (1,250 per 1M drivers, as in Round 2) |
|---|---|---|---|---|
| us-east-1 | us-west-2 | 1.0M | 250,000 | 1,250 |
| us-west-2 | us-east-1 | 0.5M | 125,000 | 625 |
| eu-west-1 | eu-central-1 | 0.8M | 200,000 | 1,000 |
| eu-central-1 | eu-west-1 | 0.7M | 175,000 | 875 |
| ap-south-1 | ap-southeast-1 | 1.2M | 300,000 | 1,500 |
| ap-southeast-1 | ap-south-1 | 0.8M | 200,000 | 1,000 |
| Sum of peaks | 5.0M | 1.25M | 6,250 |
Daily totals. As in Round 2, a day's average online is a quarter of the peak: 1.25M drivers, 312,500 pings/s. Trips scale with it: 10M × 2.5 = 25M trips a day (289/s average), about 760M a month.
Storage
| Item | Math | Result |
|---|---|---|
| Trips | 25M × 1 KB | 25 GB/day, about 9.1 TB/year |
| Telemetry lake, 30 days | 312,500 × 15 B (Parquet) × 86,400 ≈ 405 GB/day × 30 | about 12.2 TB |
| Matching audit | about 31M requests/day × 2 KB | about 62 GB/day |
Standby sizing, with per-AZ rounding. Each region provisions Valkey and routing for its own peak plus its partner's, since the partner's peak comes at nearly the same hour. Round 2 needed 4 Valkey shards and 3 routing servers per million drivers:
| Region | Own + partner | Valkey shards (4 per 1M, rounded up) | Valkey nodes (× 3, one per AZ) | Routing servers (3 per 1M, rounded up per AZ) |
|---|---|---|---|---|
| us-east-1 | 1.0 + 0.5 = 1.5M | 6 | 18 | 4.5 → 2 per AZ = 6 |
| us-west-2 | 0.5 + 1.0 = 1.5M | 6 | 18 | 6 |
| eu-west-1 | 0.8 + 0.7 = 1.5M | 6 | 18 | 6 |
| eu-central-1 | 0.7 + 0.8 = 1.5M | 6 | 18 | 6 |
| ap-south-1 | 1.2 + 0.8 = 2.0M | 8 | 24 | 6 |
| ap-southeast-1 | 0.8 + 1.2 = 2.0M | 8 | 24 | 6 |
| Total | 40 | 120 | 36 |
Routing load with pooling: Round 2's 31 vCPUs busy per 2M drivers, plus 24% for pool insertions, is about 29 vCPUs busy in a 1.5M region and 39 in a 2.0M region. Six 16-vCPU servers have 96 vCPUs: 30% and 40% busy, 45% and 61% with an AZ lost. No extra servers are needed for pooling.
Monthly cost. We build it from Round 2's measured lines (us-east-1 prices), per million drivers at peak:
| Line | From Round 2 | Per 1M |
|---|---|---|
| Everything that scales with a region's own traffic (Fargate, DynamoDB, Kinesis, Flink, SQS, load balancers, WAF, egress, S3, logs) | $29,922 − $6,125 Valkey − $2,530 routing = $21,267 for 2M | $10,634 |
| Partner replica writes | about 13 item writes per completed trip and 9 per unmatched request (including the used_quotes item), replicated at $0.625/million: (10M × 13 + 2.5M × 9) × 30.4 ≈ 4.64B ≈ $2,897, plus 900 GB of replica storage ≈ $225, for 2M | $1,561 |
| Warm standby Fargate | 20% of Round 2's $4,210 per 2M | $421 |
| Valkey node | cache.r7g.xlarge × $0.3496/h × 730 h | $255 each |
| Routing server | c7g.4xlarge × $0.578/h × 730 h | $422 each |
Replication between the paired regions has no cross-region data transfer charge for global tables; we pay the replicated writes in the standby, and nothing else for moving the data.
Prices outside us-east-1 differ. We assume us-west-2 matches us-east-1, and uplifts of about +10% in eu-west-1 and ap-south-1 and about +20% in eu-central-1 and ap-southeast-1 (assumptions; replace with each region's list prices from the calculator).
| Region | Own traffic | Partner (replicas + warm Fargate at $1,982 per 1M) | Valkey | Routing | Before uplift | Uplift | Monthly |
|---|---|---|---|---|---|---|---|
| us-east-1 | 1.0 × $10,634 | 0.5 × $1,982 = $991 | 18 × $255 = $4,594 | 6 × $422 = $2,532 | $18,751 | 0% | ≈ $18.8K |
| us-west-2 | $5,317 | $1,982 | $4,594 | $2,532 | $14,425 | 0% | ≈ $14.4K |
| eu-west-1 | $8,507 | $1,387 | $4,594 | $2,532 | $17,020 | +10% | ≈ $18.7K |
| eu-central-1 | $7,444 | $1,586 | $4,594 | $2,532 | $16,156 | +20% | ≈ $19.4K |
| ap-south-1 | $12,761 | $1,586 | 24 × $255 = $6,125 | $2,532 | $23,004 | +10% | ≈ $25.3K |
| ap-southeast-1 | $8,507 | $2,378 | $6,125 | $2,532 | $19,542 | +20% | ≈ $23.5K |
| Regions | ≈ $120.0K | ||||||
| Global extras | pool and scheduling services ≈ $1.5K; emergency and share services, locked audit copies ≈ $1.5K; matching audit storage and replay ≈ $1.0K; city map, Route 53 health checks, deploy tooling ≈ $1.0K (estimates) | ≈ $5.0K | |||||
| Total | ≈ $125K/month |
About 760M trips a month for $125K: $0.16 per thousand trips, up from Round 2's $0.098. The difference is the price of surviving a region: every region pays for its partner's peak in Valkey and routing, and for replicated writes.
Per-city SLO example (London): time to first offer P99 < 3 s (2 s window + dispatch); match within 60 s for 97% of standard requests; pickup ETA within ±2 minutes of the quoted ETA for 90% of trips. Each city's targets live in its config; failing one pages that city cell's owners.
R3.7 Trade-Offs
City cells vs one global system
| City cells (chosen) | One global system | |
|---|---|---|
| Blast radius | One cell; deploys and configs roll out cell by cell | Everyone |
| City rules | Config per city; cell code for real differences | Branches in shared code |
| Latency | Matching in the city's home region | Cross-region calls for far cities |
| Operations | About 60 deployments to observe | One |
| Global features (a rider traveling abroad) | The city map forwards to the owner | Built in |
Pooling efficiency vs detours
| Setting | Effect |
|---|---|
| Looser detour limits | More pool matches, fewer cars per rider, cheaper fares; longer and less predictable trips |
| Tighter limits | Faster, more predictable trips; fewer matches, so pool riders often ride alone and the discount costs us |
| Our choice | 40% and 8 minutes per rider, per city config, reviewed against pool completion and complaint rates |
Fairness vs ETA. Every minute of fairness boost can cost a rider up to that minute. We cap the term at 2 minutes and measure, by replay, how much total ETA it costs before raising a city's weight. The trade is explicit, owned and audited, which is the point: before, it was being made silently by where drivers happened to park.
Closing the loop. Round 1 answered "who gets this trip?" with one conditional write. Round 3 answers it with batches, road ETAs, surge, pooling, reservations and fairness terms, across six regions. Underneath, the invariant never changed: a driver reaches ON_TRIP for a trip only through a conditional write that proves they hold that trip's current offer, and they can hold only one. Every feature in this loop plugs into that same write.
R3.8 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| A region outage | Health checks fail; trips in progress lose their server | Trips continue on the phones; cities flip to the standby within about two minutes; resync on reconnect; active trips' state re-sent (step 3.6). |
| A pricing bug (a bad formula version doubles surge) | Surge by cell jumps across a city; quote conversion drops | Caps in surge_caps apply to every quote at once (a city-wide cap row); a kill switch pins the city at 1.0; snapshots are versioned, and the pricing service can pin the last good version. Quotes already locked are honored; any trip charged above its cap is refunded automatically. |
| A pooling detour violation (traffic after the match) | A pool rider's trip runs far over the promised detour | The car's route is re-checked on each ETA update; if a promise breaks by more than 5 minutes, new insertions into that car stop, the rider's fare is reduced per policy, and the city's detour metric rises. |
| A GPS-spoofing ring (fake locations to win airport queues or surge) | Drivers jump kilometers between pings; many devices share patterns | Server-side physics checks (speed between pings above about 200 km/h, positions off any road), device-integrity signals, clustering of devices and accounts; suspects get a hold in driver_holds, which every offer checks, so no ping or status change can clear it. Enforcement follows the city's review process. |
| A cell deploy goes wrong | One city's SLO alarms fire | Automatic rollback for that cell; other cells never received the change. |
| The city map is wrong (a city pointing at the wrong region) | Forwarded requests fail or loop | The map is a small global table with an owner epoch; front doors refuse to forward more than once, and alarm. |
R3.9 Runbook and Incident Response
Golden signals, per city and per region OPS 8 · REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Time to first offer, P99 | > 3 s for 5 min in a city | P1 | Check the zone matchers' solve time and routing latency; switch the city to greedy if the solver is the cause |
| Offer acceptance rate | drops by a third against the same hour last week | P2 | Push latency? A bad fare estimate? Check the late-accept rate |
| Double assignment | any driver with two active trips (a stream consumer tracks active trips per driver) | P1 | Must be 0; freeze the city's matchers, reconcile, find the path that skipped the transaction |
| Surge by cell | any city's median multiplier doubles in 10 min, or snapshot age > 3 min | P2 | Check for a real event; if not, cap the city and pin the last good snapshot |
| Location lag | Kinesis iterator age > 5 s, or ping-to-index lag > 2 s | P2 | Scale updaters; check Valkey primaries |
| Cancellation rate | rider cancellations above baseline + 50% | P2 | Pickup ETAs or prices off; check routing and surge |
| Failed conditions | > 2% of offer transactions | P3 | Contention near zone borders or a clock or push problem |
| Replica lag (global tables) | replication latency > 5 s for 5 min | P2 | The failover RPO is growing; investigate before it matters |
Emergency surge cap procedure REL 5
- Decide scope: one cell, a list of cells, or the whole city.
- Write the cap rows (command 2). The pricing service loads caps every 10 s; confirm quotes in the area show the capped multiplier.
- If the formula itself is broken, pin the last good snapshot version and set the city's kill switch to 1.0.
- List trips charged above the cap since the incident began, for automatic refunds.
- Remove the caps only after the fix is deployed and a replay of the incident window shows sane multipliers.
Fallback to greedy matching (a solver or routing problem in one city): set the city's matching mode (command 3); matchers read it every 10 s. Greedy with straight-line fallback ETAs is worse but never stuck.
Regional failover procedure REL 13
- Confirm it's the region: several services, health checks and the AWS Health Dashboard agree.
- Check the partner's replica lag (command 4) to estimate what may be lost.
- Flip each affected city in the city map, incrementing its owner epoch (command 5), written in the standby region. Confirm the front doors in every region now forward those cities to the standby.
- Restart the change feeds on the promoted side: confirm the standby's stream consumers for the failed region's tables are running and acting for the flipped cities, and that their iterator age is falling (commands 6 and 7). Without them, the standby's index gets no status hints and riders get no notifications.
- Trigger the "re-send active trip state" job for the flipped cities.
- Scale gateways and cells in the standby (command 8); watch Valkey (command 9) and time to first offer.
- Failback later, city by city at quiet hours: flip back with a new epoch after the old region has caught up on replication, then run the conflict reconciliation report.
Go deeper: CLI playbook
Plain commands an on-call engineer runs one at a time. Replace names, times and IDs with real ones.
text# 1. Alarms firing for dispatch in a region aws cloudwatch describe-alarms --region eu-west-1 --state-value ALARM --alarm-name-prefix dispatch- # 2. Emergency surge cap for one city (the pricing service loads caps every 10 s) aws dynamodb put-item --region eu-west-1 --table-name surge_caps --item '{"scope":{"S":"city#london"},"max_multiplier":{"N":"1.5"},"reason":{"S":"storm emergency"},"expires_at":{"N":"1790704800"}}' # 3. Switch one city's matching to greedy aws ssm put-parameter --region eu-west-1 --name /dispatch/london/matching-mode --value GREEDY --type String --overwrite # 4. Replica status of a failed region's trips table, seen from the standby aws dynamodb describe-table --region eu-central-1 --table-name trips-euw1 --query "Table.Replicas" # 5. Flip a city to the standby region with a new owner epoch aws dynamodb update-item --region eu-central-1 --table-name city-map --key '{"city":{"S":"london"}}' --update-expression "SET owner_region = :r, owner_epoch = owner_epoch + :one" --expression-attribute-values '{":r":{"S":"eu-central-1"},":one":{"N":"1"}}' # 6. The standby's stream consumers for the failed region's trips table aws lambda list-event-source-mappings --region eu-central-1 --function-name trip-notifier-euw1 # 7. Iterator age of that consumer over the last 15 minutes aws cloudwatch get-metric-statistics --region eu-central-1 --namespace AWS/Lambda --metric-name IteratorAge --dimensions Name=FunctionName,Value=trip-notifier-euw1 --start-time 2026-09-28T18:00:00Z --end-time 2026-09-28T18:15:00Z --period 60 --statistics Maximum # 8. Scale out gateways in the standby aws ecs update-service --region eu-central-1 --cluster dispatch --service gateways --desired-count 300 # 9. State of the standby's driver index aws elasticache describe-replication-groups --region eu-central-1 --replication-group-id driver-index
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | City cells bound the blast radius of deploys and configs; paired regions with replicated dispatch state; one writer per city with owner epochs; trips continue on phones and resync; standby capacity for the partner's peak REL 10 · REL 13 |
| Security | Signed, expiring, revocable share links; holds enforced in every offer against spoofing rings; the emergency path isolated from shedding; locked audit copies SEC 3 · SEC 4 · SEC 8 |
| Performance Efficiency | Matching in each city's home region; pool insertion checks fit in routing headroom; per-city SLOs PERF 1 · PERF 4 |
| Cost Optimization | About $125K a month, $0.16 per thousand trips; the cost of region survival shown line by line; regional prices applied COST 5 · COST 6 |
| Operational Excellence | Per-city golden signals including double assignment = 0; cell-by-cell deploys with rollback; surge cap, greedy fallback and failover procedures OPS 6 · OPS 10 |
| Sustainability | Cities served from their own continent's regions; raw pings kept 30 days, only flagged trips kept longer; batch matching and pooling cut empty kilometers driven SUS 1 · SUS 4 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Turns one service into city cells with config, cell-by-cell rollout and per-city SLOs.
- Treats pooling as route insertion with explicit promises, and commits it with the same conditional-write discipline as single rides.
- Plans scheduled rides as forecast plus early commitment plus fallback, not as a normal request at a later time.
- Makes fairness a measured, capped, audited part of the objective, and names the owner of the policy.
- Builds safety paths that skip normal shedding and retention.
- Designs region failure around what the phones already know, knows the limits of global tables (asynchronous, last writer wins, transactions atomic only locally, MRSC without transactions), and routes by a city map rather than by DNS.
Follow-up questions
-
"Why not make the standby region active for the same city, so failover is instant?" Answer: two regions writing the same city's drivers and trips would resolve conflicts by last writer wins, and a transaction in one region isn't atomic in the other: a driver could end up matched in both. One writer per city with an owner epoch keeps the invariant, at the price of about two minutes of failover for new requests. Trips in progress don't notice.
-
"How do you know the fairness term isn't just moving unfairness somewhere else?" Answer: we measure the distributions, not one number: idle time and earnings per online hour across drivers, pickup waits across riders and neighborhoods, before and after, by replay and then by a zone-level A/B test. A change ships only if the driver spread narrows without the rider spread widening past a set limit.
-
"A pool rider's detour promise is broken by traffic after the match. Whose problem is it?" Answer: ours, visibly: we detect it from the ETA updates, stop inserting more riders into that car, adjust the fare per policy, and count it in the city's detour metric, which feeds back into that city's limits.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "One global matcher with a rules table" | One blast radius for every city's traffic and mistakes. |
| "Pool by nearest pool car" | Pooling is about the route; the nearest car may double someone's trip. |
| "Match reservations at booking time" | Locks a driver for hours they can't promise. |
| "The algorithm is objective, so it's fair" | Minimizing total ETA silently favors well-placed drivers. |
| "DNS failover sends people to the backup region" | Latency routing picks the nearest healthy region, not our city's standby; forward by the city map. |
| "Global tables with strong consistency" | MRSC supports neither transactions nor TTL, and runs in exactly three regions. |
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping: ping rate, search radius, one offer at a time, 15 s, fixed prices | Restate Round 1 in 60 seconds | Restate Round 2 in 60 seconds |
| 5–15 min | Requirements and API (WebSocket pings with seq, idempotent requests, 409) | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Steps 1.0–1.5: SQL baseline → memory by cell → k-rings and their reach → conditional accept → expiry + SQS timer → state machine | Steps 2.1–2.6: batch assignment with a 3×3 example → road ETAs → lock driver and trip → epoch fencing → smoothed surge with caps → stream ingest | Steps 3.1–3.6: city cells → pool insertion → reservations → fairness → safety paths → region failover mid-trip |
| 40–50 min | Numbers, cost, geohash vs H3 | Commands, write units, latency budgets, cost, trade-offs | Per-region sizing with per-AZ rounding, cost of survival, trade-offs |
| 50–60 min | Failures and pillar check | Failures, gotchas, pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening any round: "Positions are disposable and change every 4 seconds, so they live in memory by cell; decisions are permanent, so each one is a conditional write that proves the trip is still offered to this driver, with an expiry checked by our clock and a durable timer to move on."
- When scale arrives: "I'll match in short batches as an assignment problem on road ETAs, lock the driver and the trip in one transaction before the offer goes out, fence every offer with an epoch, and keep every override, from driver holds to surge caps, in its own layer that no update can overwrite."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What if two drivers accept at once?" (REL 4) | One conditional write per trip decides; the loser gets 409. | 1 | Step 1.3 |
| "What if a driver is offered two trips?" (REL 4) | The offer is a transaction that locks the driver too; a driver can hold one offer, so can reach one trip. | 2 | Step 2.3 | |
| "What if a region fails mid-trip?" (REL 13) | Trips continue on the phones, the city flips to its standby by the city map in about two minutes, and phones resync. | 3 | Step 3.6 | |
| Performance | "Why not nearest driver first?" (PERF 1) | Batching two seconds of requests and solving the assignment cuts total and worst waits; the 3×3 example goes from 26 to 15 minutes. | 2 | Step 2.1 |
| "How do you handle 500,000 pings a second?" (PERF 3) | Gateways batch into Kinesis; updaters write Valkey cells at two complete resolutions; nothing durable per ping. | 2 | Step 2.6 | |
| Security | "Can a client change its price?" | No: quotes are signed by the pricing service, expire in 120 s, and a used_quotes item makes each one usable once. | 2 | R2.3 |
| "How do you stop GPS spoofers?" (SEC 4) | Physics and device checks flag them, and a hold in its own table blocks every offer until review. | 3 | R3.8 | |
| Cost | "What does it cost?" (COST 5) | About $670, $29.9K and $125K a month: from under a tenth of a cent per trip to $0.16 per thousand with region survival. | 1–3 | R1.7, R2.6, R3.6 |
| "Why not Step Functions for the offer timers?" (COST 5) | About $95K a month in state transitions versus about $685 for SQS delay messages, with the state already in our conditional writes. | 2 | R2.7 | |
| Operations | "How do you know dispatch is correct?" (OPS 8) | Double assignment is a metric that must be 0, alongside time to first offer, acceptance, late accepts and surge snapshot age. | 2–3 | R3.9 |
| Sustainability | "Is this wasteful?" (SUS 2) | Fleets follow the daily curve, pings aren't stored durably twice, and batching and pooling cut empty driving. | 2–3 | R2.10, R3.10 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Live location | In memory by cell, TTL, keep-alive | Stream ingest, two complete resolutions, lake at the average rate | Per-region indexes refilled by reconnecting phones after a failover |
| Finding drivers | k-rings with a proven reach, widened on demand | Road ETAs, cell-pair cache, density-chosen resolution | Pool insertion over nearby routes; airport queues by config |
| Matching | Nearest first, one offer at a time | Batch assignment, wait boost, greedy fallback | Fairness term, reservations, per-city tuning |
| Exactly one | Conditional accept on the trip | Driver + trip transaction, epoch fencing, retries that re-publish | One writer per city, owner epochs, phone as the last guard |
| Timers | Expiry in the condition + SQS delay; never DynamoDB TTL | Timer before the write; stale-offer takeover | – |
| Pricing | Fixed | Smoothed surge, complete snapshots, caps layer, locked quotes | Per-city caps, kill switch, refunds on a pricing bug |
| Evolving under new scope | Builds from a SQL table step by step | Opens with "what breaks", fixes matching first | Changes the shape (cells, pairs, products) without breaking the one-driver-one-trip invariant |