Skip to main content

High level design

Local Delivery Service — DoorDash / Uber Eats


Problem Statement

Design a system that allows consumers to order food/goods from restaurants/stores, matches them with available drivers, tracks delivery in real-time, and ensures reliable payment processing. This is a geo-distributed, real-time matching problem with tight SLA requirements.


Scale & Requirements

Functional

  • Restaurants/merchants can manage menu, inventory, and orders
  • Consumers can search nearby restaurants, place orders, track delivery
  • Drivers can accept orders and update delivery status in real-time
  • Payments are processed securely (card, wallet, promotions)
  • Consumers receive ETA updates and driver location (within consent)

Non-Functional

  • Search: Merchants search within 10 minutes' max delivery distance <200ms p99
  • Matching: Match an order with a driver within 30s
  • Tracking: Driver location updates within 1–5s (not every second — bandwidth/battery)
  • Scale: 500M orders/year in a city, 50K concurrent active drivers, 100K concurrent deliveries
  • Availability: 99.95% for orders, matching, payment
  • Reliability: Payment processed atomically; never lose an order; drivers always sync state
  • Latency: Order submission <2s, ETA computation <1s, dispatch decision <5s

Scale Estimates (Metropolis, e.g., SF Bay Area)

  • 100 restaurants active, 1000 menu items each
  • 50K drivers, 100K concurrent active users
  • Peak: 1000 orders/min → 16K QPS during lunch/dinner
  • Driver location updates: 50K drivers × 1 update/10s = 5K QPS location writes
  • Order state changes: 100K active orders × state changes = 10K QPS database writes
  • Payment processing: 16K orders/min × cost per order = $1–3 revenue/order

High-Level Architecture

┌────────────────────────────────────────────────────────────────────┐
│ ORDER SUBMISSION PATH │
│ Consumer App → API Gateway → Order Service │
│ → Payment Service → Stripe / PayPal │
│ → Inventory Service → Restaurant inventory sync │
│ → Order Queue (Kafka) │
│ ↓ │
│ Matching Engine (consume from queue) │
│ ↓ find eligible drivers │
│ Dispatch Service → assign driver + send notification │
└────────────────────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────────────┐
│ REAL-TIME TRACKING PATH │
│ Driver App → Location Service (WebSocket) │
│ → Geospatial index (update active driver locations) │
│ Matching Engine │
│ ↓ consume location updates │
│ → recalculate ETA, check for repositioning opportunities │
│ → push updates to consumer (WebSocket) │
└────────────────────────────────────────────────────────────────────┘

Data Model

Orders (Postgres — transactional source of truth)

CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
consumer_id BIGINT REFERENCES users(id),
restaurant_id BIGINT REFERENCES restaurants(id),
driver_id BIGINT REFERENCES drivers(id),
status TEXT NOT NULL, -- pending | confirmed | prep | picked | transit | delivered | cancelled
items JSONB NOT NULL, -- [{item_id, qty, special_instructions}...]
subtotal NUMERIC(8,2),
tax NUMERIC(8,2),
delivery_fee NUMERIC(8,2),
promo_code TEXT,
total NUMERIC(8,2),
payment_method TEXT, -- card | wallet | cash
payment_status TEXT, -- pending | charged | refunded
pickup_address TEXT, -- restaurant location (normalized)
delivery_address TEXT,
delivery_lat DOUBLE PRECISION,
delivery_lng DOUBLE PRECISION,
estimated_pickup_time TIMESTAMP,
estimated_delivery_time TIMESTAMP,
actual_pickup_time TIMESTAMP,
actual_delivery_time TIMESTAMP,
driver_rating SMALLINT CHECK (driver_rating BETWEEN 1 AND 5),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX idx_orders_consumer ON orders(consumer_id, created_at DESC);
CREATE INDEX idx_orders_driver ON orders(driver_id, status);
CREATE INDEX idx_orders_restaurant ON orders(restaurant_id, status);
CREATE INDEX idx_orders_status ON orders(status, created_at DESC);

Order Items (Postgres)

CREATE TABLE order_items (
id UUID PRIMARY KEY,
order_id UUID REFERENCES orders(id),
menu_item_id BIGINT REFERENCES menu_items(id),
quantity INT,
unit_price NUMERIC(8,2),
special_req TEXT -- "no onions", "extra spicy"
);

Drivers (Postgres + Redis)

CREATE TABLE drivers (
id BIGINT PRIMARY KEY,
name TEXT,
phone TEXT,
vehicle_type TEXT, -- car | bike | scooter
status TEXT, -- online | offline | on_delivery
acceptance_rate NUMERIC(4,2), -- cancelled delivery rate
avg_rating NUMERIC(3,2),
total_deliveries INT,
documents JSONB, -- license, insurance expiry
created_at TIMESTAMPTZ
);

-- Hot state in Redis (updated every location ping)
KEY: driver:{id}:state
VALUE: { lat, lng, status, heading, speed, last_update_at }
TTL: 1 hour

Restaurants (Postgres + Redis)

CREATE TABLE restaurants (
id BIGINT PRIMARY KEY,
name TEXT,
lat DOUBLE PRECISION,
lng DOUBLE PRECISION,
address TEXT,
phone TEXT,
status TEXT, -- open | closed | pause_orders
avg_delivery_time INT, -- minutes
min_order NUMERIC(8,2),
delivery_zones POLYGON[], -- delivery boundaries (PostGIS)
created_at TIMESTAMPTZ
);

-- Hot state in Redis
KEY: restaurant:{id}:live_order_count
VALUE: { pending, prep, ready_for_pickup }

Order Submission & Payment

Flow

1. Consumer submits order
POST /api/orders/create
{
restaurant_id, delivery_address, items[], promo_code
}

2. Server validates:
- Restaurant is open & accepting orders
- Address is within delivery zone
- Menu items exist + quantities available
- Promo code valid (cached in Redis)
- Minimum order value met

3. Price calculation:
subtotal = SUM(item prices)
tax = subtotal * tax_rate[state]
delivery_fee = base + surge_multiplier * base
total = subtotal + tax + delivery_fee - discount

4. Payment authorization:
- For card: Stripe charge (idempotency_key = orderId)
- For wallet: check balance, reserve funds (2-phase commit)
- For cash: mark as pending payment
- On failure: return error, order not created

5. Write to DB (Postgres):
INSERT INTO orders { consumer_id, restaurant_id, status: 'pending', ... }
INSERT INTO order_items (bulk)

6. Publish OrderCreated event (Kafka):
Topic: orders.created
Payload: { orderId, restaurantId, deliveryLat, deliveryLng, estimatedValue }

7. Increment Redis counter:
INCR restaurant:{restaurantId}:live_order_count
INCRBY restaurant:{restaurantId}:order_value_pending total

8. Return to client:
{ orderId, status: 'pending', eta_to_restaurant: 5min }

Idempotency & Retries

Every order submission includes an idempotency key (e.g., hash of timestamp + device). Idempotent key stored in Redis:

KEY: idempotency:{key}
VALUE: { orderId, status, response }
TTL: 24 hours

If retry arrives within TTL → return cached { orderId, response }
If TTL expired → allow new order (user submitted duplicate intentionally)

Payment idempotency key passed to Stripe — prevents double-charging if the Stripe call succeeds but the response is lost.


Matching Engine (The Hard Part)

Matching must happen within 30s and satisfy multiple constraints:

Input: OrderCreated event with delivery location + restaurant location
Task: find best driver to assign

Constraints:
1. Driver is currently online
2. Driver's current location is within 15 min of restaurant
3. Driver's vehicle type matches order size (bike < 5kg, car otherwise)
4. Driver's rating ≥ merchant's requirement (e.g., restaurants want 4.5+)
5. Driver doesn't have &gt;X deliveries in flight (capacity)
6. Delivery location is within driver's acceptance zone
7. Driver hasn't rejected this restaurant recently

Algorithm: Hybrid Geo + ML

Phase 1: Candidate Pool (fast, 1s)

1. Geo query: find drivers within radius R of restaurant
Using Redis geospatial index (GEORADIUS)

GEORADIUS drivers:geo:locations 37.7749 -122.4194 15 km
→ returns [{driver_id, lat, lng, distance}...]

2. Filter candidates:
- Driver status = online & not at capacity
- Vehicle type appropriate
- Driver rating ≥ merchant threshold
- No recent rejections (Redis SCARD driver:{id}:rejections:{restaurantId})

3. Rank by distance + accept rate + avg rating
score = -distance + 0.1 * accept_rate + 0.05 * avg_rating

Phase 2: Detailed Scoring (ML, <2s)

For top 10 candidates, compute:
- ETA to restaurant pickup (using current location + traffic model)
- ETA from restaurant to consumer (using A/B tested routing)
- Total delivery time (pickup + delivery)
- Driver's SLA compliance rate for this distance
- Estimated tip (via ML model trained on historical tips)

ML score = w1 * eta_freshness + w2 * driver_reliability + w3 * expected_tip + w4 * distance_cost
- w5 * surge_multiplier (higher surge = lower score to avoid over-offering expensive deliveries)

Output: sorted list of {driver_id, score}

Assignment Strategy

if best_candidate.score > threshold:
ASSIGN order to best_candidate
SET driver:assignment_pending = orderId (mutex, atomic)
SEND push notification to driver: "New order at Restaurant X"
else:
ENQUEUE to matching pool (retry in 5s with expansion radius)

Acceptance Timeout

After SEND notification, wait 15 seconds for driver response

Outcome A: Driver accepts
UPDATE orders SET driver_id = ?, status = 'confirmed'
PUBLISH OrderAssigned event (Kafka)

Outcome B: Driver rejects
INCR driver:{driverId}:rejections:{restaurantId} # track to avoid re-assigning
Retry with next-best candidate

Outcome C: Timeout (15s, no response)
Treat as implicit reject (driver offline or not checking)
Retry with next candidate

Real-Time Tracking & ETA

Driver Location Sync

Driver app (mobile, Gig OS level):
- Polls location every 10s (battery/bandwidth optimized)
- POST /api/drivers/{id}/location { lat, lng, heading, speed }

Server:
- Writes to Redis geospatial index:
GEOADD drivers:geo:locations {lng} {lat} {driverId}
- Stores state in Redis (atomic with geospatial):
SET driver:{id}:state { lat, lng, status, heading, speed, ts }
- Publishes to Kafka topic: driver.location_updated
↓ consumed by ETA service, matching engine, analytics

ETA Computation

Consumer/restaurant wants to know: "When will order arrive?"

ETA components:
1. Restaurant prep time
Lookup: SELECT avg_prep_time FROM restaurants WHERE id = ?
Adjust: if restaurant has N pending orders, add 2 min per order
Result: 10–15 minutes

2. Driver pickup time
Current: distance(driver_location, restaurant_location)
Traffic model: historical + real-time traffic API (Google Maps)
Result: 2–8 minutes

3. Driver delivery time
Lookup: distance(restaurant, consumer_delivery_address)
Traffic model: same as above
Result: 5–15 minutes

Total ETA = prep_time + pickup_time + delivery_time
Send update to consumer every 30s or if ETA changes >1 min

Live Tracking (WebSocket or SSE)

Consumer opens app → /ws/orders/{orderId}

Server maintains connection + streams:
- Driver location (obfuscated to ~100m precision for privacy)
- Current ETA
- Order status changes
- Driver message ("Arrived at restaurant", "On the way")

Updates pushed as:
{
"type": "location_update",
"driver_lat": 37.7800, # slightly randomized
"driver_lng": -122.4100,
"eta_seconds": 480,
"updated_at": "2026-08-03T12:30:45Z"
}

Accuracy & Incentives

The matching and ETA systems must be well-calibrated:

Metric: SLA Compliance (% on-time deliveries)
Target: 95%+ delivered within promised ETA ± 5 minutes

Drivers are incentivized:
- Base pay + tips (paid by consumer at end of delivery)
- Bonus for high acceptance rate (reject rate &lt;20%)
- Bonus for on-time delivery (track vs promised ETA)

Restaurants benefit from:
- Lower delivery time → higher customer satisfaction → more orders
- Data: which prep times are realistic

Payment & Refunds

Charge Timing

Order created → charge card immediately (or reserve from wallet)
Risk: customer cancels after charge; must refund

Customer cancels BEFORE driver accepts:
Status: pending → cancelled
Refund: 100% (minus any active promotion credit)

Customer cancels AFTER driver accepts:
Status: confirmed → cancelled
Refund: 50–100% depending on how far driver traveled

Driver cancels AFTER pickup:
Status: transit → cancelled
Refund: 0% (blame driver, not consumer)

Delivery completed → charge settled
Can dispute within 7 days via customer service

Payment Methods

Primary: Stripe (cards)
→ Charge with idempotency_key = orderId
→ Webhook on charge success/failure
→ Auto-retry on transient failures (max 3 times over 1 day)

Wallet (prepaid balance):
→ Reserve funds: optimistic lock on user row
→ Commit on delivery completion
→ Refund on cancellation: optimistic release
→ Ledger table: every transaction logged for audit

Cash on delivery:
→ Order marked as cash
→ Driver collects from consumer
→ Mark as settled after driver confirms in app
→ Reconciliation job matches cash received vs expected

Surge Pricing & Dynamic Supply

Monitor:
1. Order volume (sliding window: orders/min)
2. Driver supply (online drivers in region)
3. Avg ETA (if growing, means supply constraint)

Adjust delivery_fee:
base_fee = 2.00
surge_multiplier = min(max_multiplier, supply_demand_ratio)

if orders/min > threshold AND available_drivers < min_threshold:
surge_multiplier = 1.5x (push consumer to use later, or pay more)
notify drivers: "High demand! $2 bonus per delivery"

if surge lasts >10 min:
trigger onboarding: "Drivers needed! Sign up now"

if surge goes away:
cap refund: consumer only refunded if delivery time >> promised ETA
(fairness: don't refund just because surge ended mid-delivery)

Failure Scenarios

FailureImpactMitigation
Driver goes offline mid-deliveryOrder stuck in transitTimeout after 10min offline → mark as missing → auto-assign new driver; last-known location queried
Payment fails at charge timeOrder not createdUser sees error, can retry; funds not reserved
Restaurant goes offlineCan't confirm orderOrder auto-cancelled within 2min; full refund issued
Driver rejects all candidatesOrder stuckExpand radius, lower rating threshold, increase surge; escalate to support after 5min
Database outageOrders can't be createdFail-safe: queue orders in Kafka; replay when DB recovers
Matching service outageNo new assignmentsCircuit breaker: fall back to simple "nearest available"; manual queue
Real-time tracking lagStale ETA/locationAcceptable: queue updates, resync every 30s; warn consumer if lag >2min

Infrastructure Scaling

ComponentTechnologyReasoning
OrdersPostgres (sharded by restaurant_id or delivery_zone)ACID, complex queries, idempotency keys
Hot driver stateRedis (geospatial + hash)Sub-second location updates, no disk I/O
MatchingCustom service (multi-threaded, geo-index in memory)<5s latency critical; Redis for candidates
ETAService with traffic API + historical modelCompute-heavy, cache results 5min per route
PaymentStripeOutsource PCI compliance, webhook handling
EventsKafkaDecouple order creation, matching, notifications
TrackingRedis Pub/Sub or WebSocket servers (stateless)Real-time push to mobile clients
AnalyticsData warehouse (BigQuery / Snowflake)Batch import from Kafka daily

Staff Interview Follow-ups

"How do you handle a driver on a delivery when the restaurant goes offline?" Restaurant goes offline (close of business, power outage) → marked as status = 'closed'. For any in-transit orders to that restaurant: check last-known status. If still waiting for pickup, immediately assign new restaurant with similar food + re-match driver OR initiate refund + apology credit. If already picked up, continue delivery. If stuck mid-way: driver notified, consumer notified with 50% refund option + credits.

"How do you prevent drivers from gaming the system (e.g., accepting then rejecting orders to farm bonuses)?" Track rejection patterns: driver:rejection_rate = rejections / total_offers. If rejection_rate > 20%, ineligible for surge bonuses that week. If rejection_rate > 40%, deactivate driver account pending review. ML model flags suspicious patterns: sudden rejections of high-value orders, rejections only during surge, rapid accept-then-reject cycles. Human review layer for borderline cases.

"How do you compute ETA when there's no historical data for a new restaurant or route?" Use base routing engine (Google Maps distance/time estimate) as fallback. For new restaurant, bootstrap: use avg prep time of similar restaurants (same cuisine, similar order volume) + gradually adjust as actual data arrives. For new route, use traffic model + distance. Confidence scoring: "ETA: 25–35 min" for uncertain routes. ML model trained on order volume, restaurant prep variability, and traffic to refine estimates over time.

"What happens if the matching engine assigns the same order to two drivers?" Atomic assignment in Redis: SET driver:\{id\}:assigned_order \{orderId\} NX. First driver to win the CAS lock gets assigned. Second driver (or reassignment retry) reads the order and sees it's already assigned, skips it. DB update for order.driver_id happens after Redis mutation confirms. Idempotency: if both submissions reach DB before the second reads Redis, unique constraint on (order_id, driver_id) with ON CONFLICT DO NOTHING prevents duplicate rows.

"How do you handle consumer complaints about delivery time or damaged food?" Consumer files complaint immediately or within 24h via app. Triggers support ticket + auto-refund policy: if delivered >15min late, 10% refund; if damaged/missing items reported with photo, replace item or 50% refund. Driver rating impact: customer rates delivery experience 1–5 stars; rating affects future order assignment priority. Multiple complaints on one driver → deactivation review. Data: track which restaurants have highest complaint rates (quality issue) vs which drivers (care issue).