Design 10-minute grocery dispatch

From "can you deliver here?" to a delivery partner leaving the dark store with the bag. Serviceability, stock reservation per store, partner assignment and deadline timers that survive restarts all have to fit inside a ten-minute promise.

SwiftKart promises groceries in 10 minutes from dark stores (small warehouses that only serve online orders). Design what happens from the moment a customer opens the app at an address to a delivery partner riding away with the order. Cover which store serves them, reserving stock, picking, assigning a partner, and what happens when an order is running late.

Requirements

Functional

  • Serviceability: for a location, return the dark store that serves it and a live ETA, or "not serviceable right now".
  • Placing an order reserves stock at that store in one atomic step. Payment confirms the reservation; failure or timeout releases it.
  • Store staff get a pick list. A delivery partner is assigned to arrive as the bag is packed.
  • Each stage (pick, assign, handover, delivery) has an SLA timer, a deadline. When a deadline is missed, the system escalates or compensates the customer.

Non-functional

  • Serviceability p99 under 100 ms (99 out of 100 calls are faster). It is called on every app open and address change.
  • No overselling: a reservation is consistent per store and SKU (stock-keeping unit, one product such as "Amul milk 500 ml").
  • Peak of about 50 orders per second and about 10,000 partner location pings per second. A partner is assigned within 60 seconds of the order.
  • Reservations for unpaid orders are released within 10 minutes. Timers survive service restarts.

Out of scope

  • Catalogue search and recommendations.
  • Restocking and demand forecasting for dark stores.
  • Payment internals and batching several orders onto one partner (follow-up).

Estimate

  • Orders: 500K a day; most within ~14 waking hours = 500K / 50,400 s = ~10 orders/s; dinner peak ~5x = ~50 orders/s.
  • Serviceability: 2M daily users x ~5 checks (app opens, address changes) = 10M a day = ~115/s average, ~600/s at peak. Small, but it is on the first screen, so it must be fast and cached.
  • Time budget for 10 minutes: ~2 min pick and pack + ~1 min handover + ~6 min ride + ~1 min buffer. At ~20 km/h in city traffic, 6 min is ~2 km, so each store serves roughly a 2 km radius.
  • Stock rows: say 400 stores x 5,000 SKUs = 2M rows. Small for one SQL cluster; the risk is hot rows (milk and bread at 7 am), not size.
  • Partner pings: canonical ~10,000 writes/s, the same shape as the rider location problem: in-memory cell index with TTL.
  • Timers: 50 orders/s x 4 stages = ~200 timers created/s, almost all cancelled on time. Fine for a durable scheduler, wasteful as one thread sleeping per order.

API

  • GET /serviceability?lat=&lng= -> 200 {storeId, etaMin, open: true} | 200 {open: false, reason}
  • GET /stores/{id}/stock?skus= -> 200 {sku: available}
  • POST /orders {storeId, items[], addressId, idempotencyKey} -> 201 {orderId, state: "RESERVED", payBy}
  • POST /orders/{id}/events {event: PACKED | PICKED_UP | DELIVERED} -> 200 {state}
  • POST /assignments/{id}/accept (partner) -> 200 | 409 already assigned
  • POST /partners/me/location {lat, lng, ts} -> 204

Data model

  • store_cells(cell_id PK, store_id, base_eta_min): precomputed hex cells inside each store's delivery polygon
  • store_status(store_id PK, state OPEN | PAUSED, open_orders, free_partners, updated_at): live load, cached
  • stock(store_id, sku, on_hand, reserved, version) PK (store_id, sku)
  • orders(order_id PK, user_id, store_id, state, promised_at, idempotency_key UNIQUE); order_lines(order_id, sku, qty)
  • partners(partner_id PK, home_store_id, status, current_order_id, version)
  • timers(timer_id PK, order_id, stage, due_at, state SCHEDULED | FIRED | CANCELLED)

A request, step by step

  1. On app open, serviceability maps the location to a hex cell (one hexagon in a grid laid over the city). It looks up the store for that cell and the store's live load in the cache. It returns an ETA, or "not serviceable" if the store is paused or overloaded.
  2. The customer places an order with an idempotency key, a unique id that makes a retry safe. In one transaction, the order service reserves each line: "reserved = reserved + qty WHERE on_hand - reserved >= qty". If any line fails, nothing is reserved, and the app offers to drop that item. The order becomes RESERVED, with a pay-by time.
  3. The UPI webhook (a call from the payment gateway to our server) confirms payment. The order becomes CONFIRMED, and an event goes to the stream. The store tablet gets the pick list. Timers are scheduled for pick (2 minutes), assignment (1 minute) and delivery (10 minutes).
  4. The assignment worker finds free partners at or near the store. It picks the one whose arrival best matches the expected pack time. It claims that partner with a conditional update, so no partner gets two orders. The partner accepts in the app.
  5. Each stage event (PACKED, PICKED_UP, DELIVERED) cancels its timer. A timer that fires escalates: assign a new partner, alert the store lead, or tell the customer about the delay and give a coupon. Unpaid reservations expire and release their stock.

Deep dives

Serviceability: radius, polygons or live load

Weak: a pincode list or a fixed radius. Pincodes in Indian cities are large and irregular. A radius ignores flyovers, rivers and gated societies.

The bigger problem is that both ignore load. A store with 40 open orders and no free partners still says "10 minutes".

Good: precomputed polygons stored as hex cells. The operations team draws each store's delivery polygon, based on real ride times. You precompute the hex cells inside it into a table that maps each cell to a store.

A lookup is one cell calculation and one cache read, well under 100 ms. When two stores cover the same cell, a priority rule decides.

Great: serviceability that knows the live load. Combine the static cell map with live store status: open orders, packers on shift and free partners. Update it every few seconds and cache it.

If a store is overloaded, you have options. Shrink its area to the inner rings of cells. Show a longer, honest ETA. Send the order to a neighbour store if it can still keep the promise. Or pause new orders for a short time. In rain mode, ride times go up for every cell at once.

Trade-off: with live load, the answer can change from minute to minute. That can annoy a user who saw "serviceable" a moment ago. But a promise you can keep is worth more than a promise that never changes.

Reserving dark-store inventory

Weak: check stock when the item is added to the cart, and reduce it when packing. Two customers buy the last packet of milk. The picker finds the shortage three minutes into a ten-minute promise.

Good: reserve when the order is placed. In one transaction, increase reserved for every line, only "WHERE on_hand - reserved >= qty". Record the reservation with an expiry.

When payment succeeds, the reservation becomes committed. If payment fails, or after 10 minutes, a release job puts the stock back. The database now makes overselling impossible.

Great: use the same guarded reservation, and also accept that the shelf does not always match the system. Stock in the system drifts from stock on the shelf, through damage and misplaced items. Keep a small safety buffer per SKU, and hide items that fall below it.

When a picker still finds a shortage, the flow offers a substitute or an automatic partial refund. The correction updates on_hand immediately.

At 7 am, popular items such as milk and bread are rows touched by many orders at once. Keep transactions short: update all lines in one statement. Order the lines by SKU, so two transactions never wait on each other in a deadlock. Never keep the transaction open while you call the payment gateway.

Trade-off: reserving before payment locks stock for users who abandon checkout. A short expiry and a safety buffer limit that cost.

Partner assignment and SLA timers

Weak: assign a partner when the bag is packed, and track lateness with a sleep in the request handler. The partner still needs time to reach the store, so the bag waits. A restart also loses every timer kept in memory.

Good: assign at confirmation, with durable timers. As soon as payment is confirmed, find a free partner near the store. Claim them with a conditional update.

Store each stage deadline in a durable scheduler: a timers table that a scheduler checks, or a delay queue that delivers a message at a set time. Then a deploy does not forget them. Stage events cancel their timers, and a timer that fires escalates.

Great: time the assignment to the packing. Predict pack time from the order size and the store's backlog. Assign the partner whose arrival at the store matches it. A partner returning from a nearby drop may arrive at the right moment, ahead of one already waiting at the store.

Escalation happens in steps. At the pick deadline, alert the store lead. At the assignment deadline, search a wider area for partners. If delivery is at risk, tell the customer an honest ETA and give a goodwill credit. Timers are idempotent: a timer that fires twice must not give the credit twice.

Trade-off: predictive assignment needs good pack-time estimates. A wrong guess leaves a partner waiting. Start simple by assigning at confirmation, then improve it once you have data.

What breaks

  • The zone cache is unavailable. Serviceability falls back to the static cell map with a careful, longer ETA. Orders still go through.
  • A store's tablet goes offline. Pick timers fire, the store is paused automatically in serviceability, and the operations team is paged.
  • The order stream falls behind. Assignment and timers run late. Alert on consumer lag (how far the workers are behind), because every minute of lag eats into the 10-minute promise.
  • A partner app crashes after accepting. If there are no location pings for 60 seconds and no pickup event, a new partner is assigned and the first partner is released.

What each level is expected to cover

Mid level: Maps a location to a store. Reserves stock with a guarded update inside a transaction. Models the order state machine. Assigns partners without giving one partner two orders. Uses a durable scheduler for timeouts.

Senior: Starts from the 10-minute time budget to size the service area. Makes serviceability aware of store load. Handles the gap between system stock and shelf stock, and the busiest SKUs. Times partner assignment to the end of packing. Designs escalation in steps, with idempotent timers. Names the metrics that drive it, such as on-time rate per store.

Common mistakes

  • Ignoring the time budget, so the design cannot say how big a store's area should be.
  • Checking stock only when items go into the cart, which oversells when many people order at once.
  • Keeping a database transaction open during the payment call.
  • Timers kept in memory, which disappear on every deploy.
  • Serviceability that ignores store load, so the promise breaks exactly when demand peaks.

Follow-up questions

  • How would you give one partner two nearby orders without breaking either promise?
  • It starts raining across the city. What changes in serviceability and assignment?
  • How would you split one order across two stores if one lacks an item?
  • How do you measure whether the 10-minute promise is being kept, per store and per hour?
  • How would you shard orders and stock once one database is not enough?

Lessons behind this answer