Stackbook Logo
← Back to System Architectures

Designing a Food Delivery Tracking System

Design real-time order and courier tracking for food delivery: location ingest, order state machines, live ETA updates and fan-out to millions of clients.

5 min read

Customers watch a map pin move and a countdown tick. Behind it sit three streams: the order lifecycle, the courier location feed and an ETA estimate that must keep updating. The tracking system joins them and pushes updates to the right customer with low latency.

Requirements

Functional

  • Order states: placed, accepted, preparing, ready, picked up, delivered, cancelled.
  • Live courier location on a map and an updating ETA.
  • Push notifications at key transitions; support multi-stop batched deliveries.

Non-functional

  • Update latency from courier to customer under 3 seconds p95.
  • 99.9% availability for tracking; order state must never be lost.
  • 1M concurrent active orders at peak dinner time.
  • Graceful degradation: stale map is acceptable, wrong order state is not.

Capacity Estimation

Assume 1M active deliveries, with courier location every 5 seconds: 200,000 writes per second at 100 bytes, or 20 MB per second. Each customer app polls or subscribes: 1M open connections. Updates pushed to customers at one per 5 seconds equal 200,000 messages per second out. A WebSocket gateway node handles about 50,000 connections, so 20 nodes plus headroom, say 40. Order state changes are far fewer: about 6 per order, a few thousand per second at peak.

High-Level Design

Courier App -> Location Gateway -> Kafka (loc, keyed by courier_id)
                                      |-> Tracking Processor -> Redis (latest position, order-courier map)
                                      |-> ETA Service (road graph + prep time)
Restaurant/Order Service -> Order DB (state machine) -> Outbox -> Kafka (order events)
                                                                     |
Tracking Processor joins loc + order events -> Pub/Sub (topic per order)
                                                  -> Realtime Gateway (WebSocket/SSE) -> Customer App
Fallback: GET /orders/id/tracking from Redis snapshot

Data Model

  • orders(order_id, customer_id, restaurant_id, courier_id, state, version, promised_at)
  • order_events(order_id, seq, type, payload, created_at) as an append-only history
  • Redis: courier:loc:COURIER_ID as latest position with a 30 second TTL; order:track:ORDER_ID as snapshot of state, position and ETA
  • courier_assignments(order_id, courier_id, assigned_at, state)

Deep Dives

Order state machine and consistency

Transitions are valid only along defined edges. The Order Service applies a transition with a compare-and-set on version, then writes the event and outbox row in one transaction. Duplicate taps from a restaurant tablet carry an idempotency key and return the current state. Order state is strongly consistent in the primary store; the real-time view is a derived projection and may lag by a second or two.

Location pipeline

Location events are keyed by courier_id so each courier's updates are ordered within a partition. The processor keeps only the latest position, discarding older or out-of-order events by timestamp. To save bandwidth, the courier app sends faster when moving and slower when idle, and the server drops updates that did not move beyond a few meters.

Fan-out to customers

Each customer subscribes to a topic for their order. The Realtime Gateway holds the connection and subscribes to a shared pub/sub layer; the tracking processor publishes to the order topic, routed to whichever gateway node holds the connection. Because each order has one customer and a few watchers, fan-out is small per topic but large in aggregate. Use SSE or WebSocket with heartbeats; on reconnect the client first fetches the snapshot, then resumes the stream. Include a monotonically increasing sequence number so clients ignore stale messages.

ETA

ETA combines remaining food prep time, courier travel time over the road graph with live traffic, and a handoff buffer. Recompute every 30 seconds or when position changes significantly, not on every ping. Smooth the displayed value so the countdown does not jump, and never show an ETA that gets earlier after a delay is announced without justification, because trust matters more than precision.

Reassignment

If a courier stalls or cancels, the dispatcher reassigns. The assignment record has a version, and tracking updates the order-courier mapping so the customer's map switches pins cleanly. Messages from the old courier are ignored through the mapping check.

Failure Modes and Trade-offs

  • Gateway node crash: clients reconnect with backoff and jitter to a healthy node and refetch the snapshot; connection storms are smoothed by rate limiting.
  • Courier loses signal: show last known position with a stale indicator after 30 seconds; extrapolate along the route cautiously.
  • Kafka lag: processor lag directly delays customers; alert on consumer lag and autoscale on it.
  • Push versus poll: push gives better latency and lower server load per update, but polling is a simple fallback for restricted networks.
  • Accuracy versus battery and cost: higher ping frequency improves smoothness but drains batteries and raises ingest cost.
  • Single global stream versus regional: regional isolation limits blast radius at the cost of cross-region coordination for rare moves.

Key takeaways

  • Separate the authoritative order state machine from the derived, lossy location view.
  • Keep only the latest position in memory and key streams by courier for ordering.
  • Use snapshot plus stream with sequence numbers so reconnects are safe.
  • Compute ETA on a schedule and smooth it for user trust.
  • Scale WebSocket gateways horizontally behind a pub/sub layer.
  • Degrade by showing stale locations, never by showing wrong order status.