Food delivery like Foodpanda0/66 done
Part 3 · System design · Real products

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

Non functional

Questions to ask

2. Estimates

orders500k a day, but 60% in two 2 hour peaks: about 20 per second at peak
restaurants50k, each updating menus and opening hours
status readsevery 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

  1. Customer appPOST /orders with an idempotency key
  2. Order servicecopies item prices onto the order, saves it as placed
  3. Paymentsauthorise the card
  4. Restaurant tabletnew order pushed; accept or reject

Cook and deliver

  1. Restaurantaccepted, then preparing
  2. Dispatch (per city queue)sends a rider at ready time minus travel time
  3. Rider appready, picked up, delivered: each a checked state change
  4. 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 timeAuto cancel after a few minutes and refund, before anyone cooks.
No rider availableKeep trying in waves with a wider radius; tell the customer the new ETA; offer to cancel.
Customer pays twice on a bad networkIdempotency key on POST /orders, so a retry returns the first order.

8. Trade-offs

Wrap up

Say out loud: The order is a state machine, and every move is a conditional update plus an event, so nothing skips a step. Prices are copied onto the order. Riders are dispatched at ready time minus travel time, so food doesn't wait and riders don't either. Menus are cached, ordering is a small write, and dispatch runs per city in a queue.