Design a Food Delivery Service (Talabat / Uber Eats / DoorDash)
Difficulty: Tier 3 Asked at: Talabat, Careem (Now), DoorDash, Grab, Amazon; a MENA/SEA favourite Time budget: 45–60 min
Food delivery is ride-hailing plus a three-sided marketplace (customer,
restaurant, courier) and a multi-stage fulfillment problem. The geospatial matching is familiar, so
this study focuses on what’s new: coordinating three parties, the order-and-delivery state machine, and
the timing problem — when to assign a courier so the food is ready right as they arrive.
Prerequisites: Design Ride-Hailing, Geospatial Indexing, Sagas / Distributed Transactions
1. Requirements
Functional:
- Customers browse restaurants/menus, place orders, pay, track delivery.
- Restaurants receive/accept orders, mark food ready.
- Couriers get assigned deliveries, pick up, drop off.
- Real-time tracking of order status and courier location.
Non-functional:
- Three parties kept in sync in real time.
- Reliable order/payment state (no lost or double orders).
- Geospatial matching of couriers to orders.
- High availability during meal-time peaks (huge, predictable spikes).
Out of scope: payment internals (separate), restaurant recommendations/search
(reference feed/typeahead).
2. Estimation
- Talabat-scale: millions of orders/day, extreme lunch/dinner peaks (most volume in ~4 hours) →
provision/autoscale for peak, not average. 🚨 Bursty, predictable load is a defining trait.
- Courier location updates: like ride-hailing (~every few seconds) but fewer couriers than Uber drivers.
- Order state and history: durable, transactional — this is commerce, not best-effort.
3. The three-sided coordination
🚨 Three actors, each with their own app and state, must stay consistent:
flowchart LR
Customer -->|1. order + pay| Order[Order Service]
Order -->|2. send order| Restaurant[Restaurant app]
Restaurant -->|3. accept, cooking, ready| Order
Order -->|4. find courier| Match[Courier Matching<br/>geospatial]
Match --> Courier[Courier app]
Courier -->|5. pickup → dropoff| Order
Order -->|live status/location| Customer
The order service is the orchestrator holding the source-of-truth state machine; the three apps are
clients that update and observe it. Live updates flow over persistent connections (chat/ride pattern).
4. The order & delivery state machine
A long-lived workflow spanning minutes and three parties:
PLACED → (payment authorized) → RESTAURANT_ACCEPTED → PREPARING → READY
→ COURIER_ASSIGNED → PICKED_UP → EN_ROUTE → DELIVERED → (payment captured)
🚨 Each transition is a durable, ordered event. Failures at any stage need handling: restaurant rejects
→ refund + notify; no courier found → retry/escalate; customer cancels → compensations. This is a saga — a multi-step distributed transaction with compensating actions,
not a single ACID transaction.
5. Deep dives
5a. Courier matching — and the timing problem
Finding a nearby courier is the ride-hailing geospatial problem (geohash/S2 index,
nearby query, offer-and-accept). 🚨 The twist: when to assign. Assign too early and the courier waits
at the restaurant for food; too late and the customer waits. Optimal dispatch predicts food-ready time
and courier ETA so the courier arrives just as the food is ready. This prediction + optimization is what
distinguishes a good delivery system. Also: batching — one courier carrying multiple nearby orders.
5b. Reliability via sagas
Because the order spans payment, restaurant, and courier — separate services — you can’t wrap it in one
database transaction. Use a saga: each step commits locally and emits an event; if a later step fails, run
compensating transactions (refund the payment, cancel the restaurant order, notify the customer). The
order service (or a workflow engine) coordinates this. (Saga Pattern)
This guarantees the system reaches a consistent end state (delivered or fully compensated) despite partial
failures.
5c. Handling peak load
Meal times = massive predictable spikes. Autoscale services ahead of known peaks; use queues to absorb
order bursts; degrade gracefully (extend ETAs, pause new orders in overloaded zones) rather than crash.
Regional sharding (like ride-hailing) contains load per city.
5d. Real-time tracking
Customer watches: order status transitions + courier location on the map. Courier location streams via
WebSocket (ride-hailing pattern); status transitions push to the customer app. Restaurant gets order alerts
reliably (they must see the order — retry + audible alert + confirmation).
Restaurants go online/offline, run out of items, have prep-time variance. The catalog (menus, availability)
is a read-heavy service (cache + CDN for menu images), updated by restaurants. Show accurate availability so
customers don’t order unavailable items.
6. Bottlenecks & scaling further
- Peak spikes → autoscale + queues + graceful degradation; provision for peak.
- Courier matching → geospatial index + predictive dispatch + batching.
- Cross-service consistency → sagas with compensations.
- Live tracking → WebSocket connection servers.
- Menu reads → cache + CDN; independent read-heavy catalog service.
7. Trade-off summary
| Decision |
Chosen |
Alternative |
Why |
| Order consistency |
Saga + compensations |
One ACID transaction |
Spans independent services; can’t be one txn |
| Courier assignment |
Predictive (food-ready + ETA) |
Assign immediately |
Minimizes both courier and customer waiting |
| Efficiency |
Batch nearby orders |
One order per trip |
More deliveries per courier |
| Partitioning |
By city/region |
Global |
Deliveries are local; contain peak load |
| Peak handling |
Autoscale + degrade |
Provision for average |
Meal-time spikes are huge and predictable |
8. Follow-up questions
Why can't the whole order be one database transaction?
Because it spans multiple independent services and external parties over several minutes: payment
authorization, the restaurant accepting and cooking, courier assignment, pickup, and delivery. A single ACID
transaction requires one database and short duration; here the steps live in different services (payments,
restaurant, dispatch) and take minutes, with humans in the loop — you can't hold a database lock across a
20-minute cooking process. So you use a saga: each step commits locally and publishes an event, and if a
later step fails (restaurant rejects, no courier available, customer cancels), the system runs compensating
transactions to undo the completed steps (refund the payment, cancel the restaurant order, notify everyone).
The saga guarantees the order reaches a consistent terminal state — either fully delivered or fully unwound —
without needing a distributed transaction that would be impractical across these services and timescales.
What's the hardest part of courier assignment that ride-hailing doesn't have?
Timing against food readiness. In ride-hailing the rider is ready the moment they request, so you just want
the nearest available driver as fast as possible. In food delivery the food isn't ready when the order is
placed — it needs preparation — so assigning the nearest courier immediately often means the courier arrives
and waits idle at the restaurant, wasting their time, while assigning too late makes the finished food sit
and the customer wait. The system has to predict when the food will actually be ready (which varies by
restaurant, item, and current kitchen load) and the courier's travel time, and dispatch so the courier
arrives just as the food is ready. Getting this prediction and optimization right — plus batching multiple
nearby orders onto one courier — is what separates an efficient delivery platform from a mediocre one, and
it's a dimension ride-hailing simply doesn't have.
Meal times cause huge spikes. How do you handle them?
Since the spikes are large but predictable (lunch and dinner windows), you provision and autoscale for peak
rather than average — scaling capacity up ahead of known meal times. Order bursts are absorbed by queues so
a sudden flood doesn't overwhelm downstream services, and the system degrades gracefully under extreme load
rather than failing: extending quoted ETAs, temporarily pausing new orders in overloaded zones, or
throttling, so existing orders still complete. Regional sharding contains each city's peak to its own
capacity so one hot market doesn't affect others. The philosophy is to plan for the known peak and shed or
delay load gracefully at the extremes instead of crashing.
How do you make sure the restaurant actually sees an incoming order?
Treat order delivery to the restaurant as a must-deliver, acknowledged action, not fire-and-forget. The
order service sends the order to the restaurant's device/tablet and waits for an explicit acceptance
acknowledgement; if it isn't acknowledged within a short window, it retries, escalates (louder/repeated
alerts), and can fall back to alternative channels (a phone call, a support agent). The order stays in a
"pending restaurant confirmation" state until acknowledged, and the customer's payment is only authorized
(not captured) until the restaurant confirms — so an unseen order doesn't silently charge the customer or
strand them. Reliable, acknowledged delivery with retries and escalation is essential because a missed order
is a failed sale and a bad experience for all three parties.
9. What junior / mid / senior answers look like
- Junior: models order placement and a courier lookup, but treats it as a simple CRUD flow — misses the
three-party coordination and failure handling.
- Mid: an order state machine, geospatial courier matching (ride-hailing), real-time tracking, and
recognizes payment/restaurant/courier as separate services.
- Senior: frames it as a three-sided marketplace with a saga-coordinated fulfillment workflow and
compensations, adds predictive dispatch timed to food readiness plus batching, plans explicitly for
predictable peak spikes with autoscaling and graceful degradation, and ensures reliable acknowledged order
delivery to restaurants — reusing the ride-hailing geospatial core rather than re-deriving it.
Further reading