Designing a Food Delivery Backend: Order Lifecycle, Driver Matching, and Real-Time ETA Updates at Scale
A deep-dive into food delivery system design covering the full order lifecycle, proximity-based driver matching with fairness constraints, graph-based ETA computation, surge pricing mechanics, and the distributed systems challenges that show up when order state and driver state need to stay consistent under failure.
Food delivery systems are a useful lens for distributed systems problems because the domain forces you to confront nearly all of them at once: real-time geolocation at scale, eventual consistency between loosely coupled actors, time-sensitive state machines, and ML-based estimation in the critical path. This article walks through the core components, the data model decisions that matter, and the failure modes worth thinking through before you start building.
The Order Lifecycle as a State Machine
Before touching infrastructure, define the lifecycle. An order moves through a sequence of states, each with specific valid transitions:
type OrderStatus =
| "pending" // created, awaiting restaurant acceptance
| "accepted" // restaurant confirmed
| "preparing" // kitchen started
| "ready_for_pickup" // food ready, driver needed or en route
| "picked_up" // driver has the order
| "delivering" // en route to customer
| "delivered" // completed
| "cancelled"; // terminal failure state
interface OrderStateTransition {
from: OrderStatus;
to: OrderStatus;
actor: "system" | "restaurant" | "driver" | "customer";
requiredConditions?: string[];
}
const VALID_TRANSITIONS: OrderStateTransition[] = [
{ from: "pending", to: "accepted", actor: "restaurant" },
{ from: "pending", to: "cancelled", actor: "restaurant", requiredConditions: ["within_acceptance_window"] },
{ from: "accepted", to: "preparing", actor: "restaurant" },
{ from: "preparing", to: "ready_for_pickup", actor: "restaurant" },
{ from: "ready_for_pickup", to: "picked_up", actor: "driver" },
{ from: "picked_up", to: "delivering", actor: "system" },
{ from: "delivering", to: "delivered", actor: "driver" },
{ from: "pending", to: "cancelled", actor: "customer" },
{ from: "accepted", to: "cancelled", actor: "customer", requiredConditions: ["within_cancellation_window"] },
];
function canTransition(
current: OrderStatus,
next: OrderStatus,
actor: OrderStateTransition["actor"]
): boolean {
return VALID_TRANSITIONS.some(
(t) => t.from === current && t.to === next && t.actor === actor
);
}
This state machine is not ceremonial. It becomes your enforcement layer in the order service and the source of truth for event sourcing. Every state change is an event appended to an order event log, not an in-place update. This gives you a full audit trail for disputes, driver pay reconciliation, and debugging the “why did this order get stuck” class of incidents.
Store the current state as a materialized projection of the event log, updated transactionally. The event log is append-only; the projection is your fast-read surface.
Driver Matching: Proximity, Load, and Fairness
The naive approach is nearest-available-driver. It works at low scale but creates two problems: drivers near high-demand areas earn disproportionately more, and a driver who just completed a delivery near the restaurant gets every new order in that zone, burning out while others idle.
A better matching function considers three inputs:
- Proximity score: estimated travel time to the restaurant, not straight-line distance. Use a road network graph.
- Load score: driver’s current delivery queue depth and estimated completion time for active deliveries.
- Fairness score: earnings gap relative to zone average over the last N hours, to counteract consistent under-earning.
interface DriverCandidate {
driverId: string;
etaToRestaurantSeconds: number;
activeDeliveries: number;
earningsLast4h: number;
zoneAvgEarningsLast4h: number;
}
interface MatchingWeights {
proximity: number; // 0–1
load: number; // 0–1
fairness: number; // 0–1
}
const DEFAULT_WEIGHTS: MatchingWeights = {
proximity: 0.6,
load: 0.25,
fairness: 0.15,
};
function scoreDriver(candidate: DriverCandidate, weights: MatchingWeights): number {
// Normalize ETA: lower is better. Cap at 15 minutes as max acceptable.
const proximityScore = Math.max(0, 1 - candidate.etaToRestaurantSeconds / 900);
// Load score: penalize drivers with active deliveries
const loadScore = Math.max(0, 1 - candidate.activeDeliveries * 0.3);
// Fairness score: reward drivers earning below zone average
const earningsRatio = candidate.zoneAvgEarningsLast4h > 0
? candidate.earningsLast4h / candidate.zoneAvgEarningsLast4h
: 1;
const fairnessScore = Math.max(0, 1 - earningsRatio);
return (
weights.proximity * proximityScore +
weights.load * loadScore +
weights.fairness * fairnessScore
);
}
function selectDriver(candidates: DriverCandidate[]): DriverCandidate | null {
if (candidates.length === 0) return null;
return candidates.reduce((best, current) =>
scoreDriver(current, DEFAULT_WEIGHTS) > scoreDriver(best, DEFAULT_WEIGHTS)
? current
: best
);
}
This scoring runs inside a matching service that queries a geospatial index for drivers within a configurable radius (typically 5km) and then ranks by composite score. The geospatial index needs to handle high write throughput: drivers update position every 3-5 seconds, so at 10,000 active drivers you’re looking at 2,000-3,000 writes/second to that store.
Redis with geospatial commands (GEOADD, GEOSEARCH) handles this well at moderate scale. For larger fleets, H3 hexagonal indexing lets you shard the space predictably and query a known set of cells rather than a radius scan.
Real-Time ETA: Graph Routing and ML Adjustment
ETA has two components: time to restaurant (pickup ETA) and time from restaurant to customer (delivery ETA). Both require a road network graph with live traffic weight adjustment.
The core data structure is a weighted directed graph where edge weights are travel times. Historical traffic patterns seed the weights; live signals update them continuously.
interface RoadEdge {
from: string; // node ID
to: string; // node ID
baseTimeSeconds: number;
currentMultiplier: number; // 1.0 = normal, 2.0 = doubled travel time
}
interface RoutingGraph {
edges: Map<string, RoadEdge[]>; // adjacency list: nodeId -> outgoing edges
}
// Dijkstra for shortest path by time
function shortestPath(
graph: RoutingGraph,
origin: string,
destination: string
): { timeSeconds: number; path: string[] } | null {
const dist = new Map<string, number>();
const prev = new Map<string, string | null>();
const unvisited = new Set<string>();
for (const nodeId of graph.edges.keys()) {
dist.set(nodeId, Infinity);
prev.set(nodeId, null);
unvisited.add(nodeId);
}
dist.set(origin, 0);
while (unvisited.size > 0) {
// Get unvisited node with minimum distance
let current: string | null = null;
let minDist = Infinity;
for (const node of unvisited) {
const d = dist.get(node) ?? Infinity;
if (d < minDist) {
minDist = d;
current = node;
}
}
if (current === null || current === destination) break;
unvisited.delete(current);
for (const edge of graph.edges.get(current) ?? []) {
const effectiveTime = edge.baseTimeSeconds * edge.currentMultiplier;
const alt = (dist.get(current) ?? Infinity) + effectiveTime;
if (alt < (dist.get(edge.to) ?? Infinity)) {
dist.set(edge.to, alt);
prev.set(edge.to, current);
}
}
}
const totalTime = dist.get(destination);
if (totalTime === undefined || totalTime === Infinity) return null;
// Reconstruct path
const path: string[] = [];
let node: string | null = destination;
while (node !== null) {
path.unshift(node);
node = prev.get(node) ?? null;
}
return { timeSeconds: totalTime, path };
}
Raw routing gives you a geometric ETA. Observed delivery times deviate from geometric estimates for several predictable reasons: restaurant prep time variance, driver search and wait time at pickup, elevator wait in high-rise buildings, parking availability at destination. A gradient boosted model trained on historical deliveries can learn these corrections as a residual on top of the routing estimate.
Features that matter most: restaurant ID (encodes average prep time), time of day, order item count, building type at destination, current driver distance from restaurant, zone-level demand density. The model outputs an adjustment in seconds that you add to the routing estimate.
Serve the model from a low-latency inference endpoint. The ETA is in the critical path for both the customer-facing order confirmation response and the driver assignment flow. Target p99 under 50ms. Feature computation should be pre-materialized where possible; avoid real-time joins during inference.
Surge Pricing Mechanics
Surge pricing is a supply/demand signal, not a revenue optimization tool (the revenue effect is secondary). The goal is to bring drivers into undersupplied zones and to reduce demand enough that fulfillment quality stays high.
The trigger is a ratio: orders awaiting assignment divided by available drivers in a geographic zone, measured over a rolling window.
interface ZoneDemandSnapshot {
zoneId: string;
pendingOrders: number;
availableDrivers: number;
timestampMs: number;
}
function computeSurgeMultiplier(snapshot: ZoneDemandSnapshot): number {
if (snapshot.availableDrivers === 0) {
return 2.0; // Max surge when no supply at all
}
const ratio = snapshot.pendingOrders / snapshot.availableDrivers;
// Stepped multipliers reduce customer confusion vs. continuous function
if (ratio < 1.5) return 1.0;
if (ratio < 2.5) return 1.2;
if (ratio < 4.0) return 1.5;
if (ratio < 6.0) return 1.8;
return 2.0;
}
A few production details that matter:
- Use a smoothed signal, not instantaneous ratio. A single large order batch can create a momentary spike; exponential moving average over 5-10 minutes avoids oscillation.
- Commit the surge multiplier to the order at placement time. Never adjust the price on an already-placed order.
- Display the multiplier prominently before checkout. The legal and trust implications of hidden surge are significant.
- Log every surge computation with its input snapshot. You will need this for customer support disputes and driver earnings questions.
Distributed Systems Challenges
Order State vs. Driver State Consistency
Order state lives in the order service; driver state (availability, location, current assignment) lives in the driver service. These are separate services with separate data stores. When you assign a driver to an order, you need both to update atomically, but they can’t share a transaction boundary.
The practical solution is saga-based coordination. The matching service acts as the orchestrator:
- Reserve the driver (mark as assigned, but hold a timeout).
- Update the order with the assigned driver ID.
- Confirm the driver reservation.
- If step 2 or 3 fails, release the driver reservation.
Driver reservations use an optimistic lock: a version number on the driver record. The reservation step fails if the driver was concurrently assigned elsewhere. At high contention, you’ll want to retry with the next-ranked candidate rather than re-running the full matching query.
Geolocation Streaming
Driver location updates arrive as a high-frequency stream. The consumer of that stream is twofold: the geospatial index for matching queries, and the ETA refresh pipeline for active deliveries.
Ingest through Kafka. Partition by driver ID so per-driver updates are ordered. The geospatial index consumer can batch-update positions every few seconds without meaningfully degrading matching quality. The ETA consumer tracks only drivers with active deliveries and recomputes ETA on each position update.
One subtle issue: clients polling for ETA updates should receive smoothed positions, not raw GPS noise. A Kalman filter on the driver position stream, or simple exponential smoothing, reduces jitter in customer-facing map display.
Handling Restaurant and Driver Failures
Restaurants go silent (tablet offline, printer jammed). Orders in pending state need a timeout after which they auto-cancel and the customer is notified. Set this conservatively: 5-7 minutes. A restaurant that comes back online after 8 minutes shouldn’t be able to accept an order the customer already cancelled.
Drivers drop out mid-delivery. When a driver goes offline during picked_up or delivering state, you have a harder problem. You can’t easily reassign: the food is in the driver’s possession. The right response is to wait for reconnection (drivers often lose signal temporarily) up to a threshold, then escalate to manual ops review. Automated reassignment of in-progress deliveries is risky and generates customer trust problems.
Implement dead man’s switches on driver sessions: if no location ping arrives for 90 seconds during an active delivery, trigger an alert workflow. Don’t immediately cancel; just surface it to a human.
Tradeoffs
| Decision | Option A | Option B | When to prefer A |
|---|---|---|---|
| Geospatial store | Redis GEOSEARCH | H3 + custom sharding | Low-to-mid fleet size, simpler ops |
| ETA computation | Pure routing graph | Routing + ML residual | ML adds value at high order volume with historical data |
| Driver matching | Nearest driver only | Weighted scoring (proximity, load, fairness) | When driver churn is a business concern |
| Surge pricing signal | Instantaneous ratio | EMA-smoothed ratio | Always prefer smoothed to avoid oscillation |
| Driver-order consistency | Two-phase commit | Saga with optimistic locking | Saga is the right default for cross-service transactions |
| Order state storage | Mutable row update | Event sourcing + projection | Event sourcing when audit trail and replay matter |
Production Considerations
Idempotency on order placement. Clients retry on network failure. Without idempotency keys, a customer gets charged twice. Require a client-generated idempotency key on every order creation request and deduplicate at the API layer before touching any downstream service.
Clock skew in state machine validation. If your order service and restaurant service run on separate hosts, don’t use wall clock for cancellation window checks. Use the order’s event timestamps, not Date.now() at validation time.
Read replicas for ETA queries. ETA computations are read-heavy. Route them to read replicas of your routing graph store. The graph updates less frequently than location data, so replica lag is acceptable at a few seconds.
Driver location data retention. Raw GPS trails are sensitive. Define a retention policy early: aggregate into trip-level records after delivery completion, delete raw pings. This is not just a privacy concern; storing high-frequency raw GPS at scale is expensive and rarely needed after the fact.
Backpressure on the location update stream. At peak, location updates can arrive faster than downstream consumers process them. Implement consumer lag monitoring on your Kafka consumer groups. If lag grows, the geospatial index falls behind and matching degrades. Size your consumer fleet with headroom for 2-3x normal peak (dinner rush on a rainy Friday).
Graceful degradation of ETA. If the ML inference endpoint is down, fall back to routing-only ETA. If the routing service is degraded, fall back to a static estimate based on historical median for the zone and time of day. The customer seeing a slightly inaccurate ETA is better than seeing an error.
Closing
The hard parts of this system are not the algorithms. Dijkstra is textbook. The hard parts are the consistency boundaries between services that each own a slice of the overall order-fulfillment state, the failure modes that appear only under real geographic and temporal load patterns, and the data freshness tradeoffs that determine whether your ETA is trustworthy. Build the state machine first, enforce it strictly, and let everything else derive from it.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.