“Design Swiggy” looks like a restaurant catalogue with a Buy button. Browse restaurants, tap dishes into a cart, pay, wait for food. The interviewer lets that picture stand until they ask the question that breaks it: a single order is a contract between three independent parties who do not trust each other and are never all online at once - a customer who has already paid, a restaurant that has to accept and cook, and a delivery partner who has to be found, sent to the restaurant, and routed to the door. None of them is under your control. The restaurant can reject. The partner can cancel at the pickup. The customer can change the address mid-cook. And the whole thing has a hard 40-minute deadline before the food is cold and the order is a refund.
That is the spine of this system: coordinating a long-running, multi-party transaction across three actors with independent failure modes, while a delivery partner physically moves the goods in real time. Everything hard hangs off it - how you model an order that lives for 45 minutes and passes through six owners, how you keep a menu of millions of dishes fresh and searchable, how you assign a partner to a restaurant without double-booking them or letting food go cold, and how you show the customer a dot moving on a map the whole time. Ride-hailing (Uber) is a two-party instantaneous match; food delivery is a three-party staged transaction with a cooking step in the middle, and that middle step is what makes it its own problem. Let me build it properly.
Functional Requirements (FR)
In scope:
- Restaurant discovery. A customer opens the app and sees restaurants that are open, deliverable to their location, ranked by relevance (distance, rating, ETA). Search by cuisine, dish, or name.
- Menu browsing. Each restaurant has a menu of items with prices, categories, customizations (toppings, spice level), and live availability (an item can be out of stock right now).
- Cart and checkout. Add items, apply customizations, apply a coupon, see the bill (item total + taxes + delivery fee + surge), and place the order after payment.
- Order lifecycle. An order moves through states: placed -> restaurant accepted -> being prepared -> partner assigned -> picked up -> out for delivery -> delivered (or cancelled / rejected at several points), with each party driving specific transitions.
- Delivery-partner assignment (dispatch). When an order is confirmed, find a nearby available delivery partner, offer them the order, and route them restaurant -> customer. Handle decline, timeout, and re-assignment.
- Real-time tracking. Once a partner is assigned, the customer sees the partner’s live location moving on a map, plus an updating ETA, from pickup to doorstep.
- Notifications. Push updates to all three parties on every state change (“order accepted”, “partner arriving”, “food picked up”).
Explicitly out of scope (say this out loud so you own the scope):
- Payments, refunds, and the ledger. Charging the card, splitting money between restaurant, partner, and platform, and refund processing are their own systems. The order hands a paid
payment_idin and a final settlement out; I will not design the ledger. - Maps, routing, and road-network ETA internals. Turn-by-turn navigation and the routing engine (shortest path over a road graph) are a separate specialist system. I treat “road ETA / distance between A and B” as a service call.
- Restaurant onboarding, KYC, partner onboarding. Assume restaurants and partners exist and are verified.
- Recommendations / personalization ranking internals, ads, ratings and reviews. Each is its own system; I will treat discovery ranking as a scored search call.
The one decision that drives everything: this is an orchestration problem, not a CRUD problem. The data volumes are modest by internet standards (millions of orders/day, not billions of events/sec). What is hard is that a single order is a saga - a long-running transaction spanning three parties and multiple services (restaurant, payment, dispatch, delivery) where any step can fail and the whole thing must stay consistent and refundable. That is why the order state machine and the dispatch loop, not raw scale, are the heart of the design.
Non-Functional Requirements (NFR)
- Scale: ~60M daily active users, ~500K restaurants, ~2M delivery partners (~300K online at peak), ~15M orders/day. Peak is intensely bursty - lunch (12-2pm) and dinner (7-10pm) carry most of the day’s volume, and a rainy evening or a cricket final spikes it further.
- Latency: discovery/search feed must render in under ~300ms (this is the browsing path everyone hits). Menu load under ~200ms. Order placement (the write) should confirm in under ~1s. Live partner location on the tracking map should feel real time, p99 under ~2s end to end.
- Availability: discovery and order placement need 99.99%+ - a customer who cannot place an order during dinner rush churns to the competitor. Tracking can tolerate brief degradation (a stale dot for a few seconds is survivable); order placement and payment confirmation cannot.
- Consistency: the order state machine must be strongly consistent - an order is in exactly one state, transitions are atomic, and money-affecting transitions (accepted, cancelled, delivered) must never be lost or duplicated. Partner assignment must be strongly consistent - one partner is assigned to one active order at a time (a partner can batch a few deliveries, but each slot is claimed exactly once). Discovery, menu reads, and partner location are eventually consistent - a menu that is 30 seconds stale or a dot that is 2 seconds behind is fine.
- Durability: orders are financial and legal records - never lost, fully auditable (who transitioned it, when). Raw partner location pings are ephemeral; only the delivered order’s summary route needs archiving.
- Freshness: menu availability (item in/out of stock) must propagate within seconds - selling a customer a dish the kitchen ran out of is a bad, refund-generating failure.
Back-of-the-Envelope Estimation (BoE)
Real numbers with the arithmetic, because they dictate the architecture.
Orders (the transactional write path):
15M orders/day
= 15,000,000 / 86,400
≈ 175 orders/sec average
But food delivery is not uniform. Roughly 60% of orders fall in ~4 peak hours (lunch + dinner):
0.6 * 15M = 9M orders in ~4 hours = 14,400 s
= 9,000,000 / 14,400
≈ 625 orders/sec during peak windows
Momentary spikes (7:30pm on a rainy Friday) ≈ 2,000 orders/sec
So the write path peaks around 2,000 orders/sec. Modest - a well-sharded relational store handles this. The point of this number is that order volume is NOT the scaling driver. Placement is a few thousand writes/sec; that is not what breaks.
Discovery / browse (the read firehose):
Every order is preceded by browsing. Assume ~20 discovery/menu reads per order placed, plus lots of browse-without-order:
15M orders * 20 reads ≈ 300M reads/day from converters
+ browse-only sessions, call it ~1B discovery+menu reads/day
= 1,000,000,000 / 86,400
≈ 11,600 reads/sec average
Peak (lunch/dinner) ≈ 40,000-60,000 reads/sec
Reads dominate writes ~50:1, and reads spike hard at meal times. This is the number that dictates aggressive caching of the discovery feed and menus.
Location pings (partner tracking, the write-and-relay stream):
~300K partners online at peak, each pinging every ~5 seconds
= 300,000 / 5
= 60,000 location writes/sec at peak
Smaller firehose than ride-hailing (fewer partners than Uber has drivers) but the same shape - a stream of ephemeral positions, never a durable DB write. Only partners on an active delivery need their pings relayed to a customer; idle partners’ pings only feed the dispatch index.
Storage:
Per order record ≈ 2 KB (ids, items snapshot, addresses, price breakdown,
state history, timestamps, partner + restaurant refs)
15M orders/day * 2 KB = 30 GB/day ≈ 11 TB/year.
Easily sharded, cheap. Archive orders older than ~6 months to cold storage; keep the hot set small.
Menu data: 500K restaurants * ~100 items * ~500 bytes ≈ 25 GB total.
Tiny. The entire menu corpus fits in a cache tier comfortably; the challenge is invalidation (availability changes), not size.
Live partner index: 300K partners * ~100 bytes ≈ 30 MB in memory.
Trivially fits in RAM.
Bandwidth:
Discovery egress: 50K reads/sec * ~50 KB feed/menu payload ≈ 2.5 GB/sec at peak
-> CDN + cache absorb almost all of this.
Tracking egress: ~1M concurrent active deliveries * 1 push / 5s * ~100 bytes
≈ tens of MB/sec, point-to-point.
The takeaways: order volume is small (thousands/sec) and belongs in a strongly-consistent relational store; the discovery/menu read path is the real traffic (tens of thousands/sec, spiky) and must be cache-first; the live location index is tiny and in-memory; and the genuine difficulty is orchestrating the multi-party order saga and dispatch, not absorbing raw scale. The state machine and dispatch, not the database, are the design drivers.
High-Level Design (HLD)
The architecture separates four planes: the discovery plane (cache-heavy, read-optimized restaurant/menu search), the order plane (a strongly-consistent state machine orchestrating the saga), the dispatch plane (partner assignment over an in-memory geo-index), and the tracking plane (a location stream relayed to customers). An order is created in the order plane, which orchestrates calls out to restaurant, payment, and dispatch; dispatch reads the location plane; tracking relays it back.
┌──────────────┐ ┌──────────────┐ ┌────────────────┐
│ Customer App │ │Restaurant App │ │ Partner App │
│(browse/order/ │ │(accept/prep/ │ │ (accept/pickup/ │
│ track) │ │ ready) │ │ GPS stream) │
└──────┬────────┘ └──────┬────────┘ └────────┬───────┘
│ │ │
┌──────▼────────────────────────▼──────────────────────────▼───────┐
│ API Gateway / LB │
└───┬───────────────┬───────────────────┬───────────────────┬──────┘
│ browse │ place order │ state updates │ pings
┌─────▼──────┐ ┌──────▼─────────┐ ┌──────▼────────┐ ┌──────▼──────┐
│ Discovery/ │ │ Order Service │ │ Restaurant │ │ Location │
│ Search Svc │ │ (STATE MACHINE│ │ Service │ │ Gateway │
│ (feed,rank) │ │ + saga orch) │ │ (menu, accept)│ │ (WS/gRPC) │
└─────┬──────┘ └──┬────┬────┬────┘ └──────┬────────┘ └──────┬──────┘
│ read │ │ │ │ menu cache │
┌─────▼──────┐ │ │ │ ┌──────▼────────┐ ┌─────▼──────┐
│ Search │ │ │ │ │ Menu Store │ │ Kafka │
│ Index │ │ │ └────────▶│ (SQL+cache) │ │ (locations,│
│(Elastic)+ │ │ │ payment └───────────────┘ │ key=partner│
│ Redis feed │ │ │ ┌──────────┐ └─────┬──────┘
└─────────────┘ │ └▶│ Payment │ │consume
│ │ Service │ ┌──────▼──────┐
│ └──────────┘ │ Location Svc │
┌──────▼────────┐ ┌──────────────┐ │ (in-mem geo │
│ Dispatch Svc │◀──│ In-mem geo │◀──────────│ index, │
│ (assign,offer, │ │ index of │ │ sharded) │
│ strong claim) │ │ partners │ └──────┬──────┘
└──────┬────────┘ └──────────────┘ │
│ assigned relay pings │
┌──────▼──────────┐ to customer │
│ Order Store │◀───── all state ──────────────────┘
│ (sharded SQL, │ transitions
│ by city_id) │
└──────────────────┘
Browse / discovery flow:
- Customer opens the app. Discovery Service takes their location, queries a precomputed, cached feed of deliverable open restaurants for that area (served from Redis/CDN), ranked by a scoring service.
- Search-by-dish/cuisine hits an Elasticsearch index. Tapping a restaurant loads its menu from the Menu Service, served from cache with live availability overlaid.
- This whole path is read-only and cache-first - it never touches the order plane.
Order placement / saga flow:
- Customer checks out. The Order Service validates the cart against the live menu (prices, availability), computes the bill, and calls the Payment Service to authorize the payment.
- On payment success, Order Service creates a durable order row in state
PLACEDand pushes the order to the restaurant (via the Restaurant App connection / notification). - The restaurant accepts (or rejects). On accept, state ->
ACCEPTED, and the kitchen starts cooking. Order Service now triggers dispatch. - The Dispatch Service finds nearby available partners from the in-memory geo-index, ranks them, and atomically claims one (strong consistency), offering the order. On accept, state ->
PARTNER_ASSIGNED. - Partner rides to the restaurant, marks picked up (state ->
PICKED_UP/OUT_FOR_DELIVERY), rides to the customer, marks delivered (state ->DELIVERED). Settlement is handed to billing.
Every transition is an atomic write to the Order Store with an appended event to the state history. Failures at any step (restaurant rejects, no partner found, partner cancels) drive compensating transitions - re-dispatch, or cancel-and-refund.
Tracking flow: once a partner is assigned, their existing GPS ping stream (already flowing into the location plane for dispatch) is additionally relayed to the assigned customer over the customer’s open connection, so the customer sees the partner move. ETA is recomputed from the partner’s position via the routing service.
The key insight: the Order Service is a saga orchestrator, and the order row is a strongly-consistent state machine. The saga coordinates independent services (restaurant, payment, dispatch) that can each fail; the state machine guarantees the order is always in exactly one well-defined state with a durable audit trail, so a refund or a retry is always unambiguous.
Component Deep Dive
The hard parts, naive-first then evolved: (1) the restaurant + menu services and discovery, (2) the order lifecycle state machine and saga, (3) delivery-partner assignment / dispatch, (4) real-time tracking.
1. Restaurant and menu services (discovery + freshness)
Naive approach: one relational query per browse. When a customer opens the app, run SELECT * FROM restaurants WHERE ... ORDER BY rating, then for the menu SELECT * FROM menu_items WHERE restaurant_id = ?, joined to availability. Serve it straight from Postgres.
Where it breaks:
- Read volume. 40K-60K discovery reads/sec at meal times against a relational DB, each a multi-table join with ranking, is a self-inflicted denial of service. This is the highest-traffic path in the system and it is almost all repeated reads of slow-changing data.
- Geospatial deliverability. “Restaurants that can deliver to my exact location” is a spatial + business-rule filter (within delivery radius, currently open, currently accepting orders). A
WHERE lat BETWEEN ...box on a hot table churns and is slow. - Ranking cost. Sorting by a relevance score (distance, rating, ETA, popularity) per request, per user, is expensive to compute live at 50K/sec.
First evolution: cache the menu, still query the feed live. Put menus in Redis (they barely change). Helps the menu path a lot. But discovery - “what should I see right now, here” - still runs live because it depends on the customer’s location and time. And now you have a cache-invalidation problem: when a restaurant runs out of paneer, the cached menu is wrong, and you will sell a dish that does not exist.
The answer: split slow-changing catalogue data from fast-changing availability, cache the catalogue hard, overlay availability from a fast store, and precompute the feed per area.
- Catalogue vs availability split. A menu item has two kinds of data. The catalogue (name, description, price, customizations, category) changes rarely - cache it aggressively (Redis + CDN), invalidate on the rare edit. Availability (
in_stock: true/false,restaurant_accepting_orders) changes constantly - store it in a small, fast key-value layer (avail:{restaurant_id}:{item_id} -> bool,restaurant:{id}:status -> open/busy/closed) that the kitchen updates directly. At read time, the menu is the cached catalogue with the live availability overlaid. You cache the 25 GB of stable data and only hit the fast store for the tiny mutable bits.
GET menu(restaurant_id):
catalogue = cache.get("menu:" + restaurant_id) # big, stable, cached
avail = kv.mget("avail:" + restaurant_id + ":*") # small, live
return merge(catalogue, avail) # gray out sold-out items
- Availability propagation. When the kitchen marks an item out of stock, it writes the availability key and publishes an event; anything holding a warm view refreshes. Propagation is seconds, not the minutes a full menu-cache TTL would take. This directly satisfies the “sell only what exists” freshness NFR.
- Precomputed discovery feed. The “restaurants near you, open, ranked” feed is computed per geographic cell (geohash) rather than per user. A background job (or streaming update) maintains, for each cell, the list of deliverable open restaurants with their scores, cached in Redis (
feed:{cell} -> ranked list). A browse request resolves the customer’s cell, reads the precomputed list (O(1)), and applies light per-user reranking (their past orders, dietary filters) on the small returned set. Restaurant search (by dish/name) goes to Elasticsearch, which is built for text relevance and geo-filtering. - Deliverability as a geo-filter. “Can restaurant R deliver to me” is decided by the geo-cell membership plus each restaurant’s delivery radius, computed when the feed is built - not per request. Store per-cell so the read path never does spatial math.
The elegant part: discovery is read-mostly over slow-changing data, so you precompute and cache almost everything and keep only the volatile availability live. The 50K/sec read firehose is absorbed by caches; the relational store only holds the source of truth for catalogue edits, which happen thousands of times a day, not thousands of times a second.
| Data | Change rate | Store | Read path |
|---|---|---|---|
| Restaurant catalogue / menu | rare (edits) | SQL source + Redis/CDN | cache hit |
| Item availability / open status | constant | fast KV (Redis) | live overlay |
| Discovery feed (per cell) | minutes | precomputed in Redis | O(1) lookup |
| Search (dish/cuisine text) | rare | Elasticsearch | index query |
2. The order lifecycle state machine and saga
This is the heart of the system: an order is a long-running, multi-party transaction that must stay consistent across services that can each fail independently.
Naive approach: a status column updated inline. orders.status is a string; each service does UPDATE orders SET status = 'accepted' WHERE id = ? when its step happens. Order placement synchronously calls payment, then restaurant, then dispatch, in one request.
Where it breaks:
- No atomicity across services. Payment succeeds, then the dispatch call times out - now the customer is charged with no order progressing, and there is no defined way to unwind. A synchronous chain of remote calls has no transaction spanning all of them (there is no distributed ACID across payment + restaurant + dispatch).
- Illegal transitions. With a free-text status and no guard, a bug (or a retried message) can move an order from
deliveredback topreparing, or accept an order that was already cancelled. Concurrent updates from the restaurant and the customer (accept vs cancel arriving at the same instant) race and corrupt the state. - Lost transitions / no audit. Overwriting a status column loses history. When a customer disputes “my order was never accepted”, you have no record of who transitioned what, when. These are financial records.
- Blocking on slow humans. A restaurant takes 90 seconds to tap accept; a partner takes 20 seconds to respond to an offer. A synchronous request cannot block that long. The order is inherently asynchronous and event-driven.
The fix: an explicit state machine with guarded transitions, driven by a saga orchestrator, with every transition an atomic, audited write.
Explicit states and legal transitions. Define the states and exactly which transitions are legal and who may trigger them:
PLACED ─(restaurant accept)─▶ ACCEPTED ─(kitchen)─▶ PREPARING
│ │ │
(rest reject / (dispatch) (partner
cust cancel / ▼ picks up)
timeout) PARTNER_ASSIGNED ──────────▶ PICKED_UP
│ │ │
▼ (partner cancel ▼
CANCELLED ◀── (any pre-pickup failure) ── OUT_FOR_DELIVERY
(+ refund) │
(partner marks
delivered)
▼
DELIVERED
Each transition is a guarded compare-and-swap: UPDATE orders SET status=? , updated_at=? WHERE id=? AND status = <expected_current>. The AND status = expected makes it atomic and idempotent - a retried “accept” message when already accepted is a no-op, and an illegal jump (delivered -> preparing) fails the guard. Concurrent accept vs cancel: exactly one wins the CAS, the other sees the guard fail and is handled. This satisfies the strong-consistency NFR for the order.
Every transition appends to an event log. Alongside the status update, append an immutable row to order_events (order_id, from_state, to_state, actor, reason, ts). The current status is a materialized convenience; the event log is the source of truth and the audit trail. This is event sourcing for the parts that matter.
The saga orchestrator. Because there is no distributed ACID across payment, restaurant, and dispatch, the Order Service runs a saga: a sequence of local transactions, each with a compensating action if a later step fails.
saga place_order:
1. authorize_payment() compensate: refund()
2. create_order(PLACED) compensate: mark CANCELLED
3. await restaurant_accept (timeout 3 min)
reject/timeout -> compensate steps 1-2 (refund, cancel), notify
4. transition ACCEPTED/PREPARING
5. dispatch_partner() (retry loop; if exhausted -> compensate: refund, cancel)
6. await pickup, delivery -> DELIVERED -> settle
If step 3 (restaurant accept) times out, the saga runs the compensations for steps 1-2: refund the payment and move the order to CANCELLED with reason restaurant_timeout. The customer is never charged for an order that did not happen. Because each step is durable and the orchestrator is restartable, a crash mid-saga resumes from the last committed state - the order row is the saga’s checkpoint.
Asynchronous and event-driven. Human steps (restaurant accept, partner response) are not blocking calls - they are events. The restaurant’s “accept” arrives over its app connection, lands as a message, and drives the transition. Timers (a restaurant_accept_timeout) are scheduled jobs that fire a compensating event if the human does not act. The Order Service reacts to events; it does not hold a request open for three minutes.
Idempotency everywhere. Every external call (payment auth, dispatch offer) carries an idempotency key so retries after a network blip do not double-charge or double-assign. Every inbound transition event is deduplicated by the CAS guard. In a multi-party saga, at-least-once delivery is the norm, so exactly-once effect must come from idempotency, not from hoping messages arrive once.
The elegant part: the order is a strongly-consistent state machine, and the saga is how you get transaction-like safety across services that cannot share a transaction. Guarded CAS transitions make illegal and concurrent updates impossible; the event log makes every order auditable and refundable; compensations make every failure path end in a defined, money-correct state.
3. Delivery-partner assignment (dispatch)
Naive approach: assign the nearest available partner. When an order is accepted, query for the closest available partner, set them as assigned, done.
Where it breaks:
- Double assignment (the correctness bug). Two orders confirm at the same instant, the query returns the same nearest partner to both dispatch workers, both mark them assigned. One partner, two orders they never agreed to. This directly violates “one partner claimed exactly once per slot.”
- Nearest ignores timing. The geometrically closest partner may already be finishing another delivery in the opposite direction. Straight-line nearest also ignores that the food is not ready yet - assigning a partner who arrives 15 minutes before the food is cooked wastes them; assigning one who arrives 15 minutes after means cold food.
- No decline handling. Partners decline or ignore offers. Assign-and-walk-away strands the order.
- Efficiency at scale. One-order-one-partner leaves money on the table. Two orders from the same restaurant (or two nearby restaurants) going to nearby customers should be batched onto one partner - a core lever of unit economics that greedy nearest-assignment ignores.
The fix: a dispatch loop that ranks candidates by predicted total time, atomically claims a partner slot with strong consistency, offers sequentially with timeout, and considers batching - timed to food-ready, not order-placed.
The candidate pool from an in-memory geo-index. Partner locations stream through Kafka (keyed by partner_id for per-partner ordering) into an in-memory, region-sharded geohash index - the same pattern as ride-hailing driver location, and small (~30 MB). Dispatch asks “available partners within radius R of the restaurant” and gets a bounded candidate set (one geohash cell plus its 8 neighbours), not a scan.
Strong-consistency claim, only here. When Dispatch picks a candidate, it must atomically claim a delivery slot on that partner so no concurrent dispatch grabs the same one:
SET slot:partner:{id} = order_id NX EX 30 # Redis NX = only if unclaimed
-- or CAS: UPDATE partners SET active_slot=? WHERE id=? AND active_slot IS NULL
Only one dispatcher wins the key; losers skip to the next candidate. The 30s TTL auto-releases if the offer flow crashes, so a partner is never stuck claimed. Partners who batch have N slots; each slot is claimed independently.
Rank by predicted total delivery time, not distance. Candidates are scored by the routing service on predicted time to complete: time_to_reach_restaurant + expected_wait_for_food + time_restaurant_to_customer, tie-broken by rating and acceptance rate. A partner slightly farther but already heading past the restaurant, arriving right as the food is ready, beats a closer idle one.
Timing to food-ready. Dispatch does not have to assign the instant the restaurant accepts. It estimates food-ready time (from the restaurant’s prep-time estimate) and assigns so the partner arrives just as the food is ready - minimizing both partner idle time at the counter and food sitting cold. This is a scheduling decision, not just a spatial one.
dispatch(order):
ready_at = now + estimate_prep_time(order.restaurant)
candidates = location.find_nearby(order.restaurant.loc, R) # eventual read
ranked = rank(candidates,
by=predicted_total_time(cand, order, ready_at))
for p in ranked:
if not claim_slot(p.id, order.id): # STRONG: SET NX / CAS
continue # someone grabbed this slot
offer(p, order); resp = await(p, timeout=20s)
if resp == ACCEPT:
order.transition(PARTNER_ASSIGNED, actor=p.id)
return p
release_slot(p.id) # decline/timeout -> next
widen_radius_or_retry(order) # exhausted -> grow R, then escalate
Batching. Before assigning, Dispatch checks whether a partner already carrying (or about to carry) a nearby order can take this one too: same restaurant or restaurants within a short hop, customers along a compatible route, within a time budget that does not make either order late. If so, offer the batch. Batching is a bounded optimization (never batch more than ~2-3, never add more than a few minutes to an existing order) so it improves economics without wrecking delivery times.
Sequential offers with timeout and escalation. Offer to the best candidate; on decline or ~20s timeout, release the slot and offer the next. If the candidate list is exhausted, widen the radius; if still nothing (a surge with no supply), escalate - raise partner incentive, or, as a last resort, cancel-and-refund via the saga’s compensation path.
Why not optimistic-and-reconcile? Because two partners told to fetch the same order, or a customer with no partner ever assigned, are user-visible failures on a cold-food deadline. The claim lock is a few milliseconds and dispatch happens ~2,000/sec at peak - negligible next to the 60K/sec location writes. Pay for strong consistency exactly at the claim, nowhere else.
4. Real-time delivery tracking
Naive approach: the customer app polls for the partner’s location. GET /order/{id}/location every few seconds; each call reads the partner’s latest position from a DB.
Where it breaks:
- Poll amplification. ~1M concurrent active deliveries polling every 2-3 seconds is hundreds of thousands of reads/sec of pure overhead, most returning “barely moved.”
- Two GPS sources. If tracking reads a separate location than dispatch uses, you are ingesting each partner’s GPS twice and they drift out of sync.
- DB on the hot path. Writing every partner ping to a durable store to be polled is the same write-amplification mistake as ride-hailing - persisting data with a 5-second useful life.
The fix: reuse the single location stream, relay it point-to-point over a push connection, recompute ETA on the fly.
- One location stream, two consumers. The partner’s GPS already streams into the location plane for dispatch. For a partner on an active delivery, the Location Service additionally relays each ping to the one assigned customer over the customer’s open WebSocket/SSE connection. No second GPS source, no polling. This is point-to-point (one partner -> one customer), so it scales with connection servers, not with any broadcast fan-out.
Partner --WSS--> Location Gateway --> Kafka(key=partner_id) --> Location Svc
Location Svc: update dispatch geo-index (always)
if partner.active_order: push ping to that order's customer conn
- ETA recomputation. The tracking payload includes a fresh ETA. Rather than call the routing service on every single ping (expensive), recompute the road ETA every ~15-20s or when the partner deviates significantly from the expected route, and interpolate the dot smoothly between updates client-side.
- State-driven UI, not just a dot. The tracking screen is driven by the order state machine (accepted -> preparing -> picked up -> arriving) plus the live location. State transitions push over the same connection, so “food picked up” flips the UI the instant the partner taps it - no polling for status either.
- Graceful degradation. If a partner’s connection drops (dead zone), the last known position is shown with a “reconnecting” hint; when pings resume they catch up. Tracking is explicitly allowed to be briefly stale (NFR), so a lost ping is not an incident - the next one heals it.
The elegant part: tracking is a free rider on the dispatch location stream. The same pings that feed partner assignment are relayed, unchanged, to the one customer who cares - no extra ingestion, no polling, and the customer’s map and the dispatcher’s index can never disagree because they are the same data.
API Design & Data Schema
Browse and order are REST; location and offers are streamed frames; tracking is a push stream.
REST (customer-facing)
GET /api/v1/discovery/feed?lat=..&lng=..&filters=veg,fast
-> { restaurants: [ {id, name, cuisines, rating, eta_min, distance_km,
promoted, image_ref}, ... ] } # served from cache
GET /api/v1/restaurants/{id}/menu
-> { restaurant:{id,name,open}, categories:[
{name, items:[ {id, name, price, veg, customizations,
in_stock: true} ] } ] } # catalogue + live avail
POST /api/v1/orders
body: { restaurant_id, items:[{item_id, qty, customizations}],
address_id, coupon, payment_id, quote_id }
-> 202 Accepted { order_id: "o_91", status: "placed" }
GET /api/v1/orders/{order_id}
-> { order_id, status: "out_for_delivery",
partner:{id,name,phone_masked,vehicle}, partner_location:{lat,lng},
eta_min: 8, price_breakdown:{...}, state_history:[...] }
POST /api/v1/orders/{order_id}/cancel
-> { status: "cancelled", refund: {amount, eta} }
Restaurant-facing (over app connection + REST)
POST /api/v1/restaurants/{id}/orders/{order_id}/accept -> { status:"accepted", prep_min:18 }
POST /api/v1/restaurants/{id}/orders/{order_id}/reject -> { status:"cancelled", reason }
POST /api/v1/restaurants/{id}/orders/{order_id}/ready -> { status:"ready_for_pickup" }
PUT /api/v1/restaurants/{id}/items/{item_id}/availability body:{in_stock:false}
Partner-facing (streamed frames)
// persistent gRPC / WebSocket, one frame every ~5s
{ "type":"LOCATION", "partner_id":"p_44", "lat":12.97, "lng":77.59, "ts":175... }
// server -> partner: dispatch offer
{ "type":"OFFER", "order_id":"o_91", "restaurant":{loc,name}, "customer_area":"...",
"est_payout":72, "batch":false, "expires_in_s":20 }
// partner -> server
{ "type":"OFFER_RESPONSE", "order_id":"o_91", "decision":"ACCEPT" }
{ "type":"STATUS", "order_id":"o_91", "event":"PICKED_UP" }
Data stores - the right tool per plane
1. Order Store - relational (sharded PostgreSQL / a NewSQL like CockroachDB). Orders are the one place we demand SQL: a strict state machine, transactional guarded transitions, relationships (customer, restaurant, partner, payment), and durable, auditable financial records. Volume is modest (~2,000 writes/sec peak), so a sharded relational fleet is ideal, and it gives ACID transitions.
Table: orders
order_id BIGINT PRIMARY KEY -- Snowflake, time-sortable
customer_id BIGINT INDEX
restaurant_id BIGINT INDEX
partner_id BIGINT INDEX (nullable until assigned)
status ENUM(placed, accepted, preparing, partner_assigned,
picked_up, out_for_delivery, delivered, cancelled)
items_snapshot JSONB -- items + prices frozen at order time
address JSONB -- delivery lat/lng + text
price JSONB -- item_total, taxes, delivery_fee, surge
payment_id BIGINT
city_id INT -- SHARD KEY
placed_at, accepted_at, delivered_at TIMESTAMP
Shard by: city_id -- orders are naturally geo-partitioned; a city's orders
-- live together, queried together (support, analytics)
Indexes: (customer_id, placed_at), (restaurant_id, placed_at),
(partner_id, status), status
Table: order_events -- append-only audit / event source
event_id BIGINT PK
order_id BIGINT INDEX
from_state, to_state ENUM
actor ENUM(customer, restaurant, partner, system)
reason STRING
ts TIMESTAMP
Note the items_snapshot: we freeze item names and prices into the order at placement time. If the restaurant later edits the menu, the order still reflects what the customer actually bought - orders must not mutate when the catalogue does.
2. Menu / restaurant catalogue - relational source of truth + Redis/CDN cache. Restaurants and menu items are slow-changing reference data with relationships (restaurant -> categories -> items -> customizations). Store in SQL, cache the read path hard.
Table: restaurants ( restaurant_id PK, name, cuisines[], lat, lng,
delivery_radius_m, city_id SHARD KEY, rating, geohash INDEX )
Table: menu_items ( item_id PK, restaurant_id INDEX, name, price,
category, veg BOOL, customizations JSONB )
3. Availability + feed + surge - Redis (fast KV), short TTL. avail:{rest}:{item} -> bool, restaurant:{id}:status -> open|busy|closed, feed:{geohash} -> ranked restaurant list, surge:{cell} -> multiplier. Volatile, O(1) reads, written by kitchens / streaming aggregators.
4. Search - Elasticsearch. Restaurant + dish text search with geo-filtering and relevance ranking. Fed from the catalogue via CDC.
5. Live partner location - in-memory geo-index (Redis GEO / custom QuadTree), NOT a database. Region-sharded, rebuildable from the last ~1 minute of the Kafka location topic. Deliberately non-durable because a position lives ~5 seconds.
Why SQL for orders and catalogue, but Redis/in-memory for availability, feed, and location: orders and catalogue are durable, relational, transactional; availability, feed, and location are ephemeral, high-read or high-write, and tolerate staleness. Matching the store to the plane is the whole schema decision - a durable relational store for locations would be write-amplification suicide, and eventual-consistency NoSQL for orders would risk losing a paid order’s fare.
Bottlenecks & Scaling
Where it breaks first, in order, and the fix for each:
1. The discovery / menu read firehose (breaks first). 40K-60K reads/sec at meal peaks will crush any relational DB. Fix: cache-first discovery - precomputed per-geohash feeds in Redis, menus in Redis/CDN, availability overlaid from a small fast KV. The relational store only serves catalogue edits (thousands/day). Search offloaded to Elasticsearch. The read path essentially never touches Postgres.
2. Meal-time burst (thundering herd). Traffic is not flat - it triples at 1pm and 8pm. Fix: autoscale stateless services ahead of known peaks (predictable daily curve), pre-warm caches before meal windows, and rate-limit / queue non-critical work. The feed being precomputed means the spike hits cache, not compute.
3. The order state machine under concurrent transitions. Restaurant accept and customer cancel racing; retried messages.
Fix: guarded CAS transitions (WHERE status = expected) make every transition atomic and idempotent - exactly one of a race wins, illegal jumps fail the guard, retries are no-ops. The append-only order_events log preserves the full audit trail.
4. Cross-service failure / partial saga. Payment taken but dispatch fails; restaurant times out. Fix: the saga with compensating actions - every step has a defined undo (refund, cancel). A crashed orchestrator resumes from the durable order state. No order ends in an ambiguous or money-wrong state.
5. The double-assignment race (dispatch correctness). Concurrent dispatchers grabbing the same partner slot.
Fix: strongly-consistent slot claim (SET NX / CAS on active_slot IS NULL) so exactly one dispatcher claims a slot. Rare (~2,000/sec) vs location writes (60K/sec), so the cost is negligible. TTL prevents a crashed offer from stranding a partner.
6. No available partners (supply crunch). Rain, surge, a stadium - demand outstrips online partners. Fix: progressively widen the dispatch radius, then escalate incentives (surge partner payout to pull supply, shown as delivery-fee surge to the customer, computed as a windowed supply/demand aggregation per cell just like ride-hailing), then batch aggressively, and as a last resort cancel-and-refund cleanly via the saga.
7. Partner location write stream. 60K ephemeral pings/sec.
Fix: never persist to a durable DB. Stream through Kafka (keyed by partner_id for order) into an in-memory, region-sharded geo-index. A crashed index node rebuilds from a minute of Kafka retention. Same discipline as ride-hailing, smaller volume.
8. Nearest-neighbour dispatch query. Scanning all partners is O(N). Fix: the geohash geo-index turns “partners near this restaurant” into a bounded lookup of one cell plus 8 neighbours, with adaptive precision in dense cities so a hot cell does not return thousands of candidates.
9. Live tracking fan-out. ~1M concurrent deliveries each pushing partner position. Fix: relay the partner’s existing dispatch ping stream to the one assigned customer over their open connection - point-to-point, no polling, no second GPS source. Scales with connection servers.
10. Order store scaling and hot shards. Millions of durable orders; a viral restaurant or a dense city concentrates load.
Fix: shard the order store by city_id - orders are naturally geo-partitioned and per-city queries (support, restaurant dashboards, analytics) stay in one shard. Read replicas for tracking/status reads. Sub-shard a hyper-dense city if one shard gets hot. Archive orders older than ~6 months to cold storage.
11. Menu cache invalidation / stale availability. Selling a sold-out dish. Fix: keep availability out of the long-TTL menu cache - it lives in a separate fast KV updated directly by the kitchen and propagated via events within seconds, overlaid onto the cached catalogue at read time. The catalogue TTL can be long precisely because the volatile bit is separate.
12. Single points of failure. Fix: Discovery, Order, Dispatch, Restaurant services are stateless behind the LB; Location Gateways and Location Service shards rebuild from Kafka; Kafka, Redis, Elasticsearch, and the order DB are all replicated with failover. The one strongly-consistent component (the slot/transition CAS) runs on a replicated store. No single box whose loss stops ordering - at worst a region’s location index rebuilds from the stream while dispatch briefly retries.
13. Multi-region / global. Fix: deploy the stack per region and route by geography - a customer, their restaurants, and the partners around them are always in one region, so the latency-critical discovery/dispatch/tracking loop never crosses a region boundary. Orders replicate asynchronously to a global store for analytics.
Wrap-Up
The trade-offs that define this design:
- Order as a strongly-consistent saga, not a synchronous chain. We model the order as an explicit state machine with guarded CAS transitions and an append-only event log, orchestrated by a saga with compensating actions - trading the simplicity of one synchronous request for transaction-like safety across payment, restaurant, and dispatch, which cannot share an ACID transaction. Every failure ends in a defined, refundable state.
- Cache-first discovery over live queries. The read path (tens of thousands/sec, spiky) is served from precomputed per-cell feeds and cached menus, with only the volatile availability kept live - trading a few seconds of feed staleness for a read path that never touches the relational store.
- Catalogue / availability split for freshness. Stable menu data is cached hard; in-stock status lives in a separate fast store and propagates in seconds - so we can cache aggressively and never sell a sold-out dish.
- Eventual consistency everywhere except two claims. Discovery, menus, and partner location are eventually consistent; only the order transitions and the partner slot claim get strong consistency. We pay for it exactly at the state machine and the dispatch claim - the two places double-booking or a lost transition is user-visible and money-affecting - and nowhere else.
- Dispatch timed to food-ready and rank by total time, with batching. The partner is assigned to arrive as the food is ready, ranked by predicted total delivery time (not straight-line distance), and batched when compatible - separating “who is nearby” (fast, spatial) from “who finishes this order fastest” (accurate, time-aware) and squeezing unit economics without making orders late.
- Tracking as a free rider on the dispatch stream. The same partner pings that feed assignment are relayed point-to-point to the one customer who cares - no second GPS source, no polling, and the map and the dispatcher can never disagree.
One-line summary: a food-delivery platform where a cache-first discovery plane serves the read firehose from precomputed per-cell feeds and split catalogue/availability data, orders are strongly-consistent state machines driven by a saga orchestrator with compensating actions across payment/restaurant/dispatch, delivery partners are assigned by a dispatch loop that claims a slot with a single strong-consistency lock and times arrival to food-ready with optional batching, live tracking rides free on the partner GPS stream relayed point-to-point, and only the transactional orders and catalogue persist to a city-sharded relational store.
Comments