Scope the requirements
Three actors — customer, restaurant, driver — with independent lifecycles. The design work is the seams between them, especially when things go wrong (driver cancels, restaurant runs out mid-preparation).
Functional — what it must do
- Customer browses restaurants nearby, orders, tracks in real time.
- Restaurant receives order, confirms/rejects, marks ready.
- System dispatches a driver, updates ETA, pushes order state to all three.
- Payment on placement; refund/tip changes at delivery.
- Explicitly cut: subscriptions, group orders, alcohol ID — offer them back.
Non-functional — the numbers that shape the design
- Match latency: driver assigned within 20 s of restaurant confirming.
- 1M active orders in flight globally; peak 200k/s location writes from drivers.
- ETA accuracy: p95 error under 5 minutes.
- Restaurant menu update propagates in under 1 minute.
- 99.99% for the order-placement path; realtime tracking may degrade.
Three actors, three lifecycles, one order state machine. I'll draw the state machine first and hang services off it.
Back-of-envelope estimates
The scary number is location writes from drivers; the actual load on the matching brain is much smaller — because we only match when the restaurant says a batch is ready.
| Quantity | Estimate | How you got there |
|---|---|---|
| Concurrent drivers online | ~2M | peak lunch/dinner windows; heartbeat every 4 s → 500k location writes/s. |
| Orders/day | ~30M | ~350/s average; peak dinner window ~2k/s in a metro. |
| Match calls/s | ~500/s peak | one per restaurant-ready event, not per driver ping. The matching brain is small. |
| Menu items | ~500M rows | 500k restaurants × ~1k items with variants. Read-heavy, written by restaurant staff. |
| Location storage | ~1 TB/day | downsampled to per-minute breadcrumbs; hot minute in memory, day archived to S3. |
| Push notifications | ~10k/s | state changes × 3 actors per order. Batched through the notification system. |
Drivers ping cheap. What's expensive is the matching decision — 500 per second globally, but every one of them is a small geospatial optimisation.
Sketch the API
Three separate API surfaces per actor. Location writes are their own high-throughput channel that bypasses the order services entirely.
- Location writes are a separate service: high write throughput, no join needs. They land in a Redis geospatial index and a Cassandra breadcrumb log — never in the same database as orders.
- The order state machine is the source of truth; every actor's view is derived from
ordersvia subscriptions.
Data model
Four tables with clear owners: orders (transactional, per-metro sharded), restaurants + menus (mostly-read), drivers (small stable set + a hot location index), payments (external + local record).
Orders in Postgres because the state machine wants ACID + secondary indexes. Menu items shard-by-restaurant in Cassandra would work but the total is small — one Postgres cluster per metro is easier. Driver locations are the only large-write dataset — Redis for the hot query, Cassandra for history.
High-level design
Six services, one event bus that everyone subscribes to. The order state machine is the spine; the matching service reads it and writes drivers back onto it.
- Customer orders → order service inserts PENDING, kicks off payment auth, emits
order.created. Restaurant receives via its subscription. - Restaurant confirms →
order.accepted. Prep begins. Menu service is not involved after this point. - Restaurant marks batch ready →
order.ready. Matcher wakes up, queries Redis GEO for online drivers within a radius, ranks them by predicted time-to-restaurant + current queue depth, hedges to top 2-3. - Chosen driver's app receives the offer via push + socket. On accept,
order.assigned→ order service updates driver_id, all three actors get the update. - Driver's location updates flow into Redis every 4 s; the ETA service subscribes to breadcrumbs and pushes recalculated ETA to the customer's tracking socket.
Deep dives — where the interview is won
Matching: geospatial index + hedged offers
The matcher wakes up on order.ready. First step: geo query. Redis GEOSEARCH returns online drivers within R km, sorted by distance. That's a coarse filter — the real ranking considers current speed, whether the driver is finishing another order, historical acceptance rate, and predicted travel time from the road network. All of this comes from Redis + a small model call; the total budget is <200 ms.
The subtlety: don't send the offer to the top driver. A single-offer strategy has ~10-20% no-show rate (driver ignores push, phone lost signal). Hedging — send to the top 2-3 drivers simultaneously with a first-accept-wins race — cuts assignment time in half at the cost of some driver frustration. Modern designs cap hedging via a rolling reputation system so drivers who accept a lot keep getting first pick.
UPDATE ... WHERE driver_id IS NULL) or two drivers will show up to the same restaurant.Dynamic ETAs from breadcrumbs and prep-time model
A first-order ETA is time_to_restaurant + prep_time + time_to_customer. Each piece has to be modelled: time_to_restaurant comes from the road-network graph plus current traffic, updated by driver breadcrumbs; prep_time is per-restaurant, per-item-count, per-time-of-day, learned offline; time_to_customer is another graph query. On assignment, we compute an initial ETA and every ~30 s recompute it as the driver moves.
The ETA that hurts most is the wrong-and-optimistic one — customer expected 25 min, got 45. Design bias: pad ETAs slightly in the initial commitment, then beat them. The whole thing feeds into the tracking WebSocket: each recomputation is a small payload pushed to the customer app so the map animation stays honest.
The order state machine as the single source of truth
Every service reads and writes different columns of the orders table. If two of them fight — matcher assigns driver A while restaurant service marks the order cancelled — inconsistency wrecks the day. The pattern: the order service is the only writer. Every other service publishes a request (request.assign_driver) and the order service applies it under a per-order lock in a state-machine transition function that validates preconditions.
State transitions emit events on the bus after the write commits (transactional outbox pattern — insert the event row in the same transaction as the state change, then a shipper pushes to Kafka). This guarantees exactly-once state changes with at-least-once event delivery. Downstream services are idempotent on order_id + status.
Follow-ups you should expect
What happens if a driver accepts then goes offline?
How does the restaurant's menu update propagate in <1 min?
Batching multiple orders for one driver — how does the design change?
How do you handle payment on a partial order (item ran out during prep)?
Real-time tracking scale: 1M concurrent customers watching the map
Where candidates lose the room
- Storing driver locations in the same database as orders — 500k writes/s on the transactional store is a nonstarter.
- Naive matching that sends to the single closest driver — no-shows tank match rate; hedging or bidding is standard.
- Making the ETA a static number set at placement — customers expect it to move; static ETAs make angry customers.
- Letting multiple services write to the orders table — you'll have races between cancel and assign that lose orders.
- Treating menus as static — restaurants change availability constantly, and stale menus cause the worst failed order.