Guide · System design
Design a Food Delivery App: Orders, Riders and Payments
30 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Food delivery system design is the plan for moving one order between four parties: customer, restaurant, rider and payment gateway. The gateway is the company that actually moves the money.
- Orders and payments live in a transactional database, where a group of changes succeeds or fails together. An idempotency key, one random ID per checkout, makes a retry return the first order instead of creating a second.
- An order state machine, a fixed list of states plus the moves allowed between them, refuses impossible steps such as delivering an order nobody paid for.
- Riders are found with a geo search, which lists points within a distance of the restaurant. Each is then scored by when they would arrive compared with when the food will be ready.
- Beginners can stop after the dinner-rush section. Engineers then get code tested on PostgreSQL 16 and Redis 7. A row lock, which makes a second change to one order wait its turn, settled 300 races between Accept and a timeout. Without it, 971 of 1,000 went wrong in one run.
What goes wrong when a food delivery app meets the dinner rush?
Every weak spot fails on the same evening, because the rush pushes orders, payments, riders and location updates through the system at once. Food delivery system design is about closing those weak spots before the rush arrives.
Picture a common setup. A food delivery app in Visakhapatnam links about 1,200 restaurants, 3,000 riders and their customers, and handles about 40,000 orders on a normal day. The setup is made up, but the problems are real.
On the first rainy Friday of the monsoon, nobody wanted to cook. Between 8 and 9 pm, orders jumped to 9,000 in one hour. By 10 pm the support team had 900 complaints, and they fell into four piles.
- Charged twice. The app timed out on a slow network, the customer tapped Place order again, and two orders went through.
- Paid but never cooked. Restaurant tablets lost Wi-Fi, and 140 paid orders sat waiting for kitchens that never saw them.
- Riders waiting, food going cold. Riders reached some restaurants 20 minutes early, while finished food sat unclaimed at others.
- Frozen tracking. Rider location updates went into the same database as orders, and both slowed to a crawl.
Each pile points to a missing piece of design. This guide rebuilds the app one piece at a time: orders, payments, rider assignment, live tracking and a plan for the rush.
What is food delivery system design?
Food delivery system design is the plan for software that takes a food order, collects the money, gets it cooked, assigns a rider and tracks the trip. It is also a classic system design interview question.
Three things make it harder than a shopping app. Food goes cold in minutes. Four parties act at once, each on their own phone or tablet. And demand piles into two short peaks, lunch and dinner.
A picture to keep in mind: the Ganesh murti workshop
Think of a workshop that makes Ganesh idols, called murtis, before the festival. It does the same jobs as a delivery app, only over weeks instead of minutes. Here is how the parts map.
- A family booking an idol and paying an advance is a customer placing an order and paying.
- The board on the wall, where each idol's number moves from booked to shaped to painted to sent, is the order state machine.
- The receipt number in the owner's book, checked before taking any second advance, is the idempotency key.
- Loaded tempos leaving for different neighbourhoods on the last evening are riders, and putting idols for one street on one tempo is batching.
Where the picture breaks: one owner updates the board, one idol at a time. In the app, thousands of events arrive at once from different machines, and two can try to move the same order in the same instant. Much of the engineer half of this guide is about that collision.
What are the requirements of a food delivery app?
A food delivery app must let customers order and pay, let restaurants accept and cook, and let riders pick up and deliver. It must stay fast and correct during the dinner peak, when it matters most.
Functional requirements
Functional requirements are what the system does. For this app, the team writes down five.
- Customers find nearby restaurants, read menus, place an order and pay.
- Restaurants receive each order, accept or reject it, and mark it ready.
- The system assigns a rider, who picks up the food and delivers it.
- Customers see the order status, the rider on a map and an ETA (estimated time of arrival).
- Rejected or failed orders get their money back without anyone filing a ticket.
Non-functional requirements
Non-functional requirements are how well the system does those things. These four decide most of the design.
- Correct money: never charge twice, and never lose an order that was paid for.
- Quick checkout: placing an order should take about a second.
- Fresh tracking: the rider's dot on the map should be at most a few seconds old.
- Available at peak: the dinner rush is when the app earns the most.
Rough numbers for the dinner peak
A peak hour of 9,000 orders is 2.5 orders a second. The team plans for 5 a second, to cover spikes inside that hour.
Location is the bigger load. Say 2,500 riders are on the road at peak, each sending a GPS position every 5 seconds. That is 500 writes a second, all evening long, 100 times the 5 orders a second the team plans for.
Reads are bigger still, because customers browse menus far more often than they order. So orders, locations and browsing each get their own path.
How big is 5 orders a second for one database? On a shared 2-vCPU test machine, PostgreSQL 16's built-in benchmark tool, pgbench, ran 517 to 954 transactions a second across six 15-second runs. Each pgbench transaction does three updates, one read and one insert, roughly the size of one order step.
An order makes about five state changes in its life, from Placed to Delivered. So 5 orders a second means roughly 25 to 30 small transactions a second, far below what the test machine managed.
You can check your own machine. Run pgbench -i -s 10 to create the test tables, then pgbench -c 8 -j 4 -T 15. Read the line that starts tps =, which means transactions per second. Your number depends on your hardware.
What services does a food delivery app need?
A food delivery app needs a handful of services, each owning one job: orders, payments, restaurants, dispatch, location and notifications. They call each other for requests, and share news through an event stream.
A service is a separately deployed program with its own data. Dispatch is the job of choosing which rider carries which order. An event stream, such as Kafka, is a durable log where services publish facts like order paid, and other services read them.
Follow an order from the customer app through the order service in the diagram. Notice that rider locations go to their own store and never touch the orders database.
The app's main services
Drag to exploreOrders and money take the left path. Rider locations take the right path, kept apart on purpose.
Diagram connections:
- Customer app to Order service (Place order)
- Order service to Payment service (Take payment)
- Payment service to Payment gateway (Charge, then await webhook)
- Order service to Kafka event stream (Order events)
- Kafka event stream to Restaurant app (New order)
- Restaurant app to Order service (Accept or reject)
- Kafka event stream to Dispatch service (Order accepted)
- Rider app to Redis location store (GPS pings)
- Dispatch service to Redis location store (Find nearby riders)
- Dispatch service to Rider app (Offer the trip)
- Redis location store to Customer app (Latest position, pushed live)
- Customer app: Browses, orders and tracks
- Order service: Owns order states, stored in PostgreSQL
- Payment service: Keeps a ledger of every rupee
- Payment gateway: Handles cards, UPI and net banking
- Kafka event stream: Events keyed by order id stay in order
- Restaurant app: Accepts orders, marks food ready
- Dispatch service: Picks a rider for each order
- Redis location store: Every rider's position, plus a geo set of free riders
- Rider app: Sends GPS every 5 seconds
Kafka groups events into topics, named streams such as order events, and splits each topic into partitions. It keeps events in order only within one partition. Its documentation says events with the same key go to the same partition, so keying every event by order id keeps each order's events in sequence.
Each kind of data goes where it fits best. The table shows the team's choices.
| Data | Store | Why this store |
|---|---|---|
| Orders and their states | PostgreSQL | Transactions and row locks keep states correct |
| Payments ledger | PostgreSQL, append-only table | Every rupee in and out is recorded, never edited |
| Menus and restaurant pages | Redis cache in front of PostgreSQL | Read far more often than they change |
| Restaurant search | A search index such as Elasticsearch | Built for fast text search over dish names and cuisines |
| Live rider positions | Redis: a geo set of all on-shift riders and one of free riders | Overwritten every few seconds; tracking reads the first, dispatch searches the second |
| Location history | Kafka, then object storage | Cheap to keep for disputes and ETA training |
| Events between services | Kafka topics | Durable news that services can read again |
At 40,000 orders a day, one PostgreSQL server for orders, with a couple of read replicas (copies that serve reads), has room to spare. When more cities join, splitting orders by city is a natural next step.
How does a food order move from cart to doorstep?
Each order moves through a fixed set of states, such as placed, paid, accepted, picked up and delivered, and only certain moves between them are allowed. Writing those rules down is what stops half-finished orders.
This is the workshop board. Nobody paints an idol that was never shaped, and nobody loads one while the paint is wet. In software, the board is a state machine, and any move not on it is refused.
Start at Placed in the diagram, and look at the question box. That is where the 140 stuck orders went wrong.
The life of one order
Drag to exploreEvery arrow is an allowed move. The order service refuses any move that is not drawn here.
Diagram connections:
- Placed to Paid (Payment confirmed)
- Placed to Cancelled (Payment failed)
- Paid to Restaurant accepts?
- Restaurant accepts? to Accepted (Yes)
- Restaurant accepts? to Rejected and refunded (No, or no answer)
- Accepted to Picked up
- Picked up to Delivered
- Placed: Order saved, payment started
- Cancelled: Payment failed or was abandoned
- Paid: Gateway confirmed the money
- Restaurant accepts?: Must answer within 5 minutes
- Rejected and refunded: Refund starts automatically
- Accepted: Kitchen cooking, dispatch finding a rider
- Picked up: Rider has the food
- Delivered: Order closed, rider paid for the trip
The fix for the stuck orders is the arrow marked no answer. If a restaurant stays silent for 5 minutes, the order moves to rejected by itself and the refund starts. The operations team also gets an alert to phone the restaurant.
That refund is an undo step. A chain of steps across services, where each step has its own undo for when a later step fails, is called a saga. The engineer half shows how the order service runs it safely.
How does a food delivery app take payment without charging twice?
It gives every checkout a unique idempotency key, so a retried request returns the first order instead of creating a new one. Then it marks an order paid only on the payment gateway's verified confirmation, never on the app's word.
Back at the workshop, the owner writes one receipt number in his book and takes the advance once. If a father returns next day, unsure whether he paid, the owner checks that number. He never takes a second advance.
An idempotency key is that receipt number. The customer app creates a random key when the customer reaches checkout, and sends it with every try. The server stores each key next to the order it created.
Customer taps Place order
The app sends the cart and the idempotency key. If the network times out, the app retries with the same key, never a new one.
Order service checks the key
A new key creates a Placed order. A key it has already seen returns the existing order, so Friday's double tap makes no second order.
Payment service asks the gateway
It creates a payment for the exact amount, tagged with the order id, so every payment matches one order.
Customer pays
The customer approves in a UPI app, India's instant bank-to-bank payment system, or on the gateway's own card page. The app never handles full card numbers, because the gateway's page collects them.
Gateway sends a webhook
A webhook is an HTTP call from the gateway to the app's server, saying the payment succeeded. The payment service checks its signature, then moves the order to Paid.
Nightly reconciliation
A job compares the gateway's settlement report, its daily list of money paid out, with the app's ledger. Any mismatch goes to the operations team.
Webhooks can arrive more than once. Razorpay's webhook documentation, for example, says the same event may reach your server more than once and may arrive out of order. So the step that moves an order to Paid must be safe to run twice.
The pattern has a written description too. The IETF, the body that writes internet standards, drafted an Idempotency-Key header that recommends a UUID, a standard long random ID. A server that sees a repeated key should return the earlier result.
That draft is not a standard. As of 19 October 2026 its latest version is still 07, dated 15 October 2025, and it expired on 18 April 2026. The idea works the same whatever field carries the key.
Refunds follow the same rule in reverse. Record each refund request in the ledger under a key built from the order id, and check that record before any retry, so a retry cannot refund twice.
How does a food delivery app assign a rider to an order?
It finds free riders near the restaurant, scores each by how well their arrival matches the food's ready time, and offers the trip to the best one. If that rider declines, the offer moves to the next.
Step 1: find riders nearby
The app keeps every free rider's latest position in Redis, an in-memory data store. Redis keeps geo data in a sorted set, a collection of members kept in order by a number. Its GEOSEARCH command, available since Redis 6.2, returns members inside a radius, nearest first if you ask.
For each new order, dispatch asks for up to 20 free riders within 3 km of the restaurant. How such indexes split the map into cells has its own guide, on geohash and quadtrees.
Step 2: score each rider
The best rider is often not the nearest. Say the kitchen needs 15 more minutes for a biryani. Rider A is 1 km away, would arrive in about 3 minutes and then wait 12. Rider B is 4 km away and arrives about 2 minutes early.
Sending B keeps A free for food that is ready now. The score adds up who waits: minutes the rider idles at the counter, plus minutes the food sits, counted double because cold food loses customers. The engineer half runs this score in code.
Research on meal delivery uses the same idea. A 2018 study of the meal delivery routing problem, written by Georgia Tech researchers with two Grubhub engineers, states that pickup cannot happen before the order's ready time.
Its algorithm runs every few minutes, 5 by default in its experiments. Each run solves a fresh matching problem and fixes only each courier's next pickup and delivery.
Step 3: batch orders that fit together
Batching means giving one rider two or more orders in a single trip. The study calls these bundles: orders from the same restaurant with several drop-offs, where the bundle is ready only when its last order is ready.
The study favours bigger bundles when orders outnumber couriers, and single orders when couriers outnumber orders. The app follows that with a guard of its own. It batches only orders from one restaurant, ready within 5 minutes of each other, adding at most 8 minutes for the first customer.
Here is the dispatch flow for one order. Notice that batching is checked first, because joining a trip already planned is the cheapest option.
How dispatch finds a rider for one order
Drag to exploreBatching comes first. A declined offer goes back to scoring, which picks the next rider on the list.
Diagram connections:
- Order accepted to Can it join a batch?
- Can it join a batch? to Add to that rider's trip (Yes)
- Can it join a batch? to Find free riders nearby (No)
- Find free riders nearby to Score each rider
- Score each rider to Offer to the best rider
- Offer to the best rider to Rider accepts?
- Rider accepts? to Rider assigned (Yes)
- Rider accepts? to Score each rider (No, try the next rider)
- Add to that rider's trip to Rider assigned
- Order accepted: Kitchen says ready in 15 minutes
- Can it join a batch?: Same restaurant, ready within 5 minutes
- Add to that rider's trip: Adds at most 8 minutes to the first order
- Find free riders nearby: Redis GEOSEARCH, 3 km, up to 20 riders
- Score each rider: Arrival time against food ready time
- Offer to the best rider: Rider gets 30 seconds to accept
- Rider accepts?: A decline and silence both count as no
- Rider assigned: Order service and customer are told
Riders can turn offers down. The 2018 study lists couriers rejecting offers as a real feature of dispatch, but its model assumes they always accept. A real design has to handle declines itself, as the loop above does.
How does live order tracking work in a food delivery app?
The rider app sends its GPS position every few seconds to a location service, which keeps only the latest point in fast memory and pushes it to the customer's screen. The arrival time beside it comes from a separate ETA service.
Friday's frozen map came from writing every ping into the orders database, crowding out the writes that mattered. The team splits the location path into three parts.
- Latest position: each ping overwrites the rider's entry in a Redis geo set of all on-shift riders, which tracking reads. While the rider is free, the ping also updates the free-rider set that dispatch searches.
- History: each ping also goes onto a Kafka topic. A consumer saves it to object storage, a cheap store for large files, for disputes such as a rider never arriving.
- Live view: the tracking service pushes a fresh position to the customer's screen over a WebSocket, a connection that stays open so the server can send updates.
The ETA service takes kitchen progress, the rider's position and traffic, then predicts minutes to the door. The model inside it has its own guide, on delivery time prediction. Here it is simply a service the tracking screen calls.
How does a food delivery app handle the dinner rush and failures?
It handles the rush by promising less: hiding restaurants it cannot serve, batching more and paying riders more where they are short. It handles failures by giving every stuck step an automatic way out.
The key idea is serviceability: can we deliver from this restaurant to this customer right now? The app recomputes it for each area every minute, from free riders nearby and how busy each kitchen is.
- Slow down busy kitchens. A restaurant with 25 open orders first shows a longer ETA, then drops out of search until it catches up.
- Shrink the delivery radius when riders are short, so each trip ends sooner.
- Batch more, as the dispatch section showed.
- Raise trip pay in short areas, which can pull riders in from quieter parts of the city.
- Put a short queue in front of checkout, so a spike waits a few seconds instead of crashing the order service.
For each failure, the system has an automatic response and someone who hears about it.
| Failure | What the system does | Who hears about it |
|---|---|---|
| Restaurant silent for 5 minutes | Rejects the order and starts the refund | Customer at once; operations phones the restaurant |
| No webhook 2 minutes after payment | Asks the gateway for the payment status directly | Nobody, unless that check fails too |
| No rider accepts in 10 minutes | Widens the search radius and raises trip pay | Operations dashboard marks the order amber |
| Rider's phone goes offline mid-trip | Keeps the last known position and pauses the ETA | Support calls the rider |
| The relay that forwards events to Kafka is down | Events wait in the outbox, a table of events not yet sent, until it recovers | The on-call engineer gets an alert |
In this made-up setup, the next rainy Friday brought 10,500 orders in the peak hour and 120 complaints, mostly about late food. None was about a double charge.
How do you stop two events from moving one order at once?
Lock the order's row before checking its state, so the second event waits and then sees the first one's result. Without the lock, two events can both pass the check and both win.
Here is the race. At 5 minutes, the timeout job tries to reject a paid order. In the same instant, the restaurant taps Accept. Both moves are allowed from Paid, so a check that reads the state first passes for both.
First, the two tables the code uses. The orders table holds the idempotency key under a unique constraint, a rule that no two rows share a value. The checkout section relies on it.
CREATE TABLE orders (
id bigserial PRIMARY KEY,
idem_key uuid NOT NULL UNIQUE,
cart text NOT NULL,
state text NOT NULL
);
CREATE TABLE outbox (
id bigserial PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders (id),
event text NOT NULL,
sent boolean NOT NULL DEFAULT false
);Here is the heart of the order service, run on PostgreSQL 16.15 with psycopg2 2.9.13, a common Python database driver. ALLOWED is the state diagram, written as data. The outbox line is explained two sections on.
ALLOWED = {
"PLACED": {"PAID", "CANCELLED"},
"PAID": {"ACCEPTED", "REJECTED"},
"ACCEPTED": {"PICKED_UP"},
"PICKED_UP": {"DELIVERED"},
}
def move(conn, order_id, new_state):
with conn, conn.cursor() as cur: # one transaction
cur.execute("SELECT state FROM orders WHERE id = %s FOR UPDATE", (order_id,))
(state,) = cur.fetchone() # a second move on this order waits here
if state == new_state:
return False # repeated event: change nothing
if new_state not in ALLOWED.get(state, set()):
raise ValueError(f"{state} to {new_state} is not allowed")
cur.execute("UPDATE orders SET state = %s WHERE id = %s", (new_state, order_id))
cur.execute("INSERT INTO outbox (order_id, event) VALUES (%s, %s)", (order_id, new_state))
return TrueSELECT ... FOR UPDATE reads the row and locks it until the transaction ends. PostgreSQL's documentation says other transactions that try to update or lock that row are blocked until then. In psycopg2, leaving the with block commits, and an exception rolls back.
With the lock: 0 of 300. Each order ended in exactly one state with exactly one accept-or-reject event, sometimes Accepted and sometimes Rejected, depending on who reached the row first.
Without the lock, in a version that reads the state and then writes, 971 of 1,000 races published both events in one run. Counts like these vary with timing and hardware. With a 20 millisecond pause between read and write, it was 100 of 100.
In real life that can mean the kitchen cooks an order the customer was refunded for. The unlocked UPDATE still waits for the first writer, but its WHERE clause checks only the order id, so it overwrites the first result.
What just happened? The second mover waited at the locked row. In a separate test it was still waiting after 0.5 seconds, while the first transaction stayed open. When the first committed, the second read the new state, found its move not allowed, and changed nothing. PostgreSQL's locking docs say a waiting SELECT FOR UPDATE then locks and returns the updated row.
Two more checks matter for webhooks that arrive twice. An exact repeat of the current state returns False and changes nothing. An older repeat, such as a second Paid webhook after the restaurant accepted, raises an error and leaves the order Accepted.
So the caller should log that error and reply success to the gateway, or it will keep retrying. Razorpay, for one, sends an event id that is unique per event, so a repeat delivery can be dropped before it reaches the state machine.
How do you make checkout idempotent in PostgreSQL?
Put a unique constraint on the idempotency key and insert with ON CONFLICT DO NOTHING, an option that skips the insert when the key already exists. The first try creates the order; any retry finds the key taken and returns the order already saved.
You can see the core idea in a few lines of SQL, the language databases are queried in. Run this in psql, PostgreSQL's command-line client, on any recent version.
CREATE TABLE checkout (idem_key uuid PRIMARY KEY, total_rupees int);
INSERT INTO checkout VALUES ('3f2b8c1e-6d4a-4f1b-9a7e-2c5d8e9f0a1b', 320)
ON CONFLICT (idem_key) DO NOTHING;
INSERT INTO checkout VALUES ('3f2b8c1e-6d4a-4f1b-9a7e-2c5d8e9f0a1b', 320)
ON CONFLICT (idem_key) DO NOTHING;
SELECT count(*) FROM checkout;Expected output on PostgreSQL 16.15:
CREATE TABLE
INSERT 0 1
INSERT 0 0
count
-------
1
(1 row)INSERT 0 1 means one row was added; INSERT 0 0 means the second insert added nothing. The count of 1 is the proof: two tries, one order.
The full checkout also checks that a retry carries the same cart. The IETF draft says a key must not be reused with a different request, and suggests the HTTP status 422 for that mistake.
import json
def place_order(conn, idem_key, cart):
cart_json = json.dumps(cart, sort_keys=True)
with conn, conn.cursor() as cur: # one transaction
cur.execute(
"INSERT INTO orders (idem_key, cart, state) VALUES (%s, %s, 'PLACED') "
"ON CONFLICT (idem_key) DO NOTHING RETURNING id",
(idem_key, cart_json))
row = cur.fetchone()
if row: # first try: a new order
cur.execute("INSERT INTO outbox (order_id, event) VALUES (%s, 'PLACED')", row)
return row[0]
cur.execute("SELECT id, cart FROM orders WHERE idem_key = %s", (idem_key,))
order_id, saved_cart = cur.fetchone()
if saved_cart != cart_json:
raise ValueError("same key, different cart")
return order_id # a retry: the same order againRETURNING id hands back a row only when the insert really happened, as PostgreSQL's INSERT documentation says. So an empty result means this is a retry, and the code reads the saved order instead.
In the tests, the same key sent twice gave the same order id, one order row and one outbox event. The same key with a different cart raised the error.
What about two taps arriving at the same moment, on two connections? In 300 such races, every pair got the same order back and no key created two orders.
The reason is in PostgreSQL's documentation on unique indexes. If another transaction has inserted the same key but not yet committed, the second insert waits to see whether it commits. Then DO NOTHING applies. At PostgreSQL's default isolation level, Read Committed, each new statement sees rows committed before it starts, so the follow-up SELECT finds the saved order.
The IETF draft suggests a different answer while the first request is still running: reply 409, meaning conflict, and let the client retry. Waiting works here because the transaction is short. The slow gateway call happens after it, not inside it.
Why does the order service write events to an outbox table?
Because saving a state and publishing its event to Kafka are two separate systems, and a crash between them loses the event. The outbox puts the event in the same database transaction as the state change.
An outbox is a table, in the same database as the orders, where the service writes each event it wants to publish. A separate relay program reads that table and sends the events to Kafka.
Without it, the code would save the state, then publish to Kafka. A crash between the two leaves a paid order no restaurant ever hears about.
The test raised an error after the state update but before the outbox insert. The transaction rolled back: the order stayed Placed and no Paid event was recorded. State and event commit together or not at all.
Chris Richardson's catalogue of microservice patterns describes this outbox. It says messages are sent if and only if the database transaction commits. It also warns that the relay might publish a message more than once.
So every reader of these events must be idempotent, meaning safe to run twice. The state machine above already is, for exact repeats.
Why an order is a saga
Richardson's catalogue defines a saga as a sequence of local transactions, where completed steps are undone by compensating transactions when a later step fails. Placing an order is one: create the order, take payment, get the restaurant to accept, assign a rider.
The catalogue names two styles. In choreography, each service reacts to the others' events. In orchestration, one coordinator tells each service what to do next.
This team picks orchestration, with the order service in charge, so anyone can read the whole flow in one place. In the order diagram, the rejected and refunded box is the compensating step for payment.
How does the rider score work in code?
It turns minutes of waiting into one number and picks the lowest. Rider idle time counts once, food waiting time counts twice, and any rider already carrying two orders is skipped. Candidates normally come from the free-rider set. If too few accept, dispatch also searches the on-shift set, so riders already carrying orders can appear.
from dataclasses import dataclass
SPEED_KMPH = 18 # average bike speed in city traffic (made up)
@dataclass
class Rider:
name: str
distance_km: float # road distance to the restaurant
active_orders: int # orders already on the bike
def minutes_to_reach(distance_km):
return distance_km / SPEED_KMPH * 60
def score(rider, food_ready_in_min):
arrive = minutes_to_reach(rider.distance_km)
rider_waits = max(0, food_ready_in_min - arrive) # rider idles at the counter
food_waits = max(0, arrive - food_ready_in_min) # food goes cold
return rider_waits + 2 * food_waits # lower is better
def pick_rider(candidates, food_ready_in_min):
free = [r for r in candidates if r.active_orders < 2]
if not free:
return None # widen the search instead
return min(free, key=lambda r: score(r, food_ready_in_min))
riders = [Rider("A", 1.0, 0), Rider("B", 4.0, 0), Rider("C", 6.0, 1), Rider("D", 3.5, 2)]
for r in riders:
print(r.name, round(minutes_to_reach(r.distance_km), 1), "min away, score",
round(score(r, 15), 1))
print("Pick:", pick_rider(riders, 15).name)Output on Python 3.13:
A 3.3 min away, score 11.7
B 13.3 min away, score 1.7
C 20.0 min away, score 10.0
D 11.7 min away, score 3.3
Pick: BWhat just happened? Rider A, 1 km away, would arrive in 3.3 minutes and idle 11.7, so A scores 11.7. Rider B arrives at 13.3 minutes and idles 1.7, so B wins. Rider D scores well but already has two orders, so the filter skips D.
Rider C arrives 5 minutes after the food is ready, and those 5 minutes count double, for a score of 10. The weight of 2 is a team choice, not a law. Tune it against real complaints about cold food.
The 18 km an hour speed is a made-up stand-in. In a real app, the routing service's travel time replaces minutes_to_reach for the top few riders.
How can you check the Redis geo search for riders yourself?
Add a few riders with GEOADD, then ask GEOSEARCH for those within 3 km of a restaurant. The reply lists them nearest first, with distances in kilometres.
These commands use real coordinates in Visakhapatnam, longitude first, with the restaurant at 83.3000, 17.7200. Rider r4 is about 5 km west, outside the circle. Run them with redis-cli against a test server.
$ redis-cli GEOADD riders:free 83.3000 17.7254 r1 83.3142 17.7200 r2 83.3000 17.6948 r3 83.2530 17.7200 r4
(integer) 4
$ redis-cli GEOSEARCH riders:free FROMLONLAT 83.3000 17.7200 BYRADIUS 3 km ASC COUNT 20 WITHDIST
1) 1) "r1"
2) "0.6006"
2) 1) "r2"
2) "1.5045"
3) 1) "r3"
2) "2.8028"
$ redis-cli GEOADD riders:free 83.3010 17.7230 r1
(integer) 0
$ redis-cli ZREM riders:free r2
(integer) 1
$ redis-cli GEOADD riders:onshift 83.3150 17.7210 r2
(integer) 1
$ redis-cli GEOPOS riders:onshift r2
1) 1) "83.3149990439414978"
2) "17.72099984336814771"
$ redis-cli GEOSEARCH riders:free FROMLONLAT 83.3000 17.7200 BYRADIUS 3 km ASC COUNT 20 WITHDIST
1) 1) "r1"
2) "0.3500"
2) 1) "r3"
2) "2.8028"
$ redis-cli TYPE riders:free
zsetWhat just happened? The first search returned r1 at 0.6006 km, r2 and r3, but not r4. GEOADD on an existing rider returned 0, because no new member was added, yet r1's position moved to 0.35 km.
ZREM, the ordinary sorted-set delete, removed r2 when that rider took an order. Then r2, now busy, sent a ping. It went only to riders:onshift, the set of every rider at work, and GEOPOS read it back, very close to the sent point, for tracking.
The last search no longer returns r2. TYPE shows zset, because a geo set is a sorted set underneath. These outputs are from Redis 7.0.15.
Keep only free riders in riders:free. Remove a rider with ZREM the moment they accept, and stop their pings writing there until they are free again. A search can still run just before that removal, so dispatch re-checks the rider's status before confirming an accept.
How do you explain food delivery system design in a system design interview?
Start from the life of one order, not from a list of boxes. Clarify the scope, estimate the load, then walk one order from cart to doorstep, adding services only as the story needs them.
Clarify the scope
Ask which parts matter most: ordering, payments, dispatch or tracking. Agree to skip restaurant onboarding and reviews.
Estimate the load
Work out orders and location pings per second at peak. Pings are the bigger stream: 500 a second here, 100 times the planned order rate.
Draw the order states
Show the state machine and its undo steps, such as refunds, before you draw any services.
Add services along the flow
Order, payment, restaurant, dispatch, location and notifications. Explain the event stream and why the outbox makes it safe.
Go deep on one hard part
Usually dispatch: geo search, scoring against food ready time, batching rules and offer timeouts. Or the race between accept and timeout, and the row lock that settles it.
Close with peaks and failures
Cover serviceability, rider pay, idempotent payments and silent restaurants.
Questions people ask
- Which database should a food delivery app use?
- Use a relational database such as PostgreSQL for orders and payments, because they need transactions and row locks. Use an in-memory store such as Redis for live rider positions, a search index for restaurant search, and Kafka between services. One database rarely suits every kind of data, and the location stream alone can be 100 times the order rate.
- How often should a rider app send its location?
- Every few seconds while moving is a common starting point, with fewer updates while standing still. The setup in this guide uses 5 seconds. Faster updates cost battery, mobile data and server writes, while slower ones make the map jump. Measure what customers notice, then tune.
- What is order batching in food delivery?
- Order batching means one rider carries two or more orders in a single trip. It saves rider time and helps most at peak, when orders outnumber riders. The cost is a short delay for the first customer, so good systems cap the extra minutes a batch may add.
- How do food delivery apps avoid charging customers twice?
- They send an idempotency key with every checkout request. The server stores the key under a unique constraint with the order it created, so a retry returns that order instead of making a new one. Orders are marked paid only after the gateway's verified confirmation, and nightly reconciliation catches mismatches that slip through.
- What happens if a restaurant rejects an order after payment?
- The order moves to a rejected state, and that move starts an automatic refund. In saga terms, the refund is the compensating step for the payment. The customer is told at once, and a good app suggests similar restaurants nearby so the evening is not lost.
- How is food delivery system design different from ride-hailing design?
- Ride-hailing matches a driver to a passenger who is ready now. Food delivery adds a kitchen in the middle, so dispatch must time each arrival to when the food will be ready. It also adds batching, restaurant acceptance and refunds for orders that were paid but never cooked.
Sources and further reading
- The Meal Delivery Routing Problem (Reyes, Erera, Savelsbergh, Sahasrabudhe and O'Neil, 2018), Optimization Online PDF
- Pattern: Saga (Chris Richardson, microservices.io)
- Pattern: Transactional outbox (Chris Richardson, microservices.io)
- Redis docs: GEOSEARCH command
- IETF datatracker: The Idempotency-Key HTTP Header Field (draft-ietf-httpapi-idempotency-key-header, status page)
- IETF draft text, version 07 (sections 2.2, 2.6 and 2.7)
- PostgreSQL docs: Explicit locking, row-level locks (FOR UPDATE)
- PostgreSQL docs: Transaction isolation (Read Committed is the default)
- PostgreSQL docs: INSERT, ON CONFLICT and RETURNING
- PostgreSQL docs: Index uniqueness checks (waiting on an uncommitted insert)
- PostgreSQL docs: pgbench (default TPC-B-like script, tps line)
- Psycopg 2.9.13 docs: Basic module usage (with conn commits or rolls back)
- Apache Kafka: Introduction (partitions, ordering by key, retention)
- Razorpay docs: Validate and test webhooks (duplicate and out-of-order events)