Want it harder? Hide the model answer and work each phase in a scratchpad against its time budget, then reveal and score yourself. Or simply read on: the checkpoints below work either way.
How Do You Get Milk, Chips and a Phone Charger to a Door in Ten Minutes Without Selling a Packet You Don't Have?
It is 9:04 PM on a Friday. A customer in an apartment block opens the app, adds milk, a packet of chips, two cold drinks and a phone charger, and taps "Pay". Four seconds later the order is confirmed with an arrival estimate of eleven minutes.
Two kilometres away, inside a warehouse with no customers and no shop front, a handheld scanner buzzes. A picker walks a pre-computed path through the aisles, ambient shelves first and the cold room last, scans five items into a bag and drops it on a dispatch rack. A rider who returned from his last drop a minute earlier picks it up. Nobody on that rider's screen is counting down.
Multiply that by a few million orders a day, across thousands of stores, with the stock in every store changing every minute, and the whole thing getting harder exactly when it matters: at 9 PM, in the rain, on the night of a cricket final.
- 2,443 — dark stores at the end of June 2026 (200 added that quarter)
- 331 million — orders in April-June 2026, about 3.6 million a day
- ₹17,132 crore — net order value in that quarter
- ₹518 — net average order value
- 31.8 million — average monthly transacting customers
- 2.5 min · ~2 km · ~8 min — average pick and pack time, ride distance and ride time (as stated by Deepinder Goyal, then Eternal's CEO, January 2026)
- 75 lakh+ — orders across Zomato and Blinkit on New Year's Eve 2025, the group's biggest single day at the time
- 1.8% — inventory losses as a share of net order value
For context:
Blinkit is part of Eternal Ltd (formerly Zomato Ltd), which bought it in 2022
In January 2026 Blinkit removed the "10-minute delivery" claim from its branding after the labour ministry asked platforms to avoid fixed delivery-time commitments
The speed was always meant to come from store density, not from riders driving faster
Everything in this design that is not in the list above is an illustrative design target, not Blinkit's internal architecture
This is the system we'll design today.
Blinkit runs 2,443 dark stores and ~3.6 million orders a day; a true ten minutes leaves about 6 minutes of riding, ~1.5 km of road, so store density, not rider speed, is the lever. Checkout reserves stock then takes payment, an outbox sends the order to a deadline-ordered picker queue, a rider is planned while picking, and a promise engine publishes honest p90 estimates. The deep dives cover store selection, reservations, the saga, picking and batching, then store modes, a store going dark and rain on match night.
The Interview Begins#
You're interviewing at a company building a quick-commerce platform for Indian cities. The head of engineering pulls up a map dotted with small squares.
"Each square is a dark store: a small warehouse with no walk-in customers. A customer within a couple of kilometres orders, and the basket should be at their door in about ten minutes. Design the system that makes that happen, a few million times a day."
A customer opens the app, sees what their nearest dark store has in stock right now, pays, and receives the basket in about ten minutes. Thousands of stores, a few million orders a day, and a sharp evening peak.
Requirements
- Show only what the serving store can actually deliver, and when
- Never confirm an order the shelf can't fill (or say so honestly, with a substitute or a refund)
- Charge exactly once, refund automatically when anything goes wrong
- Pick and pack in about two and a half minutes
- Assign a rider, batch two orders when it costs nobody time, and track the ride live
- Keep the promise under load: rain, 9 PM, match nights, festivals
- Never make the rider absorb the variance: the speed comes from proximity and the store, not the road
"Most candidates spend this interview on rider routing. The road is the part you can least control. Show me where the minutes really go."
Phase 1: Requirements Clarification (5 minutes)#
"Before I draw anything, I want to know what 'ten minutes' actually means here, and where the stock lives."
Your Questions#
"Is ten minutes a guarantee, a target, or a slogan?"
"Treat the promise as the product, and treat it honestly. Blinkit built its brand on ten-minute delivery, and in January 2026 it removed that claim from its branding after the labour ministry asked platforms to drop fixed delivery-time commitments for the sake of rider safety. So the requirement is: show every customer an estimate the system can keep, aim for about ten minutes on average, and never make the rider pay for a missed estimate."
"Where does the stock live? Does one order come from one place?"
"Every basket comes from one dark store. Blinkit had 2,443 of them at the end of June 2026, and it now owns most of the inventory it sells, so every lost or expired packet is its own loss. Inventory losses ran at about 1.8% of net order value that quarter. Assume one store serves a basket, and that a store carries several thousand SKUs."
"How big is a typical basket?"
"The net average order value was ₹518 in April-June 2026. Call it six to eight items: milk, bread, snacks, a few household things."
"Do riders work for a store, or float across the city like a ride-hailing marketplace?"
"For this design, assume a rider logs in at a store for a shift, waits there, delivers, and comes back. That keeps dispatch local. It is a very different problem from matching cars across a city."
"When are the peaks?"
"Every evening around 8 to 10 PM. Then the spikes: rain, which raises demand while slowing every ride; big cricket matches, when snacks and cold drinks go in bursts at the innings break; festivals; and New Year's Eve, when Zomato and Blinkit together delivered more than 75 lakh orders in one day."
Functional Requirements#
1. Serviceability and catalog
- From the customer's location, pick the serving dark store
- Show that store's catalog, prices and live availability
- Show an honest arrival estimate before checkout, or "not serviceable right now"
- Search across the store's catalog, in-stock items first
2. Cart and checkout
- Validate the cart against live stock at checkout and reserve it
- Pay by UPI, card, wallet or cash on delivery
- Exactly one order per checkout attempt, however many times the button is tapped
3. In the store
- Queue confirmed orders for pickers by their promised time
- Generate a pick path through the store's bins; scan every item
- Handle a missing item at the shelf: substitute (if the customer allowed it) or refund that line
- Pack, label, and place on a dispatch rack
4. Delivery
- Assign a rider, batching two orders on one trip when both still arrive on time
- Live tracking for the customer; navigation for the rider
- Proof of delivery, and handling for "customer not reachable"
5. Store operations
- Receive stock from the warehouse, put it away into bins
- Cycle counts, expiry pulls, damage write-offs
- Feed real counts back into availability
Non-Functional Requirements#
The promise
- Median order-to-door around 10 minutes for customers close to the store
- Promise kept (delivered by the shown estimate) for at least 90% of orders, per store, per hour (design target)
- The shown estimate is a prediction the store can keep right now, never a fixed slogan
Correctness
- No order confirmed against stock that isn't reserved
- Items found missing at the shelf on at most ~1% of picked lines (design target)
- Every captured rupee ends in a delivered order or a refund, automatically
Latency
- Store selection and estimate: < 200 ms
- Checkout (reserve stock + create payment): < 1.5 s p99, excluding the customer's time in their UPI app
- Order visible on a picker's handheld: < 5 s after confirmation
Availability
- Browsing and search keep working even if checkout is degraded
- One store's trouble (power cut, network loss, overload) never spills into its neighbours
Safety
- No countdown on the rider's screen; no per-order speed pressure
- Under bad weather the estimate stretches; the road speed doesn't
Phase 2: Back of the Envelope Estimation (5 minutes)#
"Two kinds of numbers matter here: the minutes inside one order, and the load across thousands of stores. The minutes are the interesting part."
Before the numbers: where do the ten minutes go?
Picking and packing takes about 2.5 minutes on average, and the average ride is about 2 km at about 15 km/h. Add those up. Then work out how far away a customer can be if the whole order really has to arrive in ten minutes.
One strong answer
- The averages alone: 2.5 min to pick and pack + 2 km ÷ 15 km/h = 8 min of riding = 10.5 minutes, before any time to confirm the order, wait in the picker queue, hand over the bag or climb to the third floor.
- The real budget: allow about 10 s to confirm, 20 s in the picker queue, 150 s to pick and pack, 20 s to hand over to a rider who is already waiting, and 30 s at the door. That is 230 s, or 3 min 50 s, which leaves 6 min 10 s of riding.
- The radius: at 15 km/h a rider covers 0.25 km a minute, so every kilometre costs 4 minutes. 6.17 min × 0.25 km/min ≈ 1.5 km of road.
So a true ten minutes only exists for customers within about a kilometre and a half of road from the store. Everyone further away needs a longer, honest estimate. The lever that makes ten minutes possible is store density: more stores, closer to more homes. Pushing the rider can't make the arithmetic work.
The Order-to-Door Budget#
| Step | Budget | Running total |
|---|---|---|
| Customer taps Pay → stock reserved, payment authorised, order confirmed | 10 s | 0:10 |
| Order waits in the store's picker queue | 20 s | 0:30 |
| Pick (walk the path, scan 6-8 items) and pack (bag, seal, label) | 150 s | 3:00 |
| Handover to a rider already waiting at the dispatch rack | 20 s | 3:20 |
| Ride, ~1.5 km of road at ~15 km/h | 370 s | 9:30 |
| Park, find the door, hand over | 30 s | 10:00 |
Note: rider assignment happens during picking, not after it. If the rider is only chosen once the bag is packed, the handover step quietly grows from 20 seconds to a minute or two.
Traffic Estimation#
- Public baseline: 331 million orders in a 91-day quarter = ~3.6 million orders a day
- Design target: 4 million orders a day across 2,500 stores = 1,600 orders per store per day
- Busiest hour (around 9 PM), assumed to carry 12% of the day's orders:
- Per store: 1,600 × 0.12 = 192 orders an hour = ~3.2 a minute
- Nationally: 4,000,000 × 0.12 = 480,000 an hour = ~133 orders a second
- Spike nights (rain, a final, New Year's Eve), assumed 3× the normal busiest hour:
- Nationally: ~400 orders a second
- At a hot store: ~10 orders a minute
- Read traffic, assuming ~100 requests per placed order (home feed, search, product pages, cart refreshes, and the sessions that never buy):
- Busiest hour: 133 × 100 = ~13,300 reads a second
- Spike night: 400 × 100 = ~40,000 reads a second
Conclusion: the order rate is small. A few hundred orders a second nationally is a modest load for a sharded database. The read path (catalog, search, availability) carries about 100 times more traffic, and should be built and scaled as its own plane.
Inventory and People Estimation#
- SKUs per store (design assumption): ~10,000
- Items per order: ~7 (₹518 average basket ÷ ~₹75 average item)
- Stock reservations at a store's busiest hour: 3.2 orders/min × 7 lines = ~22 line reservations a minute
- Nationally at the busiest hour: 133 orders/s × 7 = ~930 line reservations a second, spread over 2,500 stores
- Pickers at the busiest hour: 192 orders/h ÷ 24 orders per picker-hour (2.5 min each) = 8 pickers busy all the time, so roster ~10 for slack
- Riders at the busiest hour:
- One trip: 1 min handover + 8 min out + 1 min at the door + 7 min back = ~17 minutes, so ~3.5 trips a rider an hour
- With ~15% of trips carrying two orders: 192 ÷ 1.15 = ~167 trips an hour
- Riders needed: 167 ÷ 3.5 = ~48 riders on shift at one store
- Riders online nationally at 9 PM: 2,500 × ~48 = ~120,000
- Rider location pings, one every 4 s: 120,000 ÷ 4 = ~30,000 a second
Conclusion: per store, inventory write rates are tiny (tens a minute). The hard part of inventory is not throughput. It's keeping the system's count equal to what is physically on the shelf.
Storage Estimation#
- Stock records: 2,500 stores × 10,000 SKUs = 25 million rows × ~100 bytes = ~2.5 GB (one store's stock is about 1 MB)
- In-stock flags as a bitmap: 10,000 SKUs = 10,000 bits = 1,250 bytes per store
- Orders: 4 million a day × ~2 KB (order, lines, state changes, payment refs) = ~8 GB a day, ~2.9 TB a year
- Rider location trail, averaging ~10,000 pings a second over the day: 10,000 × 86,400 = 864 million pings × ~40 bytes = ~35 GB a day (kept hot for a few days, then summarised)
- Serviceability index: a 2 km disc is π × 2² ≈ 12.6 km², about 120 H3 cells at resolution 9 (~0.105 km² each); 2,500 stores × 120 = ~300,000 cell entries, small enough to hold in memory on every server
Key Metrics Summary#
| Metric | Value |
|---|---|
| Orders a day (design target) | 4 million |
| Dark stores | 2,500 |
| Orders per store, busiest hour | ~192 (~3.2 a minute) |
| National order rate, busiest hour / spike | ~133/s / ~400/s |
| Read requests, busiest hour / spike | ~13,300/s / ~40,000/s |
| Pickers / riders per store, busiest hour | ~8 busy / ~48 on shift |
| Ride budget for a true 10 minutes | ~6 min, ~1.5 km of road |
| One store's whole stock table | ~1 MB |
Requirements and estimation
Q1/3 Picking and packing averages 2.5 minutes and the average ride is 2 km at 15 km/h. Before any queueing or handover, how long is the average order?
Answer & explanation
Answer: C
2 km ÷ 15 km/h = 8 minutes of riding, plus 2.5 minutes in the store = 10.5 minutes. Any queueing, handover or doorstep time comes on top, which is why a true ten minutes needs customers closer than the average.
Q2/3 With 4 million orders a day across 2,500 stores and 12% of a day's orders in the busiest hour, how many orders a minute does a typical store handle at its peak?
Answer & explanation
Answer: A
4,000,000 ÷ 2,500 = 1,600 orders per store per day; 12% of that is 192 in the hour, about 3.2 a minute.
Q3/3 The estimate says one store's whole stock table is about 1 MB and it sees only tens of reservations a minute. What is the hard part of inventory, then?
Answer & explanation
Answer: D
The load is trivial. What breaks customer trust is phantom stock: the system says 3 packets, the shelf has none, and an order is confirmed that can't be filled.
Phase 3: High-Level Design (10 minutes)#
Sketch it first: one order, from tap to door
Trace one order from the customer tapping "Pay" to the bag arriving. Which steps must be strongly consistent, which can serve slightly stale data, and which happen physically in the store?
One strong answer
- Read plane, stale is fine: store selection, the catalog, search and in-stock flags, served from caches a few seconds old. A wrong "in stock" flag costs one bad cart line; it is caught at checkout.
- Write plane, must be exact: checkout reserves each line against the store's stock in one transaction, creates a payment with an idempotency key, and confirms the order only when both succeed. An order saga drives every later step, and compensates (releases stock, refunds) when one fails.
- In the store, physical: the order joins the picker queue by its promised time, a picker walks a generated path and scans each item, and the packed bag goes on a rack. A rider is assigned while the picker is still picking.
- On the road: the rider's app sends its location every few seconds; the customer's app shows it with a live estimate.
- Across all of it: a promise engine that knows each store's queue, pickers and riders, and turns that into the estimate shown before checkout, or closes the store's outer area when it can't keep one.
"The architecture follows the numbers. The read path is a hundred times heavier than the order path, so it gets its own plane. Orders are few but every one is a physical promise, so the write path is small, exact and per store. And because the store is where the minutes are won, the store's own systems are first-class: picker queue, dispatch, and a promise engine that knows how busy each store really is."
System Architecture#
%% title: Quick-commerce platform architecture
flowchart TD
subgraph L1["Layer 1 · Clients"]
direction LR
CA@{ icon: "sd:phone", label: "Customer app" }
PH@{ icon: "sd:scan", label: "Picker handheld" }
RA@{ icon: "sd:delivery", label: "Rider app" }
end
subgraph L2["Layer 2 · Edge"]
direction LR
GW["API gateway<br/>auth, rate limits"]:::edge
CDN@{ icon: "sd:cdn", label: "CDN<br/>images, catalog pages" }
end
subgraph L3["Layer 3 · Read plane (stale by seconds is fine)"]
direction LR
SV["Serviceability<br/>H3 cell → candidate stores"]:::service
CAT["Catalog + search<br/>global index, per-store filter"]:::service
AV@{ icon: "sd:cache", label: "Availability cache<br/>in-stock bitmap per store" }
end
subgraph L4["Layer 4 · Write plane (exact, sharded by store)"]
direction LR
CO["Checkout + order saga<br/>idempotency key per attempt"]:::core
INV["Inventory service<br/>reservations, the only stock writer"]:::hot
PAY["Payment service"]:::service
end
subgraph L5["Layer 5 · Store and road"]
direction LR
PQ@{ icon: "sd:queue", label: "Picker queue<br/>earliest promise first" }
DSP["Dispatch<br/>riders, batching"]:::service
LOC@{ icon: "sd:map-pin", label: "Rider locations<br/>latest per rider, TTL" }
PE["Promise engine<br/>ETA + store mode"]:::core
end
subgraph L6["Layer 6 · Data and partners"]
direction LR
DB@{ icon: "sd:database", label: "Orders + stock DB<br/>sharded by store" }
BUS@{ icon: "sd:stream", label: "Event bus<br/>order + stock events" }
PG@{ icon: "sd:card", label: "Payment gateways<br/>UPI, cards" }
end
L1 ==> L2
L2 -->|"browse, search"| L3
L2 ==>|"checkout"| L4
L4 ==>|"outbox events"| L5
L4 --> L6
class CA,PH,RA client
class CDN edge
class AV,LOC cache
class PQ,BUS queue
class DB store
class PG externalKEY INSIGHTS:
- The availability cache is a projection of the stock DB, fed by stock events from the bus. It is allowed to lag by seconds, because checkout re-checks against the stock DB
- The inventory service is the only writer of stock. It is sharded by store, so a store's stock, reservations and orders live together and one store's load never touches another's
- The promise engine reads live queue, picker and rider state per store and publishes each store's mode and estimates to serviceability in the read plane. It is the one place that decides what estimate a customer sees, and whether a store is open to more orders right now
Data Flow: One Order#
"Let's follow Riya's Friday-night order through the system, with the clock running from the moment she taps Pay."
%% title: One order, tap to door sequenceDiagram autonumber participant C as Riya's app participant O as Checkout / saga participant I as Inventory participant P as Payment participant S as Store (picker) participant R as Rider Note over C: Before Pay · store chosen, estimate "11 min" shown C->>O: Pay (idempotency key k1) O->>I: Reserve 5 lines at store 412 I-->>O: Reserved (hold 5 min) O->>P: Create payment (k1) P-->>O: UPI success O-->>C: 0:04 · Order confirmed, arriving in ~11 min O-)S: Order joins picker queue (due by 0:30) O-)R: Dispatch plans a rider for ~3:00 Note over S: 0:25 · Picker starts, ambient aisles first, cold room last S->>I: Scan each item (stock consumed) S-->>R: 2:55 · Bag on rack slot B7 R->>C: 3:10 · Picked up, live location every 4 s Note over R,C: ~2 km at ~15 km/h, about 8 min R-->>C: 10:55 · Delivered
MEANWHILE, IN PARALLEL:
- Thousands of people are browsing the same store's catalog from the read plane, never touching the stock DB
- The store's promise engine updates its estimate every few seconds as orders, pickers and riders change
- Stock events from Riya's scans update the store's in-stock bitmap within seconds
High-level design
Q1/3 Riya's app showed chips as in stock, but at checkout the reservation fails because the last packet sold a second earlier. Is the design broken?
Answer & explanation
Answer: B
The read plane trades a few seconds of freshness for absorbing about 100 times more traffic. Checkout is the exact check; confirming against stock you know is gone would just move the failure to the shelf.
Q2/3 Why is the inventory service sharded by store rather than by SKU?
Answer & explanation
Answer: C
Every basket comes from one store. Keeping that store's stock together means a checkout is one local transaction, and one store's rush can't slow another's.
Q3/3 When should dispatch start planning a rider for an order?
Answer & explanation
Answer: D
Planning while the picker works lets a rider be at the rack when the bag is ready. Waiting until packing finishes adds a minute or two to every order.
Phase 4: Deep Dives (20 minutes)#
Deep Dive 1: Serviceability and Store Selection (Week 1 Concepts: Partitioning, Week 7: Search)#
"Before anyone sees a single product, the system has to answer a question for every app open: which store serves this spot, and can it serve it right now?"
Which store serves this pin?
A customer's pin sits inside the delivery areas of two stores. Store A is 1.2 km away but has a six-minute picking backlog; store B is 2.4 km away and quiet. The cart contains an item only store B has. How do you decide, and how do you make the lookup cheap enough to run on every app open?
One strong answer
- Make the lookup a table read: cover each store's delivery area with H3 cells (resolution 9 cells are ~0.105 km², so a 2 km area is ~120 cells) and precompute cell → candidate stores. A location becomes a cell id, and the cell id gives the two or three stores to consider. No geometry at request time.
- Filter by the store's live mode: a store that has closed its outer cells under load, or is offline, drops out.
- Score the survivors: cart coverage first (a missing item hurts more than a minute), then the estimate (backlog + pick and pack + predicted ride from that store to that cell).
- Here: store A's estimate is 6 + 2.5 + ~5 ≈ 13.5 min; store B's is ~0 + 2.5 + ~10 ≈ 12.5 min, and only B has the full basket. B wins on both counts.
- Then pin it: once a session has a store, keep it unless the customer moves or the store closes, so the catalog doesn't change under their cart.
The Challenge:
- Straight-line distance isn't road distance: a river, a railway line or a one-way street can turn 800 metres into 3 km
- The nearest store may be swamped: six minutes of queued picking is worth more than a kilometre of road
- The nearest store may not have the basket, and one basket comes from one store
- Running polygon-intersection maths on every app open, for millions of opens an hour, wastes CPU on a question with a cacheable answer
- Delivery areas change: under load the promise engine shrinks them, so the answer for a cell can change minute to minute
The Solution:
%% title: Store selection for one location
flowchart TD
LOC@{ icon: "sd:map-pin", label: "Customer pin (lat, lng)" }
CELL@{ icon: "sd:hexagons", label: "H3 cell at resolution 9" }
IDX@{ icon: "sd:cache", label: "Cell index<br/>cell → 2-3 candidate stores" }
MODE{"Store open for<br/>this cell right now?"}:::service
SCORE["Score each candidate<br/>1 · cart coverage<br/>2 · estimate = backlog + pick + ride"]:::core
PICK(["Serving store + estimate"]):::ok
NONE(["Not serviceable right now<br/>offer schedule for later"]):::bad
LOC --> CELL --> IDX --> MODE
MODE -->|"yes"| SCORE --> PICK
MODE -->|"no store open"| NONE
class LOC client
class CELL service
class IDX cache- The cell index is built offline from each store's road-distance delivery area (not a circle), and it is small: ~300,000 entries for 2,500 stores, held in memory on every serviceability server.
- The live mode per (store, cell) comes from the promise engine (Deep Dive 6) every few seconds. The index says who could serve a cell; the mode says who will right now.
- Ride estimates per (store, cell, hour, weather) come from historical trips, so a cell across a railway line from its store gets an honest number.
# serviceability/store_selector.py
"""
Picks the serving dark store for a customer location.
The static part (which stores could ever serve a cell) is precomputed.
The live part (is that store open for this cell, how busy is it) comes
from the promise engine. Nothing here touches the stock database.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set
import h3 # h3-py v4
RES = 9 # ~0.105 km² cells, ~200 m edges
PICK_PACK_MIN = 2.5 # median pick + pack, refreshed per store in practice
MAX_RIDE_MIN = 20.0 # fallback when a (store, cell) has no ride history
@dataclass
class StoreLive:
store_id: str
mode: str # OPEN | STRETCHED | THROTTLED | CLOSED
backlog_min: float # minutes of picking work already queued
open_cells: Set[str] # cells it accepts right now (shrinks under load)
ride_p90_min: Dict[str, float] = field(default_factory=dict) # cell -> p90 ride
@dataclass
class Choice:
store_id: str
cell: str
eta_min: float
coverage: float # share of cart lines in stock at this store
class StoreSelector:
def __init__(self, cell_index: Dict[str, List[str]], live: Dict[str, StoreLive], stock):
self.cell_index = cell_index # cell -> candidate store ids (static)
self.live = live # store id -> StoreLive (refreshed every few s)
self.stock = stock # availability cache: in_stock(store, sku) -> bool
def choose(self, lat: float, lng: float, cart_skus: List[str]) -> Optional[Choice]:
cell = h3.latlng_to_cell(lat, lng, RES)
best: Optional[Choice] = None
for store_id in self.cell_index.get(cell, []):
st = self.live.get(store_id)
if st is None or st.mode == "CLOSED" or cell not in st.open_cells:
continue
in_stock = sum(1 for sku in cart_skus if self.stock.in_stock(store_id, sku))
coverage = in_stock / len(cart_skus) if cart_skus else 1.0
eta = st.backlog_min + PICK_PACK_MIN + st.ride_p90_min.get(cell, MAX_RIDE_MIN)
cand = Choice(store_id, cell, round(eta, 1), coverage)
# Coverage first (a missing item is worse than a minute), then ETA.
if best is None or (cand.coverage, -cand.eta_min) > (best.coverage, -best.eta_min):
best = cand
return best # None means "not serviceable right now"Deep Dive 2: Real-Time Inventory and Reservations (Week 5 Concepts: Consistency, Week 2: Idempotency)#
"This is where quick commerce differs most from a flash sale like Tatkal. The write load is tiny, so we don't need clever counters. What we need is for the system's number to match the shelf, and for every unit to be promised to one order at a time."
No overselling, and no phantom stock
Store 412 shows 3 packets of a snack. Two customers check out at the same moment, wanting 2 each. Separately, the picker for a third order reaches the bin and finds it empty, although the system said 3. Handle both, and say when stock should be reserved: when an item is added to the cart, at checkout, or at pick time.
One strong answer
- Reserve at checkout, not at add-to-cart: carts are abandoned all the time, and holding stock for every cart would empty the shelves on paper. Reserving at pick time is too late: the order is already confirmed.
- One transaction per checkout: for each line,
reserved = reserved + q WHERE on_hand - reserved - buffer >= q. The first checkout reserves 2 of 3; the second finds only 1 available, so the transaction rolls back and the customer is told "only 1 left" before paying. No oversell, no lock dance, and at tens of reservations a minute per store an ordinary database does it easily. - Holds expire: a reservation is held for a few minutes while payment completes; if payment fails or never arrives, a sweeper releases it.
- The empty bin is a stock-truth problem: the picker taps "not found", which sets that bin's on-hand to what is really there (here 0), flags the SKU for a recount, and resolves just that line: a substitute if the customer allowed one, otherwise a line refund. The rest of the order goes on.
- Prevent the next one: keep a small safety buffer for SKUs whose counts drift, and run cycle counts on them, so the app stops promising the last packet you probably don't have.
The Challenge:
1. Overselling under concurrency
Two checkouts read "3 left", both write "1 left", and both are confirmed for 2 packets each. One customer gets a refund call instead of chips.
2. Holding stock nobody will buy
Reserve on add-to-cart and the shelf empties on paper: a cart sitting in a background tab holds a packet of milk all evening while real customers are told it is out of stock.
3. Phantom stock
The count says 3, the shelf says 0. Damaged packets, items put away in the wrong bin, expired stock thrown out without a scan, theft. Losses in the public figures run at about 1.8% of net order value: every count the system can't trust becomes a confirmed order that can't be filled.
The Solution:
Reserve on add-to-cart
- Abandoned carts lock real stock
- Needs very short TTLs that then expire under customers who are still shopping
- Shelves look empty while full
Reserve at checkout
- Stock is held only for people paying right now
- A few minutes' hold covers the payment
- Shortfalls are told to the customer before they pay
Reserve at pick time
- The order is already confirmed and paid for
- Every shortfall becomes a cancellation or a refund after the promise was made
%% title: Life of one reserved line stateDiagram-v2 [*] --> HELD: checkout reserves HELD --> COMMITTED: payment succeeds HELD --> RELEASED: payment fails or hold expires COMMITTED --> PICKED: picker scans the item COMMITTED --> NOT_FOUND: bin is empty NOT_FOUND --> SUBSTITUTED: customer allowed a substitute NOT_FOUND --> LINE_REFUNDED: no substitute PICKED --> [*] RELEASED --> [*] HELD: HELD · counts in reserved, TTL 5 min COMMITTED: COMMITTED · order confirmed, no TTL PICKED: PICKED · on hand and reserved both drop NOT_FOUND: NOT_FOUND · bin set to real count, recount queued RELEASED: RELEASED · back to available class HELD,COMMITTED core class PICKED,SUBSTITUTED ok class RELEASED,LINE_REFUNDED bad class NOT_FOUND queue
- available = on_hand − reserved − buffer.
on_handis what the store physically holds (counted, received, scanned out);reservedis promised to unpicked orders;bufferis a per-SKU allowance for count drift. - On hand only drops at the scan. Until the picker scans an item, the unit is still physically in the store, just promised.
- The availability cache (the in-stock bitmap the catalog uses) is rebuilt from stock events: a SKU's bit flips to 0 when
availabledrops to 0, and back when a delivery is put away.
Store 412 has 3 packets and two checkouts want 2 each. The guarded UPDATE (reserve only if on_hand − reserved − buffer ≥ 2) is serialised by the row lock: the first reserves 2 and is HELD for 5 minutes, the second rolls back and is told only 1 is left. Paid, the hold becomes COMMITTED and the picker's scan drops on_hand and reserved together; unpaid, the sweeper moves it HELD → RELEASED, and only that move gives the stock back.
# inventory/reservations.py
"""
Stock reservations for one dark store, in an ordinary SQL database.
Per store, reservations arrive at tens a minute, so a plain transaction
with a conditional UPDATE is enough: no distributed lock, no counter
cache. The tables are sharded by store_id; every statement below
touches one store, so it runs on one shard.
stock(store_id, sku_id, on_hand, reserved, buffer) PK (store_id, sku_id)
reservations(order_id PK, store_id, status, expires_at)
reservation_lines(order_id, sku_id, qty)
"""
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Dict, List, Optional
HOLD_TTL = timedelta(minutes=5)
@dataclass
class Shortfall:
sku_id: str
wanted: int
available: int
class StoreInventory:
def __init__(self, db):
self.db = db
def reserve(self, order_id: str, store_id: str, lines: Dict[str, int]) -> List[Shortfall]:
"""
All-or-nothing reservation for one basket. Returns [] on success,
or the lines that are short (so the app can say "only 1 left").
Idempotent on order_id: a retried checkout returns the same result.
"""
with self.db.transaction() as tx:
inserted = tx.execute(
"INSERT INTO reservations (order_id, store_id, status, expires_at) "
"VALUES (%s, %s, 'HELD', %s) ON CONFLICT (order_id) DO NOTHING",
(order_id, store_id, datetime.utcnow() + HOLD_TTL),
).rowcount
if inserted == 0:
status = tx.scalar("SELECT status FROM reservations WHERE order_id = %s", (order_id,))
if status in ("HELD", "COMMITTED"):
return [] # same checkout retried: already reserved
# an earlier attempt with this id failed or expired: start a new hold
tx.execute(
"UPDATE reservations SET status = 'HELD', expires_at = %s WHERE order_id = %s",
(datetime.utcnow() + HOLD_TTL, order_id),
)
tx.execute("DELETE FROM reservation_lines WHERE order_id = %s", (order_id,))
short: List[Shortfall] = []
# Fixed order (by sku_id) so two baskets never lock the same rows in opposite orders.
for sku_id in sorted(lines):
qty = lines[sku_id]
ok = tx.execute(
"UPDATE stock SET reserved = reserved + %s "
"WHERE store_id = %s AND sku_id = %s "
"AND on_hand - reserved - buffer >= %s",
(qty, store_id, sku_id, qty),
).rowcount
if ok == 0:
avail = tx.scalar(
"SELECT GREATEST(on_hand - reserved - buffer, 0) FROM stock "
"WHERE store_id = %s AND sku_id = %s",
(store_id, sku_id),
) or 0
short.append(Shortfall(sku_id, qty, avail))
else:
tx.execute(
"INSERT INTO reservation_lines (order_id, sku_id, qty) VALUES (%s, %s, %s)",
(order_id, sku_id, qty),
)
if short:
tx.rollback() # nothing held: the customer edits the cart and retries
return short
return []
def extend_hold(self, order_id: str, by: timedelta) -> None:
"""Payment outcome unknown: keep the stock while the reconciler asks the gateway."""
with self.db.transaction() as tx:
tx.execute(
"UPDATE reservations SET expires_at = expires_at + %s "
"WHERE order_id = %s AND status = 'HELD'",
(by, order_id),
)
def expire_holds(self, now: datetime) -> None:
"""The sweeper, every few seconds: release holds whose time is up."""
for (order_id,) in self.db.rows(
"SELECT order_id FROM reservations WHERE status = 'HELD' AND expires_at < %s", (now,)
):
self.release(order_id)
def commit(self, order_id: str) -> bool:
"""Payment succeeded: the hold stops expiring. False if it already expired."""
with self.db.transaction() as tx:
return tx.execute(
"UPDATE reservations SET status = 'COMMITTED', expires_at = NULL "
"WHERE order_id = %s AND status = 'HELD'",
(order_id,),
).rowcount == 1
def release(self, order_id: str) -> None:
"""Payment failed or the hold expired. Safe to call twice."""
with self.db.transaction() as tx:
# Only the call that moves HELD -> RELEASED gives the stock back.
moved = tx.execute(
"UPDATE reservations SET status = 'RELEASED' "
"WHERE order_id = %s AND status = 'HELD' RETURNING store_id",
(order_id,),
).fetchone()
if moved is None:
return
store_id = moved[0]
for sku_id, qty in tx.rows(
"SELECT sku_id, qty FROM reservation_lines WHERE order_id = %s ORDER BY sku_id",
(order_id,),
):
tx.execute(
"UPDATE stock SET reserved = reserved - %s WHERE store_id = %s AND sku_id = %s",
(qty, store_id, sku_id),
)
def scan_out(self, store_id: str, order_id: str, sku_id: str, qty: int) -> None:
"""The picker scanned the item into the bag: it leaves the shelf."""
with self.db.transaction() as tx:
tx.execute(
"UPDATE stock SET on_hand = on_hand - %s, reserved = reserved - %s "
"WHERE store_id = %s AND sku_id = %s",
(qty, qty, store_id, sku_id),
)
def not_found(self, store_id: str, order_id: str, sku_id: str, qty: int, counted: int) -> None:
"""
The bin held fewer than the system thought. (A partial pick is a
scan_out for what was there, then not_found for the rest.) Trust the
picker: `counted` is what is physically left, usually 0. Release this
line's hold and queue a recount. This sketch keeps one bin per SKU.
"""
with self.db.transaction() as tx:
tx.execute(
"UPDATE stock SET reserved = reserved - %s, on_hand = LEAST(on_hand, %s) "
"WHERE store_id = %s AND sku_id = %s",
(qty, counted, store_id, sku_id),
)
tx.execute(
"INSERT INTO recount_tasks (store_id, sku_id, reason) VALUES (%s, %s, 'NOT_FOUND')",
(store_id, sku_id),
)A few details carry the correctness:
- The
WHERE ... >= %sguard is the whole concurrency story. Two checkouts on the last 3 packets are serialised by the row lock on that one stock row; the second sees the first's reservation and fails its guard. At a store's tens of reservations a minute, the lock is never contended for long. releasemovesHELD → RELEASEDbefore touching stock, so a sweeper and a payment-failure callback racing each other can't give the same units back twice.not_foundcaps on-hand at what the picker actually counted, so the next checkout can't be promised the packet that isn't there. If other unpicked orders still hold units of that SKU,availabledrops below zero: the SKU disappears from the app, and those orders are flagged for their substitution choice before their pickers reach the empty bin.- Substitutions are decided by the order saga (Deep Dive 3) from the customer's choice at checkout ("replace with similar" or "refund if missing"), and are reserved like any other line.
Serviceability and inventory
Q1/4 Why precompute an H3 cell → candidate stores index instead of testing the pin against every store's delivery polygon on each app open?
Answer & explanation
Answer: B
A pin maps to a cell id in constant time, and the cell id gives the two or three possible stores. The live mode check still runs; it just runs on a tiny list.
Q2/4 Store A is 1.2 km away with six minutes of picking backlog; store B is 2.4 km away, quiet, and the only one with every item in the cart. Which should serve the order?
Answer & explanation
Answer: D
Coverage comes first and B has a lower estimate too (about 12.5 vs 13.5 minutes). Splitting one basket across two stores doubles the rider cost and the chances of a late order.
Q3/4 Why does checkout reserve stock with
UPDATE ... WHERE on_hand - reserved - buffer >= qtyinstead of reading the count and then writing a new one?Answer & explanation
Answer: C
With a separate read and write, two checkouts can both read "3 left" and both reserve 2. The guard makes the second one fail and report the real shortfall.
Q4/4 A picker finds an empty bin for a SKU the system counted as 3. What is the right response?
Answer & explanation
Answer: D
The physical count wins. The rest of the order carries on, the missing line follows the customer's substitution choice, and the corrected count stops the next customer being promised a phantom packet.
Deep Dive 3: Order Orchestration (Week 5 Concepts: Saga Pattern, Week 3: Transactional Outbox)#
"An order touches stock, money, a picker, a rider and a customer's phone. No single transaction covers them all, so the order is a saga: a state machine where every step has a defined way back."
The Challenge:
- The customer taps Pay twice because the first spinner was slow
- UPI takes forty seconds, or says "pending", or the success callback arrives after the stock hold expired
- The app crashes after the payment but before showing the confirmation
- An item is missing at the shelf after the order was confirmed and paid
- The rider can't reach the customer, or the store loses power mid-pick
The Solution:
%% title: Order state machine (a saga) stateDiagram-v2 [*] --> CREATED CREATED --> RESERVED: stock reserved CREATED --> REJECTED: stock short RESERVED --> CONFIRMED: payment ok, or cash on delivery RESERVED --> CANCELLED: payment failed RESERVED --> PAYMENT_PENDING: gateway timeout RESERVED --> REFUNDED: paid after the hold expired, stock gone PAYMENT_PENDING --> CONFIRMED: reconciled as paid, stock still held PAYMENT_PENDING --> REFUNDED: paid, but stock gone PAYMENT_PENDING --> CANCELLED: reconciled as failed CONFIRMED --> PICKING PICKING --> PACKED PACKED --> OUT_FOR_DELIVERY OUT_FOR_DELIVERY --> DELIVERED OUT_FOR_DELIVERY --> RETURNED: customer unreachable CREATED: CREATED · idempotency key stored RESERVED: RESERVED · hold TTL 5 min PAYMENT_PENDING: PAYMENT_PENDING · hold extended, reconcile CANCELLED: CANCELLED · stock released REFUNDED: REFUNDED · stock released, money returned RETURNED: RETURNED · items back to stock, refund per policy class CREATED,RESERVED,CONFIRMED,PICKING,PACKED,OUT_FOR_DELIVERY core class DELIVERED ok class REJECTED,CANCELLED,REFUNDED,RETURNED bad class PAYMENT_PENDING queue
KEY DESIGN DECISIONS:
- ONE IDEMPOTENCY KEY PER CHECKOUT ATTEMPT — The app generates it when the customer lands on the payment step (a new one after any cart change) and reuses it on every retry. The order row is created with it (unique), the reservation uses the order id, and the payment request carries it. A double tap returns the first attempt's result.
- RESERVE BEFORE CHARGING — Stock first, then money. If stock is short the customer is told before paying, and there is nothing to refund.
- AN UNKNOWN PAYMENT IS NOT A FAILED PAYMENT — On a gateway timeout the order goes to
PAYMENT_PENDING, the stock hold is extended, and a reconciler asks the gateway for the truth. If the money arrived but the stock was released, the refund is automatic. - EVENTS LEAVE THROUGH AN OUTBOX — The state change and the "order confirmed" event are written in the same database transaction; a relay publishes the event to the store's picker queue and to dispatch. The order is never confirmed without the store hearing about it.
- PARTIAL FULFILMENT IS A NORMAL PATH — A missing line becomes a substitute or a line refund, recorded on the order. The order still completes.
# orders/saga.py
"""
Order saga for one checkout. Each step is idempotent, so the whole
place_order call can be retried safely with the same key.
"""
from dataclasses import dataclass
from datetime import timedelta
from typing import Dict, Optional
@dataclass
class Order:
order_id: str
idem_key: str
store_id: str
lines: Dict[str, int]
state: str
payment_ref: Optional[str] = None
class OrderSaga:
def __init__(self, orders, inventory, payments, outbox, scheduler):
self.orders = orders # orders DB (same shard as the store's stock)
self.inv = inventory # StoreInventory from Deep Dive 2
self.pay = payments
self.outbox = outbox
self.scheduler = scheduler
def place_order(self, idem_key: str, store_id: str, lines: Dict[str, int], method: str) -> Order:
order = self.orders.get_or_create(idem_key, store_id, lines) # unique on idem_key
if order.state not in ("CREATED", "RESERVED"):
return order # already decided: a retry gets the same answer
if order.state == "CREATED":
short = self.inv.reserve(order.order_id, store_id, lines)
if short:
return self.orders.transition(order, "REJECTED", shortfall=short)
order = self.orders.transition(order, "RESERVED")
if method == "COD":
return self._confirm(order)
result = self.pay.charge(reference=order.order_id, idempotency_key=idem_key)
if result.status == "SUCCESS":
order.payment_ref = result.reference
return self._confirm(order)
if result.status == "TIMEOUT":
# Don't guess. Keep the stock a little longer and ask the gateway.
self.inv.extend_hold(order.order_id, timedelta(minutes=10))
self.scheduler.reconcile_payment(order.order_id, after=timedelta(seconds=30))
return self.orders.transition(order, "PAYMENT_PENDING")
self.inv.release(order.order_id)
return self.orders.transition(order, "CANCELLED", reason="PAYMENT_FAILED")
def _confirm(self, order: Order) -> Order:
if not self.inv.commit(order.order_id):
# The hold expired while the customer was paying. Try once more.
if self.inv.reserve(order.order_id, order.store_id, order.lines) or not self.inv.commit(order.order_id):
if order.payment_ref:
self.pay.refund(order.payment_ref, idempotency_key=f"refund:{order.order_id}")
return self.orders.transition(order, "REFUNDED", reason="STOCK_GONE")
# The state change and its event commit together (transactional outbox).
with self.orders.transaction() as tx:
order = self.orders.transition(order, "CONFIRMED", tx=tx)
self.outbox.add(tx, "order.confirmed", {"order_id": order.order_id, "store_id": order.store_id})
return order"Notice what the saga does not do: it doesn't call the picker or the rider directly. Confirmation is a durable event. If dispatch is slow for a few seconds, the order is still safe; it simply waits in the outbox relay."
Deep Dive 4: Pick and Pack in Two and a Half Minutes (Week 3 Concepts: Queues and Backpressure)#
"The store is where the ten minutes are won or lost. A ride is mostly fixed by distance; picking can be engineered."
Two and a half minutes inside the store
A store has about 10,000 SKUs in ambient, chilled and frozen zones, and up to 10 pickers at 9 PM. Orders arrive at ~3 a minute, each with ~7 items. How do you lay out the store, order the picker queue, and plan each picker's walk so most orders are packed within 2.5 minutes?
One strong answer
- Slot by velocity: the few hundred fastest-moving SKUs (milk, bread, chips, cold drinks) live in the bins nearest the packing station. Slow movers go to the back. Most baskets then never visit the far aisles.
- Queue by deadline, not arrival: each order carries a "must be packed by" time from its promise; pickers take the earliest deadline first. A far-away customer's order, with a longer ride ahead, is due sooner than a neighbour's placed at the same moment.
- Plan the walk: sort the lines by the store's walk sequence (zone, aisle, bay, shelf), ambient first and chilled and frozen last so cold items spend the least time out of the cold.
- Batch only when it's free: one picker can pick two small orders onto a two-bag trolley if the second needs no extra zone and both still meet their deadlines.
- Scan everything: every item is scanned into the bag, which catches wrong picks and turns stock from reserved to gone at the moment it leaves the shelf.
The Challenge:
- A picker waiting for the next order to appear while three are queued
- First-come-first-served queues that make a far-away customer late to save a neighbour twenty seconds
- Walking the store in cart order: chips, then frozen peas, then back to the biscuits next to the chips
- Hunting for an item put away in the wrong bin
- Ice cream sitting on the packing bench while the picker goes back for bread
- A packed bag waiting on the rack because nobody planned a rider
The Solution:
%% title: Inside the dark store
flowchart TD
EV@{ icon: "sd:stream", label: "order.confirmed events" }
Q@{ icon: "sd:queue", label: "Picker queue<br/>earliest pack-by time first" }
PL["Pick list<br/>lines sorted by walk sequence"]:::service
PK@{ icon: "sd:scan", label: "Picker with handheld" }
subgraph Z["Store zones, walked in this order"]
direction TB
A1["Ambient · fast movers near packing"]:::core
A2["Ambient · slow movers at the back"]:::service
CH["Chilled · dairy, drinks"]:::cache
FZ["Frozen · last"]:::cache
end
PS["Pack station<br/>scan each item, bag, seal, label"]:::hot
RK[("Dispatch rack<br/>slot per order")]:::store
R@{ icon: "sd:delivery", label: "Rider" }
EV --> Q --> PL --> PK
PK --> A1 --> A2 --> CH --> FZ --> PS --> RK --> R
PK -.->|"not found"| NF["Fix count, substitute or refund line"]:::bad
class EV,Q queue
class PK client
class R ok- Velocity slotting, re-run weekly
- Rank SKUs by picks a day; the top few hundred go in the bins closest to the pack station
- Heavy items low, fragile items at bag-top height, so they can be packed last
- Deadline-ordered queue
- pack-by = promised arrival − predicted ride − handover allowance
- Pickers always take the earliest pack-by; a late order gets picked first, not last
- Walk-sequence pick lists
- Every bin has a walk-sequence number set from the store's layout (a serpentine route through the aisles)
- Lines are sorted by zone (ambient → chilled → frozen), then by that number
- Batching two orders
- Allowed only when both orders are small (say ≤ 8 items each), the second needs no zone the first doesn't already visit, and both still meet their pack-by times after the combined walk
- Scan to bag, scan to rack
- Item scans catch wrong picks and consume stock; the rack slot scan tells dispatch the bag is ready
# store/picking.py
"""
Picker queue and pick lists for one dark store.
"""
import heapq
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
ZONE_ORDER = {"AMBIENT": 0, "CHILLED": 1, "FROZEN": 2} # cold items last
SECONDS_PER_LINE = 15 # walk + reach + scan, measured per store in practice
PACK_SECONDS = 40
@dataclass(order=True)
class QueuedOrder:
pack_by: float # epoch seconds; heap key
order_id: str = field(compare=False)
lines: List[Tuple[str, int]] = field(compare=False) # (sku_id, qty)
class PickerQueue:
def __init__(self, bins: Dict[str, Tuple[str, int]]):
self.bins = bins # sku_id -> (zone, walk_seq)
self.heap: List[QueuedOrder] = []
def push(self, order: QueuedOrder) -> None:
heapq.heappush(self.heap, order)
def pick_list(self, lines: List[Tuple[str, int]]) -> List[Tuple[str, int]]:
"""One walk through the store: zone order first, then the bins' walk sequence."""
return sorted(lines, key=lambda l: (ZONE_ORDER[self.bins[l[0]][0]], self.bins[l[0]][1]))
def est_seconds(self, lines: List[Tuple[str, int]]) -> float:
return len(lines) * SECONDS_PER_LINE + PACK_SECONDS
def next_task(self, now: float) -> Optional[List[QueuedOrder]]:
"""
Give a free picker the earliest-deadline order, plus a second
order on the same trolley if both still finish in time.
"""
if not self.heap:
return None
first = heapq.heappop(self.heap)
if self.heap and len(first.lines) <= 8 and len(self.heap[0].lines) <= 8:
second = self.heap[0]
zones_a = {self.bins[s][0] for s, _ in first.lines}
zones_b = {self.bins[s][0] for s, _ in second.lines}
# The shared walk takes the lines of both; the second bag's packing adds its own time.
both = self.est_seconds(first.lines + second.lines) + PACK_SECONDS
if zones_b <= zones_a and now + both <= first.pack_by and now + both <= second.pack_by:
heapq.heappop(self.heap)
return [first, second]
return [first]"The 15-seconds-a-line constant is a stand-in. In a real store you'd learn it per store, per zone, even per picker, from the scan timestamps you already collect. Seven lines at fifteen seconds plus forty seconds of packing is 145 seconds, just inside the 150-second budget."
Orders and the store
Q1/3 The customer's UPI app times out, and the gateway can't say whether the payment went through. What should the order saga do?
Answer & explanation
Answer: B
A timeout is an unknown, not a failure. Releasing the stock risks a paid order with nothing to send; a second payment risks charging twice. The reconciler learns the truth and then confirms, or refunds automatically.
Q2/3 Why is "order confirmed" written to an outbox in the same transaction as the state change, instead of calling the picker service directly?
Answer & explanation
Answer: C
If the database commits but the direct call fails, a paid order never reaches a picker. The outbox makes the state change and the event one atomic write.
Q3/3 Two orders are placed at the same second: one for a customer 600 m away, one for a customer 2.4 km away. Which should a picker take first?
Answer & explanation
Answer: D
pack-by = promised arrival − predicted ride − handover. The longer ride leaves less time in the store, so earliest-deadline-first picks the far order first and both arrive on time.
Deep Dive 5: Rider Dispatch, Batching and Live Tracking (Week 1 Concepts: Geospatial, Week 8: Streaming)#
"Riders here are store-anchored: they wait at the store, deliver, and come back. So dispatch is a small, local optimisation, repeated every few seconds per store, not a city-wide matching market."
Two bags, one rider?
Two bags are packed thirty seconds apart for customers in neighbouring buildings, 1.8 km from the store. Only one rider is at the store; the next returns in four minutes. Should one rider take both? What rule decides batching in general, and what does the customer see while the rider is on the way?
One strong answer
- Batch when nobody is late: compute the route store → A → B. If the second drop adds only a minute or so and both customers still arrive within their shown estimates, one rider takes both. Here it does: the other option is a four-minute wait for the second bag.
- The rule: for each pair of packed or nearly packed orders, the extra ride time from the detour must be small (say ≤ 2 minutes) and both arrivals must stay inside their promises. Otherwise send them separately.
- Plan, don't react: assignment is planned from the moment of confirmation using predicted pack times and riders' predicted return times, then confirmed when the bag hits the rack.
- Tracking: the rider app sends a location every few seconds; a location service keeps only the latest position per rider in memory; the customer's app polls (or holds a socket) and shows the dot plus a recomputed arrival time.
- No countdown for the rider: the rider sees the route and the address, not the customer's promise.
The Challenge:
- Bags packed with no rider at the store wait on the rack, eating the promise
- Riders waiting with no bags waste paid time
- Batching saves riders but can make the first customer late
- Fairness: the rider who has waited longest should usually get the next trip
- ETA quality: a promise is only as good as the ride time model behind it, which changes with the hour, the weather and the building (gated societies, lifts)
The Solution:
%% title: Dispatch loop for one store, every few seconds
sequenceDiagram
autonumber
participant O as Order events
participant D as Dispatch (store 412)
participant M as Ride-time model
participant R as Riders at / returning to store
participant C as Customer app
O-)D: Confirmed orders with predicted pack times
R-)D: Positions and predicted return times
D->>M: Ride time store → A, store → B, A → B (hour, weather)
M-->>D: p50 / p90 minutes
Note over D: Pair A + B if detour ≤ 2 min<br/>and both arrive inside their promises
D->>R: Trip offer to the longest-waiting rider who is back in time
R-->>D: Accepted, bag scanned from rack
loop every 4 s while riding
R-)D: Location ping
D-)C: Dot + recomputed arrival
end# dispatch/batching.py
"""
Decides whether two ready orders share one rider. Ride times come
from a model trained on the store's own past trips (by hour, weather
and destination cell), as p90 minutes, so "on time" is judged
conservatively.
"""
from dataclasses import dataclass
from typing import List, Optional, Tuple
MAX_DETOUR_MIN = 2.0
@dataclass
class ReadyOrder:
order_id: str
cell: str # H3 cell of the drop
ready_at_min: float # minutes from now until packed (0 if on the rack)
promise_at_min: float # minutes from now until the shown arrival time
class Batcher:
def __init__(self, ride_model, handover_min: float = 1.0):
self.ride = ride_model # ride.p90(from_cell_or_store, to_cell) -> minutes
self.handover = handover_min
def plan(self, a: ReadyOrder, b: ReadyOrder, rider_free_in_min: float) -> Optional[Tuple[ReadyOrder, ReadyOrder]]:
"""Return the (first, second) drop order if batching keeps both promises."""
start = max(a.ready_at_min, b.ready_at_min, rider_free_in_min) + self.handover
best = None
for first, second in ((a, b), (b, a)):
t_first = start + self.ride.p90("STORE", first.cell)
t_second = t_first + self.handover + self.ride.p90(first.cell, second.cell)
solo_second = max(second.ready_at_min, rider_free_in_min) + self.handover + self.ride.p90("STORE", second.cell)
detour = t_second - solo_second
if t_first <= first.promise_at_min and t_second <= second.promise_at_min and detour <= MAX_DETOUR_MIN:
if best is None or t_second < best[0]:
best = (t_second, (first, second))
return best[1] if best else None
def pick_rider(riders: List[dict], needed_at_min: float) -> Optional[dict]:
"""Longest-waiting rider who will be at the store in time (fairness first)."""
eligible = [r for r in riders if r["back_in_min"] <= needed_at_min]
return max(eligible, key=lambda r: r["waiting_since_min"], default=None)Live tracking without a firehose:
- ~30,000 location pings a second at 9 PM (Phase 2) go to a location service that keeps only the latest position per rider in memory, with a short TTL; the trail is appended to a stream for analytics and dispute resolution.
- The customer's app asks for one rider's position every few seconds. That is a key lookup, not a query over all riders.
- The arrival estimate shown during the ride is recomputed from the rider's position and the ride-time model, and only ever moves the promise later with an honest message ("running 3 minutes late, heavy rain"), never silently.
Deep Dive 6: The Promise Engine (Week 10 Concepts: SLOs, Week 3: Backpressure)#
"Everything so far assumes the store can keep up. The promise engine is the part that knows when it can't, and changes what we promise before customers are let down."
An estimate you can keep
It's 8:55 PM. Store 412 has 14 orders queued, 9 pickers, 5 riders at the store and 30 out. What estimate should a new customer 2 km away see? At what point should the store stop offering ten-ish minutes, and what are the steps between "open" and "closed"?
One strong answer
- Build the estimate from live parts: queue wait (14 orders ÷ 9 pickers ≈ 1.6 orders each, at ~2.5 min ≈ 3.9 min) + pick and pack p90 for this basket (~3.5 min) + the wait for a rider to be back (5 are at the store, so ~0) + handover (~0.5 min) + the p90 ride for that cell (~10 min for 2 km at this hour) ≈ 18 minutes. That's honest; showing "10" would be broken for most customers.
- Promise the p90, not the average: customers judge you by the late orders. Show the time you'll beat nine times in ten.
- Steps, not a switch: OPEN (normal estimates) → STRETCHED (estimates grow, everything still accepted) → THROTTLED (the outer cells, which cost the most rider minutes, close first; maybe cap large baskets) → CLOSED (not serviceable right now, offer to schedule).
- Hysteresis: close at one threshold and reopen at a lower one, held for a few minutes, so a store doesn't flap open and closed every 30 seconds.
- Watch the outcome: if the share of orders delivered by their shown time drops below the target, tighten automatically, because the model is wrong about tonight.
The Challenge:
Promise too short
The app says 10 minutes, the store has 6 minutes of queued picking and the rain has doubled ride times. Customers wait 25 minutes, support queues fill up, and some riders feel pressure to make up time on wet roads. Exactly the risk the January 2026 rider-safety push targeted.
Promise too long, or closed too eagerly
The app says 30 minutes when the store could do 12, or shows "not serviceable" during a short burst. Customers go elsewhere. Both errors cost; only one of them also costs safety.
The Solution:
%% title: Store modes with hysteresis stateDiagram-v2 [*] --> OPEN OPEN --> STRETCHED: backlog > 4 min or riders short STRETCHED --> OPEN: backlog < 2 min for 3 min STRETCHED --> THROTTLED: backlog > 8 min or promise kept < 85% THROTTLED --> STRETCHED: backlog < 5 min for 3 min THROTTLED --> CLOSED: backlog > 15 min, or power/network lost CLOSED --> THROTTLED: backlog < 8 min for 5 min and store healthy OPEN: OPEN · normal estimates, all cells STRETCHED: STRETCHED · longer estimates, all cells THROTTLED: THROTTLED · outer cells closed, big baskets capped CLOSED: CLOSED · not serviceable now, schedule later class OPEN ok class STRETCHED core class THROTTLED queue class CLOSED bad
# promise/engine.py
"""
Per-store promise engine. Runs every few seconds per store and
publishes (mode, open cells, per-cell estimates) to serviceability.
"""
import math
from dataclasses import dataclass
from typing import Dict, List
PICK_P90_MIN = 3.5 # pick + pack p90 for a typical basket at this store
HANDOVER_MIN = 0.5
@dataclass
class StoreNow:
queued_orders: int
pickers_active: int
riders_at_store: int
riders_back_in_min: List[float] # predicted return times of riders out
promise_kept_last_hour: float # share delivered by the shown time
healthy: bool # power, network, handhelds
class PromiseEngine:
# (enter threshold, leave threshold) for backlog minutes, per mode
ENTER = {"STRETCHED": 4.0, "THROTTLED": 8.0, "CLOSED": 15.0}
LEAVE = {"STRETCHED": 2.0, "THROTTLED": 5.0, "CLOSED": 8.0}
ORDER = ["OPEN", "STRETCHED", "THROTTLED", "CLOSED"]
def __init__(self, ride_model, outer_cells: List[str], all_cells: List[str]):
self.ride = ride_model
self.outer = set(outer_cells) # cells with the longest rides
self.cells = all_cells
self.mode = "OPEN"
self.calm_since = None # when the backlog last fell below LEAVE
def backlog_min(self, s: StoreNow) -> float:
if s.pickers_active == 0:
return math.inf
return s.queued_orders / s.pickers_active * 2.5
def rider_wait_min(self, s: StoreNow) -> float:
if s.riders_at_store > 0:
return 0.0
return min(s.riders_back_in_min, default=15.0)
def estimate(self, s: StoreNow, cell: str) -> int:
eta = (self.backlog_min(s) + PICK_P90_MIN
+ max(0.0, self.rider_wait_min(s) - PICK_P90_MIN) # rider wait overlaps picking
+ HANDOVER_MIN + self.ride.p90("STORE", cell))
return int(math.ceil(eta))
def pressure(self, s: StoreNow, nxt: str) -> bool:
"""Should the store move up to mode `nxt`?"""
if self.backlog_min(s) > self.ENTER[nxt]:
return True
if nxt == "STRETCHED" and self.rider_wait_min(s) > PICK_P90_MIN:
return True # bags would be packed before any rider is back
if nxt == "THROTTLED" and s.promise_kept_last_hour < 0.85:
return True # the model is wrong about tonight
return False
def step(self, s: StoreNow, now_min: float) -> Dict:
b = self.backlog_min(s)
i = self.ORDER.index(self.mode)
if not s.healthy:
self.mode = "CLOSED"
self.calm_since = None
elif i < 3 and self.pressure(s, self.ORDER[i + 1]):
self.mode = self.ORDER[i + 1] # tighten at once, one step at a time
self.calm_since = None
elif i > 0 and b < self.LEAVE[self.mode]:
self.calm_since = self.calm_since if self.calm_since is not None else now_min
hold = 5.0 if self.mode == "CLOSED" else 3.0
if now_min - self.calm_since >= hold:
self.mode = self.ORDER[i - 1] # relax slowly
self.calm_since = None
else:
self.calm_since = None
open_cells = [] if self.mode == "CLOSED" else (
[c for c in self.cells if c not in self.outer] if self.mode == "THROTTLED" else list(self.cells)
)
return {
"mode": self.mode,
"open_cells": open_cells,
"estimates": {c: self.estimate(s, c) for c in open_cells},
}"Two things are deliberately absent. First, nothing here makes riders go faster: when ride times rise, the estimate rises. Second, the engine doesn't set prices. A 'high demand' or 'rain' fee is a business lever; the engine only supplies the signal (backlog, rider shortage) that such a lever could use."
Dispatch and the promise
Q1/4 Two bags for neighbouring buildings are ready 30 seconds apart; batching adds about a minute to the second drop and both still arrive inside their estimates. The only other rider is four minutes away. What should dispatch do?
Answer & explanation
Answer: A
The rule is "batch when nobody is late". A one-minute detour beats a four-minute wait, and both promises hold, so one trip serves both.
Q2/4 Why does the promise engine show the p90 estimate rather than the average?
Answer & explanation
Answer: B
Showing the average means about half the orders arrive later than promised. A p90 estimate is kept for nine orders in ten and still looks fast on a normal night.
Q3/4 At 9:15 PM a store's backlog crosses its throttling threshold. Which cells should stop accepting orders first?
Answer & explanation
Answer: D
Each far order ties up a rider for longer and is most likely to break its promise. Closing the outside first keeps the most customers served on time.
Q4/4 Why does the store close at a backlog of 15 minutes but reopen only after the backlog stays under 8 minutes for 5 minutes?
Answer & explanation
Answer: C
With one threshold, the moment the store closes its backlog drops, it reopens, the flood returns, and it closes again. A gap plus a hold time makes each change stick.
Phase 5: Scaling and Edge Cases (5 minutes)#
"Walk me through a whole day in one store, and then tell me what breaks."
A Day in One Dark Store#
- 6:00-9:00 AM · Morning basket
- Milk, bread, eggs and fruit dominate; dairy arrives early and is put away before the rush
- Fewer riders on shift; the outer cells may open a little later
- 11:00 AM-4:00 PM · Restock window
- Trucks from the regional warehouse; every carton scanned in, put away into bins, availability bits flip back on
- Cycle counts on SKUs whose counts drifted yesterday (the not-found list)
- 7:00-10:00 PM · Evening peak
- The busiest hour carries ~12% of the day's orders (design assumption): ~192 orders, ~8 pickers busy, ~48 riders on shift
- The promise engine is most likely to move between OPEN and STRETCHED here
- 10:00 PM-1:00 AM · Late night
- Snacks, ice cream, cold drinks; fewer riders, so stores stretch or throttle earlier
- Overnight · Reset
- Expiry pulls on dairy and bakery, full counts for drift-prone SKUs
- Tomorrow's demand forecast per SKU drives the next restock order and the rider and picker rosters
Edge Cases#
Edge case 1: Phantom stock at the shelf
Problem: The system says 2, the bin is empty.
Solution:
- Picker taps "not found" and enters the real count; on-hand is corrected at once and a recount is queued (Deep Dive 2)
- The line is substituted or refunded per the customer's choice; the order continues
- SKUs with repeated not-founds get a higher safety buffer until a count proves them accurate
Edge case 2: Payment confirms after the hold expired
Problem: UPI was slow; the stock hold expired and the last packet went to someone else; then the money arrives.
Solution:
- The saga tries to reserve again; if the stock is gone, it refunds automatically and tells the customer clearly (Deep Dive 3)
PAYMENT_PENDINGextends the hold so this is rare, not routine
Edge case 3: The store goes dark mid-rush
Problem: A power cut or network loss stops handhelds and scanners with 20 orders confirmed.
Solution:
- Missing heartbeats from the store's devices mark it unhealthy; the promise engine sets CLOSED within seconds and serviceability routes new customers to neighbours that cover their cells
- Confirmed orders: if the store can still pick on battery-backed handhelds, carry on; if not, cancel and refund promptly with an honest message rather than letting them sit
- Never silently move a confirmed order to another store: its stock was reserved here, and the other store has to reserve it fresh
Edge case 4: Customer not reachable at the door
Problem: The rider has been at the door for five minutes; calls go unanswered.
Solution:
- A bounded wait, then the bag returns to the store as
RETURNED; perishables follow a write-off or quarantine rule, everything else goes back to stock with a scan - Refund per policy; the rider is paid for the trip
Edge case 5: A wrong pin
Problem: The customer's GPS drifts or they order for another address; the pin's cell belongs to a different store's area.
Solution:
- Store selection runs on the delivery address's cell, re-checked at checkout
- If the cell isn't served by the cart's store, the app says so before payment, not after
Graceful Degradation#
- Stretch the estimate
- Show longer, honest times; accept everything
- Batch more
- Allow more two-order trips (still only when both promises hold)
- Shrink the area
- Close the outer cells first; nearer customers keep fast delivery
- Simplify the app
- Serve a cached, store-level home feed instead of personalised recommendations; keep search and checkout
- Cap the basket
- Limit per-order quantity of hot SKUs, and very large baskets, so one order can't take a picker for ten minutes
- Close and offer later
- "Not serviceable right now" with a scheduled slot, instead of a promise the store can't keep
Scaling and edge cases
Q1/3 A store loses power with 20 confirmed orders and pickers can't scan. What is the right handling of those orders?
Answer & explanation
Answer: C
The stock was reserved in this store; another store must reserve it fresh. An honest cancellation and automatic refund beat an order that sits for an hour.
Q2/3 Which rung of the degradation ladder should a busy store reach for first?
Answer & explanation
Answer: A
Stretching the estimate costs least: everyone is still served, just told the truth. Closing areas, capping baskets and closing the store come later, as the backlog grows.
Q3/3 Why does a SKU with repeated "not found" events get a larger safety buffer?
Answer & explanation
Answer: C
available = on_hand − reserved − buffer. For a drift-prone SKU, the buffer keeps the app from selling the packet that probably isn't there, until a recount proves the count right.
Phase 6: Monitoring and Operations#
"Quick commerce runs store by store, so the dashboard has to as well. A national average of 'on time' can hide one store in the rain that is failing every order."
Monitoring Dashboard#
Orders and promise
| Metric | Now |
|---|---|
| Orders, last 10 minutes | 34 |
| Mode | OPEN |
| Promise kept, last hour | 93.8% |
| Order-to-door p50 / p90 | 10.8 / 14.1 min |
In the store
- Picker backlog: 2.1 min (9 of 10 pickers active)
- Pick and pack p50 / p90: 2.4 / 3.6 min
- Lines not found at the shelf: 0.6%
- Bags waiting on the rack for a rider, p90: 40 s
Riders
- On shift: 48 (31 out, 12 returning, 5 at the store)
- Ride p50 / p90: 7.6 / 11.2 min
- Two-order trips: 14%
Alerts (last hour)
- 20:31 (amber) — Ride p90 up 30% in the north-east cells (road works), estimates for those cells raised automatically
- 20:58 (amber) — Backlog crossed 4 min, mode STRETCHED
- 21:04 (green) — Backlog under 2 min for 3 min, mode OPEN
- 21:09 (amber) — SKU "cola 750 ml" not found twice in 20 min, buffer raised to 2, recount queued
SLOs for the Platform#
SLO 1: Promise kept
- Target: ≥ 90% of orders delivered by the estimate shown at checkout, per store, per hour (design target)
- Why per store: a national number hides the stores that are failing tonight
SLO 2: Stock truth
- Target: ≤ 1% of picked lines not found at the shelf (design target)
- Alert: any SKU not found twice within 30 minutes at one store
SLO 3: Money integrity
- Target: 100% of captured payments end in a delivered order or a completed refund within 30 minutes
- Error budget: 0. Reconciliation runs continuously against the gateways' records
SLO 4: Checkout
- Target: p99 < 1.5 s for reserve + create payment; 99.95% success excluding customer declines
SLO 5: Honest serviceability
- Target: no store stays OPEN while its promise-kept rate is below 85% for 15 minutes; the promise engine should have tightened already
Reading the dashboard
Q1/3 Store 412 shows "Promise kept 93.8%" and an order-to-door p90 of 14.1 minutes. How can both be healthy at once?
Answer & explanation
Answer: B
The promise isn't "10 minutes for everyone". Far or busy-time orders were shown longer estimates and still met them, so many slow orders still count as kept.
Q2/3 Why is the promise-kept SLO measured per store per hour rather than nationally per day?
Answer & explanation
Answer: C
Quick commerce fails locally: one store, one evening, one weather cell. The SLO must be at the level where you'd act.
Q3/3 The same SKU is not found twice in twenty minutes at one store. What does the alert trigger?
Answer & explanation
Answer: D
It's a local stock-truth problem. Raising the buffer stops further phantom sales while the recount finds out what really happened.
Final Challenge: Rain on Match Night#
The interviewer looks out of the window. "One more. And it's going to rain."
"It's 8:30 PM on the night of a big cricket final. Demand at store 412 jumps to three times its normal busiest hour. Then heavy rain starts: 30% of riders go offline, and every ride takes half as long again. Chips, cold drinks and ice are selling out. What do you shed, what do you promise, and what do you close?"
- Demand: 3 × 192 = ~576 orders an hour at store 412
- Riders: 48 on shift, 30% go offline → ~34 riders
- Rides: 8 min out becomes 12, 7 min back becomes ~10.5, so a trip takes ~25 minutes instead of ~17
- A few hot SKUs are running out; pickers are fine, riders are the bottleneck
- The store must not become a place where riders feel they have to race on wet roads
What do you shed, promise and close?
Work out how many orders an hour the store can actually deliver tonight, then decide what the app should say to the other customers.
One strong answer
- Capacity first: ~34 riders × (60 ÷ 25) trips an hour ≈ 82 trips; batching more aggressively tonight (1.3 orders a trip, still only when both promises hold) gives ~105-110 orders an hour. Demand is ~576. The store can serve about one order in five on time.
- Promise honestly: the promise engine's ride model takes the rain multiplier, so estimates jump from ~11 to 20-plus minutes, and the promise-kept rate decides whether that's enough.
- Shed from the outside in: THROTTLED closes the outer cells first; the nearest customers, whose short rides use the fewest rider minutes, keep being served. If the backlog keeps growing, CLOSED with "schedule for later" for everyone else.
- Protect the hot SKUs: raise buffers and cap per-order quantity (say four of any one SKU), so one party order can't empty the cold drinks for twenty customers.
- Shed load in the app, not in checkout: cached store feeds instead of personalised ones; checkout and tracking keep their capacity.
- Never push riders: no countdowns, rain safety prompts, estimates that grow instead of speeds. Late is a message to the customer, not pressure on the rider.
- Next time, plan: a final is on the calendar. Pre-stock snacks and drinks from last final's demand, roster extra riders and pickers, and open the evening in STRETCHED rather than discovering it at 8:45.
Final challenge · Quest check
Q1/4 Tonight store 412 has about 34 riders, each trip takes about 25 minutes, and trips average 1.3 orders. Roughly how many orders an hour can it deliver?
Answer & explanation
Answer: B
34 × 60 ÷ 25 ≈ 82 trips an hour, × 1.3 orders a trip ≈ 106. That's a fifth of the ~576 orders customers want, which is why the store must shed, not just stretch.
Q2/4 Demand is five times what the riders can carry. Which response keeps the most customers served on time?
Answer & explanation
Answer: C
Far orders use the most rider minutes per order. Closing them first serves more orders an hour within their promises, and the rest are told the truth or offered a later slot.
Q3/4 Cold drinks are selling out. Why cap the per-order quantity of hot SKUs tonight?
Answer & explanation
Answer: D
With limited stock, a cap spreads it across more households. Raising the safety buffer at the same time keeps the app from selling the last units that may not be on the shelf.
Q4/4 Ride times have risen by half in the rain. What should change in the system?
Answer & explanation
Answer: A
Speed comes from proximity and the store, never from the road. When the road is slower, the promise moves, and batching continues wherever it keeps both promises.
Interview Conclusion#
"Good. A few quick ones to finish."
"Why not reserve stock the moment something goes into the cart? Customers hate seeing an item vanish at checkout."
"Because carts are cheap and abandoned constantly, and every held unit is one a paying customer can't buy. Reserving at checkout holds stock only for people actually paying, and a shortfall shows up before they pay. To keep the checkout surprise rare, I'd keep the in-stock flags fresh within seconds and add a safety buffer on SKUs whose counts drift."
"Where would you spend your next engineering quarter?"
"On stock truth and the ride-time model. A missing packet breaks an order outright, and a bad ride estimate breaks the promise for a whole cell. Both are measured by data the system already collects: not-found events, scan timestamps, GPS trails."
"What's the single most important design decision?"
"Making the promise a per-order prediction from live store state, instead of a fixed number. Once the estimate is honest, everything else has a target to hit: the picker queue orders by it, dispatch batches within it, and the store closes its outer cells to protect it. And it means a slow night lands on the estimate, not on a rider's speedometer."
Summary: Concepts Applied from 10-Week Course#
- Week 1: Data at scale
- Partitioning — stock, reservations and orders sharded by store, so a basket is one local transaction
- Geospatial indexing — H3 cells mapping a pin to two or three candidate stores
- Week 2: Failure-first design
- Idempotency — one key per checkout attempt across order, reservation and payment
- Timeouts — a payment timeout is "unknown", reconciled rather than guessed
- Week 3: Messaging and async processing
- Transactional outbox — "order confirmed" and its event commit together
- Backpressure — the promise engine shrinks the area a store accepts when its queues grow
- Week 4: Caching
- A seconds-stale availability cache (in-stock bitmaps) for the read plane, re-checked at checkout
- Week 5: Consistency and coordination
- Guarded conditional updates for reservations; holds with a TTL
- Saga — reserve, pay, confirm, with compensation (release, refund, line refund)
- Week 7: Search
- A global product index filtered by the serving store's in-stock bitmap
- Week 10: Production readiness
- SLOs at the level you act on: per store, per hour
- Graceful degradation — a ladder from longer estimates to closing the store
- Capacity planning — pickers, riders and stock rostered from forecasts for known peaks
Why Blinkit Matters#
Physical truth
- Most systems in this course model data. This one models a shelf, a picker and a rider, and the data is only right if it matches them
- Inventory losses of about 1.8% of net order value are a reminder that the shelf and the database drift every day
Local, not global
- Load is modest nationally (a few hundred orders a second) but intensely local: one store, one evening, one rainstorm
- Every important decision (store, queue order, batching, mode) is made per store, every few seconds
Promise as product
- The customer buys a time, not just groceries. An honest, per-order estimate is the core feature
- In January 2026 Blinkit dropped its fixed "10-minute" branding after a government push on rider safety; the engineering lesson is the same one this design starts from: speed comes from store density and store operations, not from the road
"The ten minutes were never on the road. They were in where the store is, how it's laid out, and how honestly it tells you when it's busy."
Self-Assessment Checklist#
After studying this case study, you should be able to:
Architecture:
- Split a quick-commerce platform into a stale-tolerant read plane and an exact, per-store write plane
- Trace one order from tap to door, naming each step's time budget
Inventory:
- Explain why stock is reserved at checkout rather than at add-to-cart or at pick time
- Write a guarded conditional update that reserves a basket without overselling
- Handle phantom stock with counts, buffers and line-level substitutions
Store and road:
- Order a picker queue by pack-by time and plan a pick path through store zones
- Decide when two orders should share a rider
- Track riders live without querying every location on every refresh
The promise:
- Build an estimate from live queue, picker, rider and ride-time state
- Design store modes with hysteresis, closing the outer cells first
- Explain why the estimate, not the rider, absorbs a slow night
Operations:
- Define per-store SLOs for promise kept, stock truth and money integrity
- Plan a match night or festival: pre-stock, roster, and degrade gracefully
Sources#
Company figures (Eternal Ltd, Q1 FY27 and FY26):
- Read
Eternal Ltd — shareholders' letters and results: https://www.eternal.com/investor-relations/results
- Read
MediaNama — 7 key takeaways from Eternal Q1 FY27 earnings call (stores, net AOV, customers, inventory losses): https://www.medianama.com/2026/07/223-takeaways-eternal-q1-fy27-earnings-call/
- Blog
INDmoney — Eternal results: Blinkit's strongest quarter (331 million orders, NOV, AOV, stores): https://www.indmoney.com/blog/stocks/eternal-results-blinkit-strongest-quarter
- Read
Outlook Business — Blinkit widens quick commerce lead (FY26 orders and store counts): https://www.outlookbusiness.com/corporate/blinkit-widens-quick-commerce-lead-matches-rivals-zepto-and-instamart-combined-in-scale
- Read
IIFL — Eternal Q1 FY27 results, Blinkit becomes biggest revenue driver: https://www.indiainfoline.com/news/earnings/eternal-q1-fy27-results-net-profit-jumps-3-7x-to-92-crore-as-blinkit-becomes-biggest-revenue-driver
Delivery times, rider safety and the "10-minute" branding:
- Read
News24 — Deepinder Goyal explains 10-minute deliveries (2.5 min pick and pack, under 2 km, ~8 min, ~15 km/h, no timer on the rider app), 2 January 2026: https://news24online.com/india/after-gig-workers-strike-zomato-ceo-deepinder-goyal-explains-10-minute-deliveries-says-rider-drives-an-average-of-under-2kms-in-about-8-minutes/700638/
- Read
Inc42 — Blinkit drops "10-minute delivery" claim after govt push (2.03 km average distance): https://inc42.com/buzz/blinkit-drops-10-minute-delivery-claim-after-govt-push/
- Read
All India Radio News — Delivery aggregators agree to drop 10-minute delivery deadline after government intervention, 13 January 2026: https://www.newsonair.gov.in/delivery-aggregators-agree-to-drop-10-minute-delivery-deadline-after-government-intervention-sources
- Read
Entrackr — Blinkit removes 10 min delivery branding after govt intervention: https://entrackr.com/news/blinkit-removes-10-min-delivery-branding-after-govt-intervention-zepto-instamart-others-in-focus-10994919
- Read
Storyboard18 — Eternal reports record New Year's Eve orders (75 lakh+ across Zomato and Blinkit): https://www.storyboard18.com/brand-marketing/zomato-blinkit-parent-eternal-reports-record-new-years-eve-orders-despite-gig-worker-strike-call-86878.htm
Company background:
- Read
Inc42 — Zomato acquires Blinkit for $568 Mn (₹4,447 crore, all-stock, 2022): https://inc42.com/?p=306165
- Read
YourStory — Zomato is now Eternal, MCA approves name change (March 2025): https://yourstory.com/2025/03/zomato-now-eternal-mca-approves-name-change
- Read
MediaNama — Blinkit's inventory model shift (from September 2025): https://www.medianama.com/2025/07/223-blinkit-inventory-model-shift-2025-means-for-sellers/
Geospatial:
- Book
H3 — table of cell areas by resolution: https://h3geo.org/docs/core-library/restable/
Design note: the architecture, code, thresholds, budgets and dashboards in this case study are an illustrative design for the problem, not a description of Blinkit's internal systems, which are not publicly documented.
Further Reading#
Related systems to study:
- Read
Uber (Bonus Problem 9 in this series): city-wide matching with H3; compare it with this store-anchored dispatch
- Read
IRCTC Tatkal (Bonus Problem 11): extreme contention on a few hundred rows; compare it with quick commerce, where contention is low and stock truth is the hard part
- Talk
Food delivery (this course's Week 3 and Week 6 capstones): restaurant preparation instead of a store you control
Foundational concepts (this course):
- Read
Week 1, Day 1 — Partitioning Deep-Dive
- Read
Week 2, Day 2 — Idempotency in Practice
- Read
Week 3, Day 2 — Transactional Outbox
- Read
Week 3, Day 3 — Backpressure and Flow Control
- Read
Week 5, Day 2 — Distributed Transactions: The Saga Pattern
- Read
Week 10, Day 1 — SLIs, SLOs and SLAs
"A basket, a picker, a rider and two kilometres. The system that makes it feel instant isn't the one that drives fastest. It's the one that is honest about how long it will take."
- 0/7stages cleared
Go deeper · reading trail
all reading →Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.
Instacart's Item Availability Architecture: Solving for scale and consistency
2023- What
- How Instacart keeps item availability consistent across search, item pages and checkout.
- Key idea
- ML availability scores are stored in the database and refreshed two ways: a full sync several times a day and a lazy refresh triggered when items appear in search.
- Why here
- The real-time inventory deep dive of this quest: availability shown to a customer must agree on every surface.
Route IQ: How We Reconstruct Delivery Paths from Noisy GPS at Scale
2026- What
- How Zepto turns noisy rider GPS pings into the paths riders actually took.
- Key idea
- Outlier pings are removed, stalls detected, gaps smoothed with a Kalman filter, and the result map-matched with OSRM and given a confidence score.
- Why here
- The live tracking and promise-engine inputs of this quest: ETAs and SLAs are only as good as the location data under them.
H3: Uber's Hexagonal Hierarchical Spatial Index
Isaac Brodsky · 2018- What
- Uber's open-source hexagonal grid for indexing locations.
- Key idea
- Every point maps to a hexagon at a chosen resolution, with uniform neighbour distances and parent-child nesting.
- Why here
- A practical way to precompute which dark store serves each delivery cell, the serviceability lookup in this quest.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.