Food delivery like Foodpanda
Customers order from restaurants, the restaurant accepts and cooks, a rider picks it up and delivers it. Everyone sees the order's status live.
In plain words
You order kacchi biryani at 1 pm. The order is placed, then the restaurant accepts it, then the kitchen starts cooking. At this point you try to cancel, and the app says no: the rice is already in the pot. Every order walks through the same fixed steps, and the system refuses any jump that doesn't make sense, like delivered before picked up.
The biryani will be ready in 20 minutes and the nearest rider is 8 minutes away. If the rider is sent now, he stands in the restaurant for 12 minutes. So the system waits and sends him at minute 12. He walks in just as the box is sealed.
Examples
- Foodpanda, Uber Eats and DoorDash all model an order as a state machine.
- The price of each item is copied onto your order, so a menu change a minute later doesn't change your bill.
- Lunch and dinner peaks are 60% of the day's orders: menus come from cache, ordering is a tiny write.
- Pressing Pay twice on a bad network sends the same idempotency key, so you get one order, not two.
1. Requirements
Functional
- Customers order from a restaurant and pay.
- The restaurant accepts and cooks; a rider picks up and delivers.
- Everyone sees the status live.
- Cancel is allowed only before cooking starts.
Non functional
- Survive lunch and dinner peaks (60% of orders).
- No step can be skipped or reversed.
- Riders shouldn't wait at restaurants, food shouldn't wait for riders.
- One order per tap, even on bad networks.
Questions to ask
- How many orders a day, and how peaky is lunch and dinner?
- Can an order be cancelled after cooking starts?
- Do riders carry more than one order at a time?
- Is the menu and search in scope, or only ordering and delivery?
2. Estimates
| orders | 500k a day, but 60% in two 2 hour peaks: about 20 per second at peak |
| restaurants | 50k, each updating menus and opening hours |
| status reads | every open app polls or listens: about 100 reads per order |
3. API
POST /orders { restaurantId, items, address } { orderId, total, eta }
POST /restaurant/orders/{id}/status { to } 200, or 409 not allowed
POST /rider/orders/{id}/status { to } 200, or 409
WS /orders/{id}/live status, rider position, ETA
4. Data model
orders(id, customer_id, restaurant_id, rider_id, status, total, version) order_items(order_id, item_id, qty, price_at_order) order_events(order_id, status, at) append only, feeds the live view and outbox menus: Postgres + a search index, cached per restaurant
5. High level design
Follow one request at a time, top to bottom.
Place an order
- Customer appPOST /orders with an idempotency key
- Order servicecopies item prices onto the order, saves it as placed
- Paymentsauthorise the card
- Restaurant tabletnew order pushed; accept or reject
Cook and deliver
- Restaurantaccepted, then preparing
- Dispatch (per city queue)sends a rider at ready time minus travel time
- Rider appready, picked up, delivered: each a checked state change
- Order eventslive status for the customer, outbox to payouts
6. Deep dives
How do you keep the order state correct?
A state machine: placed, accepted, preparing, ready, picked up, delivered, or cancelled. Each move is checked against the allowed list and written as a conditional update plus an event. A rider can't mark picked up before the food is ready.
When do you send the rider?
Not at order time, or the rider waits at the restaurant. Send at ready time minus the rider's travel time, using the restaurant's prep time estimate.
How is the price fixed?
Store the item price at order time on the order line. If the menu changes a minute later, the order doesn't.
How do you survive the lunch peak?
Menus and restaurant pages come from cache. Ordering is a small write. Rider assignment runs in a queue per city so a slow city can't block others.
See the core idea run
Core logic (JavaScript, tested)
// An order moves through fixed states. Any other jump is a bug, so it is refused.
const NEXT = {
placed: ["accepted", "cancelled"],
accepted: ["preparing", "cancelled"],
preparing: ["ready"],
ready: ["picked_up"],
picked_up: ["delivered"],
delivered: [],
cancelled: []
};
function advance(order, to) {
if (!NEXT[order.status].includes(to)) return { ok: false, reason: `${order.status} cannot go to ${to}` };
order.history.push(to);
order.status = to;
return { ok: true };
}
// Send the rider so they arrive when the food is ready, not before (waiting) or long after (cold food).
const dispatchAt = (readyAt, riderMinutesAway) => Math.max(0, readyAt - riderMinutesAway);
7. Failures
| Restaurant doesn't accept in time | Auto cancel after a few minutes and refund, before anyone cooks. |
| No rider available | Keep trying in waves with a wider radius; tell the customer the new ETA; offer to cancel. |
| Customer pays twice on a bad network | Idempotency key on POST /orders, so a retry returns the first order. |
8. Trade-offs
- Batching two orders per rider saves cost but makes one customer wait longer.
- Polling is simple; WebSockets give smoother live updates but need connection servers.