Notes
Listen
Requirements, one user at a time. Customers browse restaurants that deliver to their address, order, pay, and track the courier live with an ETA. Restaurants accept or reject orders, set a prep time and mark food ready. Couriers go online, receive delivery offers, accept, pick up and deliver. Reviews, promotions, courier payouts and grocery delivery are out of scope.
Qualities. Orders and payments need strong guarantees: an order is never lost and never charged twice, and all three apps agree on its state. Courier locations only need to be fresh within five to ten seconds, so they can be lossy. Dispatch must decide within seconds. Traffic spikes at dinner to around five times the average. And because delivery is local, design per city, so a problem in one city doesn't spread.
- customer: browse restaurants
- that deliver here, order,
- pay, track live, get an ETA
- restaurant: accept or reject,
- set prep time, mark ready
- courier: go online, receive
- offers, accept, pick up,
- deliver
- out: reviews, promos, payouts
- to couriers, grocery
- an order is never lost or
- charged twice
- order state consistent across
- all three apps
- courier position fresh within
- ~5-10 seconds
- dispatch decision in seconds
- peak = dinner, ~5x average
- per-city: one city failing
- must not affect others
Ask scale
Assume ten million orders a day. That's only about a hundred a second on average, maybe five hundred at the dinner peak. Browsing is heavier: fifty million daily users with ten searches each is five thousand queries a second. The surprising number is couriers: a million online at peak, each sending GPS every five seconds, is two hundred thousand location writes a second. That's the highest write rate in the system, and it shapes the design.
Storage. An order with its items is about two kilobytes, so twenty gigabytes a day and around seven terabytes a year. Small, valuable, relational. GPS pings are tiny but constant: about ten megabytes a second, close to a terabyte a day if you keep them all, which you only need for analytics. The live state, where each courier is right now, is only about a hundred megabytes. That fits in memory easily.
orders/day 10M 10M / 100,000 s = 100/s dinner peak x5 = 500/s browse: 50M DAU x 10 500M / 100,000 s = 5K qps couriers online, peak 1M ping every 5 s = 200K location writes/s live trackers ~2M sockets
order row + items ~ 2 KB 10M x 2 KB = 20 GB/day x 365 = ~7 TB/yr GPS ping ~ 50 B 200K/s x 50 B = 10 MB/s = ~860 GB/day raw live state: 1M couriers x 100 B = 100 MB -> fits in RAM
Define API
The API. Customers list restaurants near a location and place an order with an idempotency key, so a double tap or a retry on a bad network never creates two orders. Restaurants accept and mark ready. Couriers post their location every few seconds and accept offers. Customers open a WebSocket, or use server-sent events, to receive live order status and courier position.
GET /v1/restaurants?lat=..&lng=..
POST /v1/orders
Idempotency-Key: 7f3c..
{ restaurantId, items[], addr }
POST /v1/orders/{id}/accept
POST /v1/orders/{id}/ready
POST /v1/courier/location
{ lat, lng, heading, ts }
POST /v1/offers/{id}/accept
WS /v1/orders/{id}/track Data model
Data model. Orders are relational and transactional, so use SQL, sharded by city, since an order never spans cities. Each order has a state and a version for optimistic locking, and every transition is appended to an order events table, which doubles as an audit trail. Offers to couriers have an expiry. Payments are tied one-to-one to an order. Live courier positions don't go in the database at all: they live in an in-memory geo index.
order (orderId, cityId, custId,
restaurantId, courierId,
state, version, total, ts)
order_event (orderId, seq, state)
offer (offerId, orderId,
courierId, expiresAt, status)
payment (orderId, intentId,
status) unique orderId
courier_live (in memory):
courierId -> lat, lng, status High-level design
The order path. The customer finds a restaurant through search, which is a proximity search like the Yelp design, plus a delivery radius check. Placing the order calls the payment service to authorise, not yet charge, the card. The order is saved as placed, and the restaurant's tablet is notified. When the restaurant accepts, it sets a prep time, and the order moves on to dispatch.
The delivery path. Couriers send location pings that the location service writes into an in-memory geo index. Once the restaurant accepts, dispatch asks the index for available couriers near the restaurant, asks the ETA service how long each would take, picks the best and sends an offer that expires in about thirty seconds. Then the courier's pings drive the live map on the customer's phone.
Deep dive: order state
Deep dive one: the order state machine. An order moves from placed, to accepted, to ready, to picked up, to delivered, with rejected and cancelled as side exits. Each transition is a conditional update: set the new state only if the current state and version are what you expect. If zero rows change, someone else moved it first, like a cancel racing a pickup. Payment is authorised at order time and captured only on delivery, so a rejected order just releases the hold.
How do all three apps stay in agreement? Write the transition and an outbox row in the same database transaction, and have a relay publish outbox rows as events. That way an event is published if and only if the state change committed, see Concepts episode thirty-three. Apps receive pushes, and whenever they reconnect they fetch the current state rather than trusting missed messages. Payment calls use the order ID as the idempotency key. Timers flag orders stuck in one state too long.
PLACED -> ACCEPTED -> READY -> PICKED_UP -> DELIVERED PLACED -> REJECTED (refund) any -> CANCELLED (rules) UPDATE order SET state=.., version=version+1 WHERE id=.. AND state=.. AND version=.. 0 rows updated -> conflict capture payment on DELIVERED
- transition + outbox row in
- the same transaction
- outbox relay publishes the
- event: exactly the states
- that committed
- apps get pushes; on reconnect
- they fetch current state
- payment calls use the order
- ID as idempotency key
- stuck orders: timers alert
Deep dive: locations
Deep dive two: courier locations. Two hundred thousand pings a second is too much for a transactional database, and we don't need durability for each one. Stateless ingest nodes, partitioned by city, update an in-memory index that maps each cell to the couriers in it, using geohash or hexagon cells, plus the courier's latest position. Ignore a ping older than the one you have. A lost ping doesn't matter; another arrives in five seconds. Raw pings go to a stream for analytics and disputes.
Live tracking. The customer subscribes to their order, not to a courier. When a ping arrives for a courier who is carrying an order, the tracking service looks up which socket is watching it and pushes the new position. The client animates smoothly between pings so the bike glides instead of jumping. And to save battery, couriers ping every five seconds while moving and much less often when idle.
- stateless ingest nodes
- partition by city, then by
- courierId
- geo index per city in RAM:
- cell -> set of courierIds
- (geohash or H3 cells)
- update: only if ts is newer
- lost ping? the next one is
- 5 s away: no retries
- raw pings -> stream -> archive
- customer subscribes to their
- order, not to the courier
- tracking service: courier ping
- -> lookup active order
- -> push to that socket
- smooth on client: interpolate
- between pings
- adapt ping rate: 5 s moving,
- 30 s when idle (battery)
Deep dive: dispatch
Deep dive three: dispatch. Timing matters as much as choice. If the food takes fifteen minutes and a courier is five minutes away, dispatching now means ten minutes of waiting at the counter, so dispatch at ready time minus travel time. Candidates are available couriers in cells near the restaurant. Score them by how well their arrival matches the ready time. Offer to the best with a timeout, holding a short lock so two orders can't grab the same courier. Batch two orders on one route when it barely delays either.
The ETA is a sum of predictions. Time until a courier is assigned, then whichever is longer, the remaining prep time or the courier's trip to the restaurant, plus the handoff, plus the trip to the customer. Travel times come from a road graph with live traffic, which differs for a bike and a car. Prep time is learned per restaurant and hour, because a pizza place on Friday night is slower than its menu claims. Recompute at every event and push changes to the customer.
- when: time it so courier
- arrives as food is ready
- (ready time - travel time)
- candidates: idle couriers in
- cells near the restaurant
- score: arrival vs ready time,
- then drop-off time, rating
- offer to one, 30 s timeout,
- then next; lock courier
- batch: 2 orders, same route
- ETA = wait for courier
- + max(prep left, courier
- travel to restaurant)
- + pickup handoff
- + travel to customer
- travel: road graph + live
- traffic, by vehicle type
- prep: learned per restaurant
- and hour, not the menu
- recompute on every event
Trade-offs
Failures. If a geo index node dies, it refills from pings within ten seconds. If dispatch is down, accepted orders wait in a queue, and courier locks prevent double assignment when it recovers. If a restaurant tablet goes offline, escalate with a phone call, then auto-reject and release the payment hold. Retry slow payment calls with the same idempotency key. Running each city as its own cell limits the blast radius. Watch time to assign a courier and the share of late deliveries.
Trade-offs. In-memory locations are fast and cheap, and losing a few pings is fine. Delaying dispatch until the right moment means hotter food and less waiting, but risks no courier being free at that moment, so start earlier when supply is low. Batching saves courier time but can add a few minutes for one customer. At ten times the scale, split big cities into zones, each with its own dispatch and geo index.
- geo index node dies: rebuilt
- from pings within 5-10 s
- dispatch down: orders queue,
- retry; never double-assign
- restaurant tablet offline:
- call or auto-reject + refund
- payment provider slow: retry
- with same idempotency key
- cells per city: blast radius
- watch: time to assign, late %
- in-memory locations: speed
- and cost, lose a few pings
- delayed dispatch: hotter food
- and less courier waiting,
- some risk if no one is free
- batching: efficiency vs a
- few minutes for one customer
- 10x: split big cities into
- zones, regional dispatch
Say it like this
The summary. Orders are few but precious: SQL sharded by city, a state machine of guarded transitions, outbox events to keep three apps in sync, and payments authorised up front and captured on delivery with idempotency keys. Courier pings are many and disposable: two hundred thousand a second into a per-city in-memory cell index. Dispatch happens at ready time minus travel time, scoring nearby couriers and offering with a timeout and a lock. ETAs are a sum of learned predictions.
- orders: ~500/s peak, SQL by
- city, guarded transitions,
- outbox events
- pay: authorise, capture on
- delivery, idempotent
- pings: 200K/s into per-city
- in-memory cell index
- dispatch at ready - travel,
- score, offer, timeout, lock
- ETA: sum of predictions