SYSTEM DESIGN · DESIGN PROBLEMS · LOCATION · MEDIUM · 20 OF 41

Design a Food Delivery App: Orders, Live Courier Tracking, Dispatch and ETAs

How to design a food delivery platform that connects customers, restaurants and couriers: requirements, dinner-peak math, the order and tracking APIs, an order state machine with idempotent payments, ingesting courier GPS pings into an in-memory geo index, dispatch that scores and offers orders to couriers with timeouts and batching, ETAs from prep time and travel time, and the trade-offs.

Reported atFlipkartSwiggy

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 19

Press play to hear the narration, or step through at your own pace.

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.

Functional requirements
  • 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
Non-functional requirements
  • 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.

The numbers
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
Storage
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.

Core API
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.

Records
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.

Order state machine
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
Keeping three apps in sync
  • 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.

Ingesting pings
  • 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
Live tracking
  • 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.

Matching
  • 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
  • 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.

Failures and monitoring
  • 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 %
Trade-offs to name
  • 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.

The 60-second answer
  • 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

Interview questions

How do you store courier locations arriving every few seconds?

Keep only the latest position per courier in an in-memory geo index partitioned by city, mapping cells to courier IDs. Pings are not individually durable, because the next one arrives in seconds, and raw pings are streamed separately for analytics.

How do you prevent a courier from being assigned two orders at once?

Dispatch takes a short lease on the courier when sending an offer, and acceptance is a conditional update that succeeds only if the courier is still free. An expired offer releases the lease so the next order can use them.

When should you dispatch a courier?

Aim for the courier to arrive just as the food is ready, so dispatch around the predicted ready time minus the courier’s travel time to the restaurant. Start earlier when few couriers are available.

How do you keep the order consistent across three apps?

Each transition is a conditional update on state and version, written together with an outbox row that a relay publishes as an event. Apps get pushes and refetch current state on reconnect, so they converge even if a message is missed.

How do you avoid charging a customer twice?

Order creation carries a client idempotency key, and payment calls use the order ID as their idempotency key with the provider. Authorise at order time and capture once on delivery, so retries never create a second charge.

How do you compute the ETA?

Sum the predicted time to assign a courier, the larger of remaining prep time and the courier’s travel to the restaurant, a pickup allowance, and travel to the customer. Prep times are learned per restaurant and hour, travel times come from a road graph with live traffic, and the ETA is recomputed at every event.

Why shard by city?

Orders, couriers and restaurants almost never interact across cities, so city is a natural partition key that keeps queries local. It also limits the blast radius, so an incident in one city does not affect others.

What do you do if the restaurant never responds?

Start a timer when the order is placed; escalate with a call or alert, and if there is still no response, auto-reject, release the payment authorisation and tell the customer. Timers for every state catch stuck orders.