Skip to content

Design a Local Delivery Service

TL;DR

A local delivery service lets users order food or groceries from nearby stores with delivery in under an hour. The interesting constraints are not the ordering flow (that is standard e-commerce) but the inventory problem: a restaurant's menu or a store's shelf stock changes continuously, the read-to-write ratio is 175:1, and the system must handle the case where an item shows as "in stock" when you add it to cart but is actually depleted by the time you check out. DoorDash uses the driver rejection rate as a real-time signal for menu item depletion. The adaptive cache TTL -- short for items that sell quickly, long for items that sit on shelves -- is the key insight that makes the system work without overwhelming small merchant POS systems with inventory queries.

The System

A local delivery service connects consumers with nearby restaurants and stores, dispatches delivery drivers, and fulfills orders within 30-60 minutes. The user browses stores, adds items to a cart, places an order, and a driver picks up the items and delivers them.

DoorDash processes over 2 million orders per day across 900,000+ merchants in the US, Canada, Australia, and Japan. Instacart handles grocery deliveries from 1,400+ retail banners. Uber Eats processes over 1 billion orders annually. The unit economics are brutal: DoorDash's average order value is ~$35, commission is ~20% ($7), driver pay is ~$5-8, leaving $0-2 margin per order. At this margin, operational efficiency is existential. A 1% increase in failed orders (item unavailable, driver no-show) can erase the entire profit margin. This is why inventory accuracy and dispatch optimization are the most critical engineering problems.

Requirements

Functional

  • Store discovery: Show nearby stores/restaurants based on user location, with estimated delivery time
  • Menu/catalog browsing: Display available items with prices, descriptions, and availability status
  • Order placement: Cart, checkout, payment, and order confirmation
  • Real-time order tracking: Track order status from placed to delivered
  • Driver dispatch: Assign a delivery driver to the order and optimize pickup/delivery routing
  • Inventory management: Track item availability and surface out-of-stock items before checkout

Non-Functional

  • Catalog read latency: Store menu/catalog loads in under 200ms (175:1 read-write ratio)
  • Order throughput: 2M orders/day = 23 orders/sec average, 100/sec peak (dinner rush)
  • Inventory freshness: Item availability reflects actual stock within 5 minutes for fast-moving items, 30 minutes for slow-moving items
  • Order success rate: 98%+ of placed orders are fulfilled without substitution (inventory accuracy is critical)
  • Availability: 99.9% uptime for the ordering path
  • Payment reliability: Zero dropped payments. At-least-once charge, exactly-once fulfillment

Back-of-Envelope Math

Stores/restaurants: 900K
Items per store: average 100 (restaurant menu) to 30,000 (grocery store)
Total catalog items: ~500M (most are grocery SKUs)

Read traffic:
  Active users browsing: 10M/day
  Average pages per session: 5 (store list, store detail, item details)
  50M page views/day = 579 views/sec
  Peak (3x): 1,737 views/sec
  Each page: 1-5 API calls = 2,893 - 8,685 API calls/sec peak

Write traffic:
  Menu/price updates: 500M items, ~1% change daily = 5M updates/day = 58/sec
  Order writes: 23/sec average, 100/sec peak
  Total writes: ~158 writes/sec peak

  Read:write ratio: 8,685 / 158 = ~55:1 for API calls
  For catalog data specifically: ~175:1 for catalog reads vs writes

Order data:
  Each order: ~2 KB (items, prices, addresses, driver assignment)
  2M orders/day * 2 KB = 4 GB/day
  Retain 90 days: 360 GB. Single database handles this.

Catalog storage:
  500M items * 500 bytes = 250 GB
  With indexing overhead: ~500 GB
  Fits in a single database with read replicas.

The Naive Design

┌──────────┐     ┌──────────────┐     ┌──────────────┐
│  User    │────>│  API Server  │────>│  PostgreSQL  │
│  App     │<────│  (monolith)  │<────│              │
└──────────┘     └──────────────┘     └──────────────┘

-- Browse store:
SELECT * FROM items WHERE store_id = 123 AND available = true;

-- Place order:
BEGIN;
UPDATE items SET quantity = quantity - 1 WHERE id IN (item_ids) AND quantity > 0;
INSERT INTO orders (...) VALUES (...);
COMMIT;

A single database, a single server. For a food delivery startup with 100 restaurants, this works for the first year.

Where Does This Break First?

The available flag. A restaurant's item availability changes when they run out of an ingredient, but the POS system (if it even has an API) does not push real-time updates. You are showing items as available that are actually sold out. The user orders, the restaurant rejects, the user has a terrible experience. Inventory accuracy, not throughput, is the first failure mode.

Where It Breaks

Problem 1: Inventory data is unreliable at the source. Most restaurants do not have real-time inventory APIs. A pizza place runs out of pepperoni at 7 PM. There is no automated system to tell your platform. The first signal is when the restaurant rejects the order, or worse, when the driver calls the customer to ask about substitutions. Item unavailability is a top source of customer complaints in food delivery.

Problem 2: The 175:1 read-write ratio creates a caching challenge. 175 reads for every write means aggressive caching is essential. But cache invalidation for inventory is tricky -- if an item sells out and the cache serves stale "available" data for 5 minutes, hundreds of users see an item they cannot actually order. The TTL must be adaptive: fast-selling items need short TTLs, slow-selling items can have long TTLs.

Problem 3: Race conditions during checkout. Two users add the last unit of an item to their carts simultaneously. Both see it as available. Both submit orders. Only one can be fulfilled. Without optimistic locking, one user gets charged and then told "sorry, item unavailable." The refund process creates friction and erodes trust.

Problem 4: Multi-item orders with partial availability. A grocery order has 30 items. Three are out of stock. Do you fulfill the remaining 27? Ask the user to approve substitutions? Cancel the entire order? Each choice has different UX implications and the system must handle all three paths.

Problem 5: The write path is deceptively simple. 23 orders/sec is trivial for a single PostgreSQL instance. But each order triggers a cascade: payment authorization, driver dispatch, restaurant notification, inventory update, ETA calculation, push notification. The fan-out from one order touches 8-10 services. The write path is a distributed transaction, and getting the saga pattern right is the actual engineering challenge.

The Real Design

┌──────────────┐                                    ┌──────────────┐
│  User App    │──── browse ──────────────────────>  │  Catalog     │
│              │<─── catalog data ────────────────── │  Service     │
│              │                                     │  (read-heavy)│
│              │                                     └──────┬───────┘
│              │                                            │
│              │                                     ┌──────v───────┐
│              │                                     │  Redis Cache │
│              │                                     │  (adaptive   │
│              │                                     │   TTL)       │
│              │                                     └──────────────┘
│              │
│              │──── order ────┐
│              │<── confirm ───│
└──────────────┘               │
                        ┌──────v───────┐
                        │  Order       │
                        │  Service     │
                        └──────┬───────┘
           ┌───────────────────┼───────────────────┐
           │                   │                   │
    ┌──────v───────┐  ┌────────v──────┐  ┌─────────v─────┐
    │  Payment     │  │  Inventory   │  │  Dispatch     │
    │  Service     │  │  Service     │  │  Service      │
    │  (auth hold) │  │  (optimistic │  │  (driver      │
    │              │  │   locking)   │  │   matching)   │
    └──────────────┘  └──────────────┘  └───────────────┘
           │                   │                   │
           └───────────────────┼───────────────────┘
                               │ saga coordinator
                        ┌──────v───────┐
                        │  Order Saga  │
                        │  (state      │
                        │   machine)   │
                        └──────────────┘

The 175:1 Read-Write Ratio

For a food delivery catalog (store menus and item availability), the estimated read-to-write ratio is ~175:1. This is the most important number in the system because it determines the caching strategy.

At 175:1, aggressive caching is not just an optimization -- it is a requirement. Without caching, the catalog database handles 8,685 reads/sec at peak. With a 95% cache hit rate, database load drops to 434 reads/sec. That is the difference between needing a fleet of read replicas and needing a single instance.

Cache architecture:

User request -> CDN (static assets) -> API Gateway
  -> Redis Cache (catalog data, 95% hit rate)
  -> PostgreSQL read replica (cache miss, 5%)
  -> PostgreSQL primary (writes only)

DoorDash Rejection Rate as Depletion Signal

This is the most clever engineering insight in the system. DoorDash uses the driver/restaurant rejection rate for a specific item as a proxy for real-time inventory depletion.

def track_item_availability(store_id, item_id, event):
    key = f"rejection:{store_id}:{item_id}"

    if event.type == "order_rejected" and event.reason == "item_unavailable":
        redis.incr(key)
        redis.expire(key, 3600)  # 1-hour window

        rejection_count = redis.get(key)
        if rejection_count >= 3:
            # 3 rejections in an hour -> mark item as likely unavailable
            mark_item_unavailable(store_id, item_id)
            # Shorten cache TTL for this store's catalog
            set_cache_ttl(f"catalog:{store_id}", ttl=60)  # 1-minute refresh

    elif event.type == "order_fulfilled" and item_id in event.items:
        # Reset rejection count on successful fulfillment
        redis.delete(key)

Why this works: When a restaurant runs out of an item, the first few orders containing that item get rejected. After 3 rejections, the system learns the item is unavailable and stops showing it. The feedback loop takes 10-15 minutes (3 orders * 5-minute average order gap), which is imperfect but far better than waiting for the restaurant to manually update their menu (which may never happen).

False positives: Sometimes a restaurant rejects an order for reasons unrelated to item availability (too busy, closing early). The system must distinguish between "item unavailable" rejections and other rejection types. DoorDash's tablet interface gives restaurants specific rejection reasons to click.

Adaptive Cache TTL by Stock Level

Not all items deserve the same cache TTL. A popular dish that sells 200 orders per dinner rush needs a shorter TTL than a specialty item that sells 2 per week.

def calculate_adaptive_ttl(store_id, item_id):
    # Historical sell rate (orders per hour at current time-of-day)
    sell_rate = get_sell_rate(store_id, item_id, current_hour())

    if sell_rate > 20:  # sells every 3 minutes
        return 60       # 1-minute TTL (check availability frequently)
    elif sell_rate > 5:  # sells every 12 minutes
        return 300       # 5-minute TTL
    elif sell_rate > 1:  # sells every hour
        return 900       # 15-minute TTL
    else:
        return 1800      # 30-minute TTL (slow-moving item)

TTL per store category:

Store type Fast items TTL Slow items TTL Rationale
Fast food 2 min 15 min Limited menu, items sell out quickly
Full restaurant 5 min 30 min Larger menu, more predictable stock
Grocery 1 min 60 min Some items (bananas) deplete fast
Convenience 5 min 60 min Small store, stock changes slowly

Optimistic Locking for Inventory

When two users try to order the last unit of an item simultaneously, optimistic locking prevents double-selling.

-- Each item has a version number
-- Checkout reads the version, then updates with a version check

-- Step 1: Read item and version
SELECT id, quantity, version FROM items WHERE id = 123;
-- Returns: quantity = 1, version = 47

-- Step 2: Update with version check (optimistic lock)
UPDATE items 
SET quantity = quantity - 1, version = version + 1
WHERE id = 123 AND version = 47 AND quantity >= 1;

-- If 0 rows updated: version changed (another order got it first)
-- Retry or return "item unavailable"

Why optimistic over pessimistic: Pessimistic locking (SELECT FOR UPDATE) holds a row lock during the entire checkout flow (3-10 seconds). At 100 orders/sec peak, this causes lock contention. Optimistic locking never holds locks -- it detects conflicts at write time. Since conflict rate is low (< 1% of items are ordered simultaneously), optimistic locking has nearly zero overhead in the common case.

Multi-DC Orders with Saga Pattern

A single order touches multiple services: payment, inventory, dispatch, notification. If any step fails, the preceding steps must be compensated (rolled back).

Order Saga:
  Step 1: Payment Service -> authorize $35 hold on credit card
  Step 2: Inventory Service -> reserve items (decrement stock)
  Step 3: Restaurant Service -> send order to restaurant tablet
  Step 4: Dispatch Service -> assign driver

  Compensation (if Step 3 fails):
    Undo Step 2: release item reservation
    Undo Step 1: void payment authorization

  Compensation (if Step 4 fails):
    Wait for driver (retry 3 times, expanding search radius)
    If no driver after 15 minutes: cancel order
      Undo Step 3: cancel restaurant order
      Undo Step 2: release item reservation
      Undo Step 1: void payment authorization
      Notify user: "No drivers available, order cancelled"

Why sagas, not distributed transactions? Two-phase commit (2PC) requires all participants to hold locks until all agree to commit. With 4 services, one slow service (payment gateway taking 5 seconds) blocks all others. Sagas allow each step to complete independently and compensate on failure. The trade-off: sagas are eventually consistent (there is a brief window where the payment is authorized but items are not yet reserved), but this window is milliseconds.

PostgreSQL Handles the Write Load

A common mistake in this interview: over-engineering the write path. Let's verify that a single PostgreSQL instance handles the write load.

Order writes: 100/sec peak (each order = 1 INSERT + 3-5 UPDATE for saga steps)
  = 500 writes/sec peak

Inventory updates: 
  Order-related: 100 orders/sec * 5 items/order = 500 updates/sec
  Menu updates from merchants: 58/sec
  = 558 updates/sec

Total writes: ~1,058 writes/sec peak

PostgreSQL on a db.r6g.xlarge (4 vCPU, 32 GB RAM):
  Handles 10,000+ writes/sec for simple INSERT/UPDATE
  Our 1,058 writes/sec is 10.6% of capacity.

You do not need database sharding for 2M orders/day. A single PostgreSQL primary with 2 read replicas handles the entire write load with 90% headroom. The read replicas serve the 175:1 read traffic for cache misses.

When do you need to shard? At ~20M orders/day (10x), write load reaches ~10K writes/sec. At that point, shard the orders table by geographic region (cities are naturally independent). Inventory data for store X does not need to be on the same shard as store Y -- they are in different cities.

Deep Dives

Local Delivery — Local Delivery High-Level Design

Deep Dive 1: Delivery Time Estimation

Delivery time is the single most important number on the store listing page. If you promise 30 minutes and deliver in 45, the user does not order again.

Components of delivery time:

Delivery Time = Restaurant Prep Time 
              + Driver Pickup Time (travel to restaurant)
              + Driver Delivery Time (travel to user)
              + Buffer

Example:
  Prep time: 15 min (predicted from historical data for this restaurant)
  Driver pickup: 8 min (distance from nearest driver to restaurant)
  Driver delivery: 10 min (distance from restaurant to user)
  Buffer: 2 min (parking, building access)
  Total: 35 min

Prep time prediction: Each restaurant has a distribution of prep times. A fast food chain averages 8 minutes; a sit-down restaurant averages 25 minutes. The model is trained on historical order-to-ready times, adjusted for current order volume (if the restaurant has 10 pending orders, prep time is longer).

Dynamic adjustment: If 3 out of the last 5 orders from a restaurant were late, increase the estimated prep time by 20%. This prevents the system from continuously under-promising.

Deep Dive 2: Store Search and Ranking

When a user opens the app, they see a list of nearby stores ranked by relevance.

Ranking factors:

def rank_stores(user_location, stores):
    for store in stores:
        store.score = (
            0.30 * delivery_time_score(store, user_location)  # faster = higher
          + 0.25 * user_affinity(store, user)                 # past orders, favorites
          + 0.20 * store_quality(store)                       # ratings, order success rate
          + 0.15 * promotion_boost(store)                     # sponsored placement
          + 0.10 * cuisine_relevance(store, user, time_of_day) # sushi at lunch
        )
    return sorted(stores, key=lambda s: s.score, reverse=True)

Delivery time as the top factor: DoorDash places the highest weight on delivery time because it directly correlates with order conversion. A store that can deliver in 20 minutes converts 3x better than one that takes 50 minutes, all else being equal.

Sponsored listings: Restaurants pay for promoted placement. This is a significant revenue stream (DoorDash's "DashPass" and promoted listings generate substantial revenue). The ranking algorithm ensures sponsored listings appear in the top 5 but are clearly labeled.

Deep Dive 3: Driver Dispatch and Routing

When an order is placed, the dispatch service assigns a driver. The optimization is different from ride-sharing because the driver must go to the restaurant first, then to the user.

Batched dispatch: If two orders are from the same restaurant (or nearby restaurants) and going to nearby destinations, assign both to the same driver. The driver picks up both orders in one trip.

def batch_orders(pending_orders):
    # Group by restaurant proximity (within 500m)
    clusters = cluster_by_location(pending_orders, radius=500)

    for cluster in clusters:
        if len(cluster) >= 2:
            # Check if destinations are also close (within 2 km)
            dest_spread = max_distance(cluster, key=lambda o: o.delivery_location)
            if dest_spread < 2000:
                # Batch these orders for one driver
                create_batched_delivery(cluster)
            else:
                # Too far apart, dispatch individually
                for order in cluster:
                    dispatch_single(order)

Driver assignment optimization: Unlike ride-sharing (minimize pickup time), delivery dispatch optimizes for total time from now until the last delivery is completed. Factors:

  1. Driver's current location
  2. Time for driver to reach restaurant
  3. Expected food prep time remaining (food should be ready when driver arrives)
  4. Distance from restaurant to delivery location
  5. If batching: total route time for multiple deliveries

The ideal dispatch sends the driver to arrive at the restaurant exactly when the food is ready. Arriving 10 minutes early means the driver is idle. Arriving 5 minutes late means the food is cold.

Alternative Designs

Alternative 1: Event-Sourced Inventory

Every inventory change is an event (stock_added, stock_depleted, order_placed, order_cancelled). Current stock is derived by replaying events. Enables perfect audit trail and time-travel queries.

Alternative 2: POS Integration (Real-Time Inventory)

Directly integrate with restaurant POS systems (Square, Toast, Clover) for real-time inventory data. Eliminates the depletion-signal approach.

Alternative 3: Pre-Order Model (Scheduled Delivery)

Users order hours in advance. The restaurant confirms item availability before committing. Eliminates real-time inventory problems at the cost of spontaneity.

Aspect Adaptive Cache + Signals Event-Sourced Inventory POS Integration Pre-Order Model
Inventory accuracy 90-95% (signal-based) 99%+ (event-sourced) 99%+ (real-time POS) 100% (confirmed)
Integration complexity Low (no merchant work) High (new data model) Very High (1000s of POS) Low (merchant confirms)
User experience Occasional stockouts Accurate availability Accurate availability No spontaneous ordering
Merchant onboarding Easy (just a menu) Hard (event modeling) Hard (POS API access) Easy
Latency Sub-200ms (cached) Sub-200ms (projections) Depends on POS API N/A (async)
Operational cost Low Medium (event store) High (integration maint) Low

The adaptive cache with depletion signals is the right starting answer because it works without any merchant-side integration. As the platform grows and specific merchant categories dominate (e.g., grocery), add POS integration for those categories. Event sourcing is a good fit for the order service (audit trail, financial compliance) but overkill for restaurant menu data.

Scaling Math Verification

Catalog reads (8,685 API calls/sec peak):

  • Redis cache hit rate: 95%
  • Redis reads: 8,251/sec. Single Redis instance handles 200K+/sec. 4% utilization.
  • Database reads (cache miss): 434/sec. Read replica handles this easily.

Order writes (100/sec peak):

  • PostgreSQL writes: ~1,058/sec total. Single primary at 10% capacity.
  • Saga steps per order: 4 service calls * 100ms each = 400ms per order.
  • With 20 order service instances: 20 * (1000ms / 400ms) = 50 orders/sec per instance. Need 2 instances for 100/sec peak.

Inventory updates (558/sec):

  • Optimistic lock contention rate: < 1% of updates conflict.
  • Retry overhead: 558 * 0.01 = 5.58 retries/sec. Negligible.

Driver dispatch (100/sec):

  • Each dispatch: find nearby drivers (1ms), estimate routes (10ms), assign (1ms) = 12ms
  • Single dispatch instance: 83 dispatches/sec. Need 2 instances for peak.

Failure Analysis

Component Current capacity At 10x (20M orders/day) Breaks? Fix
Redis catalog cache 200K+ ops/sec Still 200K+ ops/sec No More items cached, but ops/sec doesn't grow 10x
PostgreSQL writes 10K+ writes/sec ~10K writes/sec needed Maybe Shard by city/region
Order service 2 instances 20 instances No Horizontal scaling
Payment service 100 txns/sec 1,000 txns/sec No Payment gateway handles this
Dispatch optimization 2 instances 20 instances No Horizontal scaling
Catalog storage 500 GB 5 TB Yes Shard by store_id, archive old menu versions
Driver location index 100K drivers 1M drivers No H3 grid scales with driver count
Saga coordinator 100 sagas/sec 1,000 sagas/sec No Temporal handles 10K+ workflows/sec

The first bottleneck at 10x is database writes approaching single-instance limits. The natural sharding key is geographic region (city or metro area). Orders from San Francisco never need to join with orders from New York. Each region gets its own PostgreSQL shard with its own catalog data, order tables, and inventory.

The more interesting bottleneck is catalog accuracy. At 10x scale (9M merchants), maintaining inventory signals across 9M stores requires a much larger signal-processing pipeline. The depletion signal approach scales linearly (more orders = more signals), but the cold-start problem worsens: new merchants with no order history have no signals, so the system cannot predict stockouts.

What's Expected at Each Level

Aspect Mid-Level Senior Staff+
Inventory model Boolean available flag Stock levels with cache, mentions staleness Depletion signal from rejection rate, adaptive TTL per item
Caching "Cache the menu" TTL-based cache with invalidation 175:1 ratio, adaptive TTL, per-item sell-rate-based TTL
Concurrency Not discussed Database locking for checkout Optimistic locking with version column, quantifies contention
Order lifecycle REST API calls State machine with database Saga pattern with compensation, Temporal for orchestration
Database scaling "Shard the database" Explains why sharding is not needed at stated scale Single PostgreSQL handles write load, shards at 10x by region
Dispatch "Assign nearest driver" Considers restaurant prep time Batched dispatch, timing driver arrival with food readiness
Delivery time Distance / speed estimate Prep time + travel time components ML-based prep prediction, dynamic adjustment from recent data
Real-world reference "Like DoorDash" Mentions read-write ratio DoorDash rejection signals, specific POS integration challenges

The single most important signal at any level: do you understand that inventory accuracy is the hardest problem, not order throughput? The write path is trivially handled by a single PostgreSQL instance. The inventory problem -- knowing what is actually available on a restaurant shelf that has no real-time inventory API -- is where the engineering challenge lies. The depletion signal approach demonstrates real-world thinking that no amount of database scaling can solve.


References from Our Courses


Red Team This Design

Ready to stress-test this architecture? The Attack companion tears apart every decision in this design — from hardware physics to security holes to what actually happens at 10x scale.

Attack: Design a Local Delivery Service →