Design Local Delivery
Design one-hour delivery from small local warehouses: mapping customers to centres by travel time, fast availability reads, and orders that never oversell.
On this page
It is 9 pm and you are out of milk. You open an app, see only what can reach you within the hour, add milk and snacks, and pay. A driver brings them from a small warehouse a few kilometres away, one of hundreds the company runs across the country. A service such as Gopuff, delivering from its own local warehouses, is the kind of product this question means.
Interviewers like it because it mixes three skills. "Near me" means travel time, not distance. The catalogue page must read stock fast across several warehouses. And the order must take the last carton of milk exactly once, across a whole basket of items. The steps follow A Framework for Any Design. Try each "Your turn" box before reading on.
Clarify the problem
Design a service that delivers groceries within an hour from local warehouses.
So a customer sees what is available near them and places an order, and we own the warehouses and their stock. Does an order always ship from one warehouse?
Prefer one. Splitting costs a second driver.
How many warehouses, items and customers?
500 fulfilment centres, each with about 5,000 of our 20,000 products. 5 million daily users, 1 million orders a day.
Then my priorities are: never confirm an order we cannot fill, show only what can really arrive within the hour, and keep browsing fast. A catalogue page can be a second or two behind the stock; the order itself must be exact. I'll leave out driver dispatch, picking inside the warehouse, and restocking.
- Functional: given a delivery address, show products that can be delivered within the hour, with availability; place an order for a basket of items; reject or adjust an order if an item just sold out.
- Non-functional: catalogue pages in under 200 ms; availability at most a few seconds stale; never oversell (a confirmed order always has its stock reserved); a failure in one region does not stop orders in the others.
- Out of scope: driver dispatch and tracking (see Design Ride Sharing), picking and packing, restocking, pricing.
Estimate the scale
- Catalogue pages: say 20 page loads per daily user: 5 million × 20 = 100 million a day ÷ 86,400 ≈ 1,200 a second. Evenings are about 4 times busier: about 4,600 a second at peak.
- Orders: 1 million a day ÷ 86,400 ≈ 12 a second, about 46 at peak. Spread over 500 centres, the average centre takes one order every 10 seconds even at peak.
- Inventory: 500 centres × 5,000 products = 2.5 million rows. At about 100 bytes each that is 250 MB: it fits in the memory of any server.
Each catalogue page shows 50 products, and each customer can be served by 2 centres. How many (centre, product) stock lookups a second happen at peak?
- 4,600 catalogue pages a second at peak
- 50 products per page
- 2 reachable centres per customer
Show the working
- 50 × 2 = 100 lookups per page
- 4,600 × 100 = 460,000 lookups a second
Estimate 460 thousand/s (anything from 150 thousand/s to 1.5 million/s is a good answer)
Orders are 46 a second; stock reads are about 10,000 times that. The read path and the order path need different stores.
How much memory holds the stock level of every product in every centre?
- 500 centres
- About 5,000 products stocked per centre
- About 100 bytes per stock row
Show the working
- 500 × 5,000 = 2.5 million rows
- 2.5 × 106 × 100 B = 250 MB
Estimate 250 MB (anything from 80 MB to 800 MB is a good answer)
Small enough to copy into every server that answers catalogue pages.
Write the catalogue and order endpoints. Who chooses the centre, the app or the server? What does the order return when one item has just sold out?
Compare with a model answer
The app sends the address; the server chooses the centre, both when browsing and when ordering. A sold-out item returns 409 with what is missing and the alternatives, so the customer can fix the basket in one tap.
Define the API
GET /catalog?lat=40.73&lng=-73.99&category=dairy&cursor=...
200 { "eta_min": 27,
"items": [ { "id": "p_17", "name": "Oat milk 1 L", "price": 349,
"available": true }, ... ] }
200 { "items": [], "reason": "outside delivery area" }
POST /orders Idempotency-Key: 6f1c...
body: { "lat": 40.73, "lng": -73.99,
"items": [ { "id": "p_17", "qty": 1 }, ... ], "payment": "pm_..." }
201 { "order_id": "o_5521", "centre": "C1", "eta_min": 27 }
409 Conflict { "unavailable": [ { "id": "p_17", "elsewhere": { "eta_min": 39 } } ] }
402 Payment Required card declined; nothing is reserved
GET /orders/{id}The decision worth arguing is that the server picks the centre. If the app sent a centre id, an old app would order from a centre that has since closed, or one that is now 45 minutes away in traffic. Sending the address lets the server apply today's travel times and today's centre status. The order carries an idempotency key, so a retry after a timeout cannot create a second order.
Sketch the records for centres, stock and orders. How would you partition stock so that an order for 15 items is one local transaction?
Compare with a model answer
Stock rows keyed by (centre, product) with on-hand and reserved counts, in a relational database partitioned by region. An order ships from one centre, a centre belongs to one region, so every order's rows sit on one partition.
Model the data
centres
centre_id PK, region_id, lat, lng, status -- open | paused | closed
cell_centres -- precomputed, key-value
cell CHAR(6) -- geohash of a ~0.75 km² area
time_bucket SMALLINT -- morning | midday | evening rush | night
centres JSON -- [["C1", 12], ["C2", 24]]: minutes away
PRIMARY KEY (cell, time_bucket)
inventory -- partitioned by region_id
region_id, centre_id, product_id
on_hand INT -- physically on the shelves
reserved INT -- promised to confirmed orders
PRIMARY KEY (centre_id, product_id)
CHECK (reserved <= on_hand)
orders -- same regional partition as inventory
order_id PK, idempotency_key UNIQUE, region_id, customer_id, centre_id,
status, -- reserved | paid | picking | out | delivered | cancelled
items JSON, total_cents, created_atAvailable stock is on_hand - reserved. Keeping two counts means an order reserves at checkout and the shelf count only drops when a picker scans the item, so the warehouse's numbers always match what is physically there.
An order changes several rows that must all succeed together, so inventory goes in a relational database with transactions. Partition it by region, a metro area with a few dozen centres, because an order touches one centre and so one region: no transaction ever crosses partitions. Orders live on the same partition, so the order row commits with its reservations. Partitioning by product would spread every basket over many partitions and need a distributed transaction for each order. Product names, photos and prices change rarely and go through a cache and a CDN.
Sketch the read path for a catalogue page and the write path for an order. Where does each get stock from?
Compare with a model answer
An availability service answers catalogue pages from an in-memory copy of stock, fed by the inventory database's change stream. An order service runs the reservation transaction on the region's inventory partition. Both use the precomputed cell-to-centres table.
High-level design
The read side serves 460,000 lookups a second from a copy; the write side takes 46 orders a second against the truth. Neither slows the other.
request and replyasynchronous message
Step through Ana browsing and ordering the last carton of oat milk at her nearest centre, while Ben tries to buy the same carton.
- centres
- C1, C2
1Ana opens the dairy aisle. The availability service turns her address into a 6-character geohash cell and looks it up: C1 is 12 minutes away, C2 is 24.
- oat milk at C1
- 1
- oat milk at C2
- 9
2It checks 50 products at both centres in its own memory: 100 lookups, well under a millisecond. Oat milk: 1 left at C1, 9 at C2. The page says available, in 12 + 15 = 27 minutes.
- oat milk at C1
- 1
3She checks out 15 items. The order service looks up her cell again and picks C1, the closest centre that has all 15 in its copy, then opens one transaction on the region's partition.
- oat milk at C1
- 0
- rows reserved
- 15 of 15
4It reserves each item only if
on_hand - reservedcovers the quantity. All 15 rows change, so it commits. The last oat milk at C1 is now Ana's.- oat milk at C1
- 0
- Ben's rows
- 3 of 4
5Ben ordered oat milk from C1 at the same moment. His update waited for Ana's row lock, then re-checked: 0 available. Only 3 of his 4 rows change, so he rolls back.
- Ben
- 409, C2 offered
6Ben gets 409: oat milk is unavailable at C1 but C2 has it, 24 + 15 = 39 minutes. He can switch the whole order to C2 or drop the item. Nothing of his was reserved.
- order o_5521
- paid
7Ana's card is authorized. The order moves to paid and goes to C1's pickers. Had the card been declined, one more transaction would release her 15 reservations.
- copy of C1 oat milk
- 0
8The commit lands in the change stream, and every availability server applies the deltas to its copy within a second or two. C1 now shows oat milk as sold out.
- oversold
- 0
9If the stream falls 30 seconds behind, pages show items that just sold out. Orders stay correct, because the transaction checks the truth; customers just see more 409s, so alert on stream lag.
Deep dives
Interviewers push on three things: which centres can reach a customer, reading stock fast across them, and reserving a basket without overselling.
Mapping a customer to centres by travel time
A centre 3 km away across a river with one bridge may be 30 minutes out; one 6 km away along a highway may be 10. The promise is about time, so "near" must be travel time. Say picking and handover take about 15 minutes; a 30-minute driving limit then leaves a margin inside the hour.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Straight-line radius | One distance check per centre; no routing at all. | Wrong at rivers, highways and hills: promises deliveries that take 40 minutes and hides ones that take 10. | A first version in a city with a regular street grid. |
| Ask a routing engine per request | Uses live traffic for this exact address. | 4,600 pages a second × 3 nearby centres ≈ 14,000 route calculations a second, each adding tens of milliseconds. | Only at checkout, to refine the time shown, not for browsing. |
| Precomputed cell table Usual default | Offline, compute driving time from every centre to every map cell around it; browsing is one key lookup. | Times are per cell and per time-of-day bucket, not live. Must be rebuilt when centres open or roads change. | Almost always. My pick. |
The arithmetic shows the table is small. A precision-6 geohash cell is about 1.2 km × 0.6 km ≈ 0.75 km² near the equator (smaller further from it; see Design Proximity Search). Say each centre serves about 50 km², roughly a 4 km radius in city traffic (a scenario assumption). Then 500 centres cover at most 25,000 km², about 25,000 ÷ 0.75 ≈ 33,000 cells. At 100 bytes per cell that is 3.3 MB. With 3 centres per cell and 4 time buckets (morning, midday, evening rush, night), a nightly rebuild is 33,000 × 3 × 4 ≈ 400,000 route calculations, a modest batch job.
Live conditions go on top as flags read at request time, not as rebuilds: a centre marked paused because its pickers are overwhelmed, or closed for the night, is skipped. That gives the team a lever during a rush without recomputing anything.
A fast availability read across centres
A catalogue page needs, for 50 products, "can I get this within the hour?". That is the best of the customer's 2 or 3 centres for each product. The truth is in the inventory database, but 460,000 lookups a second should not land on the database that takes orders.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Query the inventory database | Always exact. | 460,000 lookups a second on the partitions that run order transactions; read replicas help, but it is still most of the load. | A small operation with a few centres. |
| Shared cache fed by the change stream | A hash per centre in an in-memory store; one batched read per centre per page. | A network hop per centre, and a cache cluster to run and keep in sync. | The stock data is too big to copy onto every server. |
| In-process copy fed by the change stream Usual default | 250 MB fits in each availability server, so a page's 100 lookups cost microseconds and survive a cache outage. | Each server must load a snapshot on start and apply every delta; servers can differ by a moment. | The whole data set is small, as here. My pick. |
Each availability server loads a snapshot of all stock at start-up, noting the stream position it corresponds to, then applies deltas from that position on. That is the same idea as a database replica, built from the change stream. Two more rules make the copy useful. For products whose shelf counts often drift with breakage and mis-scans, show them as available only above a small safety buffer, so you rarely promise a unit that is not there. And never show a count ("only 2 left") unless you accept it will sometimes be wrong for a second.
Ordering without overselling
A basket of 15 items must reserve all 15 or none. If 14 succeed and 1 fails, the customer is charged for a bag with a hole in it, or the 14 are locked away from other customers by a half-finished order.
BEGIN;
UPDATE inventory AS i
SET reserved = i.reserved + b.qty
FROM (VALUES ('p_17', 1), ('p_42', 2), ...) AS b(product_id, qty) -- 15 rows
WHERE i.centre_id = 'C1' AND i.product_id = b.product_id
AND i.on_hand - i.reserved >= b.qty;
-- rows changed = 15: INSERT the order, COMMIT.
-- fewer than 15: ROLLBACK, and report which products fell short (409).
COMMIT;Why it is safe: each row the update changes stays locked until commit. A second order for the same product waits, then, at read committed in databases such as PostgreSQL, re-checks its WHERE clause against the new values, finds too little stock, and skips the row; the count comes up short and it rolls back. At stricter isolation it fails with a serialization error, which you treat the same way (see transactions and isolation). Two baskets sharing products can lock them in different orders and deadlock; the database aborts one, and the order service retries it once. The CHECK (reserved <= on_hand) constraint is a last line of defence against a bug elsewhere.
The rest of the flow follows ticket booking: reserve first, then authorize the card, then confirm. A reservation whose payment never completes expires after a few minutes: a sweeper moves the order from reserved to cancelled only if it is still reserved, and releases its stock in the same transaction. Before cancelling, it asks the payment processor by idempotency key, in case the card was authorized just before a crash. When a picker finds a shelf empty despite the count, the order offers a substitute or a refund for that item and the count is corrected; no transaction can fix a miscounted shelf.
Bottlenecks and failures
At 10x, 46,000 page loads and 460 orders a second at peak, the availability servers scale by adding copies, since each holds everything. The order side is 460 ÷ 500 ≈ 1 order a second per centre, still easy for a region's partition. The first strain is a hot product: a promotion on one item sends many orders to the same few rows at one centre, and they queue on its row lock. Cap the quantity per order, and if needed split the item's stock into several rows that orders pick at random, as in the general-admission answer of ticket booking.
| When this dies | What happens | What you do |
|---|---|---|
| An availability server | Its pages fail until the load balancer notices. | The others hold full copies; a new server loads a snapshot and catches up from the stream. |
| The change stream | Copies freeze; pages show stock as it was. | Orders stay correct; customers see more 409s. Alert on lag and hide low-stock items while it lasts. |
| A region's inventory primary | Orders in that region fail until a replica takes over. | Synchronous replica so no reservation is lost. Other regions are unaffected, which is a benefit of partitioning by region. |
| The payment processor | Orders reserve stock but cannot be authorized. | Release reservations after a short timeout so stock is not stranded; tell customers to retry. |
| The cell-to-centres store | New customers cannot be mapped. | It is 3.3 MB: keep a copy in every availability and order server. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Finds centres by straight-line distance, reads stock from the main database on every page, and decrements quantities one item at a time. |
| Mid | Uses travel time, caches availability, and reserves the basket in one transaction with a conditional update. |
| Senior | Precomputes travel time per map cell, sees that all stock fits in memory and copies it into the read servers from a change stream, partitions inventory by region so every order is a local transaction, and separates the copy that shows from the truth that sells. |
How would you shard the inventory database?
By region. An order ships from one centre and a centre is in one region, so every basket's rows are on one partition and the reservation is an ordinary local transaction. Sharding by product would put a 15-item basket on up to 15 shards and need a distributed commit for every order.
What's the cost?
Regions are uneven, so a big metro needs a bigger partition, and a nationwide query, like total stock of one product, has to fan out. I'd serve those from an analytics copy, not the order database.
Follow-up questions
No single centre has the whole basket. What then?
Offer the choices in the 409: move the whole order to the farther centre with a later time, drop the missing items, or split into two deliveries for an extra fee. Splitting means two reservations on two centres in the same region, each its own transaction, with the second released if the first fails.
How do you show a delivery time on the page?
From the cell table's minutes for the chosen centre, plus the current picking backlog at that centre, which the order service publishes every few seconds.
A customer is right at the edge of a centre's area. Do they see it?
Only if their cell's travel time is under the limit. Cells are about 0.75 km², so the edge is fuzzy by a few hundred metres; add a small margin to the limit rather than computing routes per address.
How do you handle restocking deliveries?
A delivery to a centre is a transaction that adds to on_hand; the change stream carries it to the copies, and products reappear on pages within seconds.
Could you reserve stock when an item goes into the cart?
It would stop disappointments at checkout, but abandoned carts would hide stock for minutes from customers ready to pay. Reserve at checkout, and warn in the cart when the copy shows an item has run out.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".