Guide · System design
Design a Quick-Commerce App (10-Minute Delivery)
20 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Quick commerce system design is about selling only what one dark store, a small local warehouse closed to walk-in shoppers, really has, then packing it within minutes.
- Each delivery address maps to one dark store through small map cells. The app shows that store's stock, not a city-wide catalogue.
- Every stock row keeps two numbers: on_hand, the units on the shelf, and reserved, the units promised to open orders. Free stock, what you can still sell, is on_hand minus reserved.
- At checkout, one conditional update, a database write that runs only if enough stock is free, checks and holds stock in one step. Unpaid holds expire after a few minutes.
- Beginners can stop at the warning that every hold needs an expiry. Engineers get tested details after it. They cover a stock leak in the sweeper (the job that clears expired holds), a late-payment race, duplicate-proof updates to the fast stock copy (cache) and a restock rule.
What goes wrong when a 10-minute grocery app sells stock it does not have?
The customer pays, a picker walks to the bin, and the bin is empty. That is the core failure in quick commerce system design: the stock count and the real shelf have drifted apart.
Picture a common setup. A grocery app in Delhi runs 38 small dark stores, each serving homes within about 2 km. The setup is made up, but the problem is real.
On the night of a big cricket final, orders at one South Delhi store jumped to four times a normal evening. Chips, cold drinks and ice sold out first. The app kept selling them for twenty more minutes.
Pickers kept finding empty bins. That night, 212 orders lost at least one item after payment. Each one meant a refund, an apology call and often a customer who did not come back.
The team traced it to four causes.
- The app read stock from a copy refreshed every five minutes, so it was always a little behind the shelf.
- Two customers could check the same last packet at the same moment, and both checks said yes.
- Stock went down only when a picker scanned the item, several minutes after the customer had paid.
- Ice was restocked on a fixed morning schedule, so nobody ordered more before the evening rush.
The guide fixes each cause in turn, with code you can run, and ends with how to explain the design in an interview.
What is quick commerce system design?
Quick commerce system design is the design of the software behind apps that deliver groceries in minutes from small local warehouses. Blinkit, Zepto and Swiggy Instamart are well-known Indian examples.
The speed does not come from fast riders alone. It comes from keeping goods very close to customers, so every trip is short. That changes what the software must get right.
Dark store. A dark store is a small warehouse laid out like a shop but closed to walk-in customers. Pickers inside pack online orders only. Each store in the setup holds about 3,000 items in numbered bins.
SKU. An SKU, short for stock keeping unit, is one exact product the store counts on its own. A 200 g pack of paneer and a 500 g pack are two different SKUs.
Serviceability. Serviceability is the check that answers one question: which dark store, if any, can deliver to this address right now?
Hold. A hold, also called a reservation, is stock set aside for one order that is still paying. Nobody else can buy those units while the hold lasts.
Think of the week before Holi. Colour stalls pop up in every lane, each with a few sacks of gulal. You buy from the stall in your lane, even if the wholesale market has tonnes of pink.
A dark store is that lane stall. The lane is the store's delivery area, and the sacks are its stock. So quick commerce is not one big stock count. It is 38 small counts, each kept honest.
Where the picture breaks: a stall owner can look at the table, but software only sees numbers. Most of this guide is about keeping those numbers close to the table.
Before drawing anything, write down what the system must do.
- Show each customer only items their store can deliver, with a count close to the real shelf.
- Never sell the same last unit twice, even when many people check out at once.
- Get a paid order onto a picker's phone within a few seconds.
- Keep browsing fast. The setup sees about 50 stock reads for every order placed.
- Restock each store before popular items run out.
Here is the whole system on one page. Start at the customer app. Notice that every change to stock goes to one place: the inventory database.
The quick commerce system, part by part
Drag to exploreNotice the split: browsing reads only the cache, while every stock change goes through the inventory database first.
Diagram connections:
- Customer app to Serviceability service (Which store?)
- Customer app to Catalogue service (Browse)
- Customer app to Order service (Checkout)
- Catalogue service to Stock cache (Redis) (Read stock)
- Order service to Inventory DB (PostgreSQL) (Hold stock)
- Order service to Payment gateway (Charge)
- Order service to Picker app (Send pick list)
- Picker app to Inventory DB (PostgreSQL) (Picked or missing)
- Inventory DB (PostgreSQL) to Stock events (Kafka) (Changes via outbox)
- Stock events (Kafka) to Stock cache (Redis) (Update counts)
- Customer app: Browse, cart, pay and track
- Serviceability service: Location to map cell to dark store
- Catalogue service: Lists only this store's items
- Order service: Checkout, holds and payment status
- Inventory DB (PostgreSQL): One stock row per store and SKU
- Payment gateway: Takes UPI (India's instant bank payments) and cards
- Stock events (Kafka): Every stock change, kept in order
- Stock cache (Redis): Fast free-stock counts per store
- Picker app: Pick, pack, report missing items
The split is on purpose. Browsing is almost all reads, so it hits a cache, a fast copy kept in memory. Buying is a write that must be exact, so it goes to the database, which has the final word. Redis, an in-memory data store, holds that cache. Kafka stores the stream of stock events, and the outbox, explained later, stops a crash from losing an event.
How does a quick commerce app decide which dark store serves you?
The app turns your delivery location into a map cell, then looks up which dark store covers that cell. If no open store covers it, you see a not-serviceable message instead of a catalogue.
A common choice is H3, an open-source grid system developed at Uber. H3 splits the world into hexagonal cells. It turns any latitude and longitude into the cell that contains it, and it can list a cell's neighbours.
H3 has 16 sizes, called resolutions. At resolution 9, a cell covers about 0.105 square km on average, with edges about 200 m long. All cells within 7 steps of a centre cell number 169, and the farthest cell centre is about 2.5 km away.
The team draws each store's area as a set of cells, based on real riding time, not a neat circle. A busy flyover or a canal can make a close block slow to reach. Those cells go to another store, or to none.
The cell-to-store map is small and changes rarely. Every app server keeps a copy in memory. In tests with the h3 4.5.0 Python package, one cell lookup plus a dictionary read took 2 to 3 microseconds (millionths of a second).
If you want to know how map cells work inside, geohashes and quadtrees solve the same problem with squares.
How do you track live inventory in a dark store?
You keep one stock row per store and SKU, holding the units on the shelf and the units already promised to orders. Free stock is the difference, and every change is also written to a log.
The first fix is to stop storing a single number. One count cannot tell a packet sitting free on the shelf from a packet a paid customer is waiting for.
| Field | What it means | Paneer 200 g |
|---|---|---|
| on_hand | Units physically in the bin right now | 30 |
| reserved | Units held for orders not yet picked | 4 |
| free | on_hand minus reserved: what checkout may still hold | 26 |
| safety_buffer | Units the app hides on fast sellers; checkout does not use it | 2 |
| version | Goes up by one on every change, so the cache can spot old news | 42 |
| bin | Where a picker finds it | B-14 |
Many events change these numbers. A van delivers a crate, so on_hand goes up. A customer taps Pay, so reserved goes up. A picker scans the pack, so both go down by one.
Every change also goes to a movement log: what changed, by how much, why, and which order or crate caused it. The stock row is the current answer. The log explains how it got there.
When the store manager asks why the row says 30 on hand when the shelf holds 28, the log helps. It lists every sale and delivery since the last count. Whatever is still missing is loss nobody recorded, like two torn packs thrown away.
How do you stop two customers buying the last packet?
You make the check and the hold one single database step, so nothing can slip in between them. In SQL, the language most databases speak, that is an UPDATE whose WHERE clause (its condition) also says enough stock is still free.
The naive version takes two steps, and it is wrong. It reads the count, sees 1, and then writes 0. Two checkouts that read at the same moment both see 1, so both orders go through.
This is a race condition: the result depends on which request happens to run first. In a test on PostgreSQL 16.15, 20 buyers raced for 1 packet using read-then-write. Most of 20 rounds sold that packet 20 times.
The fix does both steps in one UPDATE. A transaction is a group of database changes that succeed or fail together. PostgreSQL locks each row an UPDATE changes until that transaction ends.
A second transaction updating that row waits until the first commits (saves) or rolls back (cancels). If the first commits, PostgreSQL checks the second's WHERE clause again, against the new version of the row. If the first order took the last packet, the condition fails and nothing changes.
That recheck is how Read Committed works. Read Committed is PostgreSQL's default isolation level, the setting for how much one transaction sees of others' changes. The same 20-buyer test sold the packet exactly once in every round.
At the stricter Repeatable Read level, if the first transaction commits its change, the second update fails instead, with the error "could not serialize access due to concurrent update". Your code must then retry the whole transaction.
Here are the two tables the code needs. The holds table's primary key, the columns that must be unique, allows one row per order and SKU. That matters later for retries.
CREATE TABLE stock (
store_id int NOT NULL,
sku text NOT NULL,
on_hand int NOT NULL CHECK (on_hand >= 0),
reserved int NOT NULL DEFAULT 0 CHECK (reserved >= 0),
version bigint NOT NULL DEFAULT 0,
PRIMARY KEY (store_id, sku)
);
CREATE TABLE holds (
order_id text NOT NULL,
store_id int NOT NULL,
sku text NOT NULL,
qty int NOT NULL,
status text NOT NULL DEFAULT 'pending',
expires_at timestamptz NOT NULL,
PRIMARY KEY (order_id, sku)
);Here is the reserve function. The db object is a database connection, such as one from psycopg 3, the library Python uses to talk to PostgreSQL, and OutOfStock is your own error type. The items argument maps each SKU to a quantity.
def reserve(db, store_id, order_id, items):
"""items maps each SKU to a quantity, e.g. {"PANEER200": 2}"""
with db.transaction(): # all holds, or none
for sku, qty in sorted(items.items()): # one fixed lock order
row = db.execute(
"UPDATE stock SET reserved = reserved + %s, "
"version = version + 1 "
"WHERE store_id = %s AND sku = %s "
"AND on_hand - reserved >= %s RETURNING sku",
(qty, store_id, sku, qty)).fetchone()
if row is None:
raise OutOfStock(sku) # undoes earlier holds
db.execute(
"INSERT INTO holds (order_id, store_id, sku, qty, expires_at) "
"VALUES (%s, %s, %s, %s, now() + interval '5 minutes')",
(order_id, store_id, sku, qty))Three lines matter. First, the WHERE clause adds a hold only if on_hand minus reserved still covers the quantity. If no row comes back, the item did not have enough free stock.
Second, raising OutOfStock inside the transaction undoes every hold made so far. In the test, a basket of paneer and sold-out milk left paneer's reserved count exactly where it started.
Third, sorted() locks items in one fixed order. Without it, one order could lock paneer then wait for milk while another locks milk then waits for paneer. That is a deadlock: each waits for the other.
PostgreSQL spots a deadlock after deadlock_timeout, 1 second by default, and cancels one of the transactions. In the test, opposite lock orders cancelled one checkout after about 1.3 seconds. With both checkouts in sorted order, it never happened.
The version column goes up on every change, which the cache uses later. In the real service, the same transaction also writes an outbox row for the event. It is left out here to keep the code short.
Check the race yourself in two terminals
You can watch the recheck happen with psql, PostgreSQL's command-line client, and two terminal windows connected to the same test database.
-- once, in any terminal
CREATE TABLE stock (store_id int, sku text, on_hand int, reserved int,
PRIMARY KEY (store_id, sku));
INSERT INTO stock VALUES (7, 'CHIPS', 1, 0);
-- the hold, run in terminal A and then in terminal B
UPDATE stock SET reserved = reserved + 1
WHERE store_id = 7 AND sku = 'CHIPS' AND on_hand - reserved >= 1
RETURNING sku;Hold the packet in terminal A
Type BEGIN; and then run the UPDATE. You see the row CHIPS and UPDATE 1. Do not commit yet.
Try the same hold in terminal B
Run the same UPDATE without BEGIN. The terminal hangs, because terminal A still holds the row lock.
Commit in terminal A
Type COMMIT; in terminal A. Terminal B wakes up at once and prints (0 rows) and UPDATE 0.
Read the result
UPDATE 0 means the recheck found no free stock, so the second buyer gets a clean sold-out message. Tested on PostgreSQL 16.15.
What about a flash sale on one item?
One row can become a hot spot. When the app sells gulal at half price on Holi morning, a burst of checkouts lines up on the same row, one at a time.
For a few very hot SKUs, some teams keep the free count in Redis. The check-and-hold then runs as a short script in Lua, a small language Redis runs inside the server. Redis documents that a script runs atomically and blocks all other server activity meanwhile.
So scripts must stay tiny. In a test on Redis 7.0.15, 2,000 attempts on a counter of 100 units produced exactly 100 wins and a final count of 0.
Redis turns away the buyers who cannot win, so only about as many checkouts as there are units reach the database row. The database still records every order and stays the truth.
The team turns this on for planned sales only, because a second copy of stock is a second thing that can drift. If Redis restarts without saved data, the counter must be rebuilt from the database. When a hold expires, a payment fails or the database refuses the hold, the release must add the units back to the Redis counter. Otherwise Redis shows sold out while the database still has free stock.
When should a grocery app hold stock: at add to cart or at payment?
Hold stock when the customer taps Pay, not when they add to cart. Give each hold a short expiry, and turn it into a firm sale only when the payment succeeds.
| When you hold | What goes well | What goes wrong |
|---|---|---|
| At add to cart | A customer never loses an item already in the cart | Idle carts lock stock, so one person can block the last packets for hours |
| At tap on Pay, with an expiry | Stock is held only while payment runs, usually a minute or two | Now and then a hold expires while a slow payment is still going |
| Only after payment succeeds | Nothing is ever held for someone who does not pay | Two people can pay for the last packet, and one needs a refund |
The setup holds at Pay with a 5-minute expiry. A small sweeper job runs every 30 seconds. It finds expired holds with no successful payment and gives the units back.
Payments make this harder. The payment gateway may confirm late, through a webhook (a call it makes to your server when something happens). If the hold already expired, the order service tries to hold the stock again.
If that second hold fails, the customer gets an automatic refund and a clear message at once, instead of a picker discovering the problem at the bin.
Retries need care too. Phones on weak signals resend the Pay tap. Each checkout carries an idempotency key, a unique id sent with the request, so the server can recognise a resent tap.
Using the order id as that key, the holds table's primary key does the rest. In the test, a second reserve call for the same order failed on the INSERT, and the whole transaction rolled back. The service then catches that duplicate-key error and returns the first attempt's result, such as the order's holds, instead of a failure.
Follow one checkout below. Notice the two question boxes: the stock check and the payment result. Each one has a path that gives stock back.
One checkout, from Pay to pick list
Drag to exploreNotice the two question boxes: stock is held before money moves, and each failure path gives the stock back.
Diagram connections:
- Customer taps Pay to Hold stock in one update
- Hold stock in one update to Enough free stock?
- Enough free stock? to Offer a swap or remove it (No)
- Enough free stock? to Charge the customer (Yes)
- Charge the customer to Payment succeeded?
- Payment succeeded? to Send the pick list (Yes)
- Payment succeeded? to Release the hold (No or timed out)
- Customer taps Pay: Customer app → Order service
- Hold stock in one update: Order service → Inventory DB
- Enough free stock?: Inventory DB → Order service
- Offer a swap or remove it: Order service → Customer app
- Charge the customer: Order service → Payment gateway
- Payment succeeded?: Payment gateway → Order service
- Release the hold: Order service → Inventory DB
- Send the pick list: Order service → Picker app
Beginners can stop here. The rest of the guide is for engineers: the sweeper bug, the late-payment race, keeping the cache honest, restocking, fixing drift and the interview frame.
The sweeper bug that leaks stock
Here is a sweeper that deletes expired, unpaid holds and gives their units back, in one SQL statement. The back step adds up the holds for each stock row first.
WITH gone AS (
DELETE FROM holds
WHERE status = 'pending' AND expires_at < now()
RETURNING store_id, sku, qty
), back AS (
SELECT store_id, sku, sum(qty) AS qty -- one row per stock row
FROM gone GROUP BY store_id, sku
)
UPDATE stock s SET reserved = s.reserved - back.qty,
version = s.version + 1
FROM back
WHERE s.store_id = back.store_id AND s.sku = back.sku;That grouping step is not decoration. The PostgreSQL manual warns that when UPDATE ... FROM joins one target row to several rows, only one of them is used, and which one is not predictable.
In a test, two expired holds of 2 and 3 paneer packs were swept without the grouping. Both holds were deleted, but reserved dropped from 5 to 3, not 0. Three packs stayed locked with no hold behind them.
The late webhook and the sweeper can also race. The payment handler marks a hold paid with an UPDATE that requires status = 'pending', so the row lock and the recheck pick one winner.
In the test, whichever ran first won cleanly. When the sweeper went first, the payment update returned UPDATE 0, which is the signal to try a fresh hold. When the payment went first, the sweeper skipped that hold.
The sweeper takes row locks too, so it must follow the checkout's lock order. In a test, a checkout holding milk and waiting for paneer deadlocked with the sweep in 5 of 10 runs. PostgreSQL cancelled the sweep each time, but its manual says which one it cancels is hard to predict.
The fix is to lock the stock rows first, in checkout's order, inside the sweeper's transaction. Here, COLLATE "C" sorts text by character code, the same order Python's sorted() uses. With this step, the same test finished cleanly in every run.
BEGIN;
SELECT s.sku FROM stock s
WHERE (s.store_id, s.sku) IN (
SELECT store_id, sku FROM holds
WHERE status = 'pending' AND expires_at < now())
ORDER BY s.store_id, s.sku COLLATE "C" -- the order sorted() uses
FOR UPDATE;
-- then run the sweep statement above, and COMMIT;How does a quick commerce app show live stock without slowing down?
The app reads stock counts from a cache, and events from the database keep that cache up to date. The count shown can lag the database slightly, which is fine because checkout always rechecks the database.
Sending all 50 reads per order to the inventory database would load it heavily at peak. So the team keeps free stock per store in Redis, keyed by store and SKU.
The tricky part is keeping the cache in step. If the order service updates the database and then sends an event separately, a crash between the two loses the event. The cache keeps showing stock that is gone.
The transactional outbox pattern fixes this. The transaction that changes a stock row also inserts a row into an outbox table, so both commit or neither does. A separate relay program reads new outbox rows and publishes them to Kafka, a system that stores streams of events.
Kafka splits each stream into partitions, which are separate ordered logs. The default producer picks a partition from a hash of the event's key, and consumers read each partition in the order it was written.
The team keys every event by store and SKU, so all events for one paneer row land in one partition, in order. Kafka's docs warn that after adding partitions, events with the same key may land in a different partition, so per-key order is not kept across that change.
Make duplicate events harmless
Events can arrive twice. The outbox pattern's own description says the relay might publish a message more than once, for example after a crash. A Kafka consumer that crashes before saving its place re-reads some events.
So each event carries the row's new free count and its version, not a change like minus one. The cache keeps a value only if its version is newer than the one it holds.
In a Redis test, events with versions 41, 42, 42 again, a late 40 and 43 left the cache at version 43 and 24 free, the right answer. Applying the same events as plus or minus amounts counted the duplicate twice and showed 23.
Here is the path one stock change takes. Start at the picker app. Notice that, in normal running, only the event stream writes to the cache.
How one stock change spreads
Drag to exploreNotice that one commit writes the change and its event, and in normal running only the event stream writes to the cache.
Diagram connections:
- Picker app to Inventory DB (PostgreSQL) (Picked or missing)
- Inventory DB (PostgreSQL) to Outbox relay
- Outbox relay to Stock events (Kafka)
- Stock events (Kafka) to Stock cache (Redis) (Update counts)
- Stock events (Kafka) to Restock planner
- Stock events (Kafka) to Drift alerts
- Restock planner to Mother warehouse (Restock order)
- Picker app: Scans each pack as it is picked
- Inventory DB (PostgreSQL): Stock row and outbox row, one commit
- Outbox relay: Publishes new outbox rows
- Stock events (Kafka): Keyed by store and SKU, kept 7 days by default
- Stock cache (Redis): Keeps only the newest version per row
- Restock planner: Checks reorder points every 15 min
- Drift alerts: Flags bins with many missing picks
- Mother warehouse: Sends restock vans to stores
The app also softens what it shows. With plenty of stock, it shows nothing special. Below 10 units, it shows that only a few are left. For fast items, the safety buffer hides the last 2 units.
How does a dark store restock before it runs out?
Each store reorders an item when its stock position falls to a reorder point: the sales expected before a van can arrive, plus a safety margin. A bigger mother warehouse nearby, which supplies several stores, then sends the van.
Stock position means free stock plus stock already on a van. Counting the van stops the planner ordering the same packs twice. The planner checks every item every 15 minutes, using this rule.
def restock_qty(sales_per_hour, van_hours, safety_units,
on_hand, reserved, on_van, case_size):
position = on_hand - reserved + on_van # free now, plus what is coming
reorder_point = sales_per_hour * van_hours + safety_units
if position > reorder_point:
return 0 # enough until a van arrives
target = reorder_point + sales_per_hour * 6 # also cover the next 6 hours
shortfall = target - position
cases = -(-shortfall // case_size) # round up to whole cases
return int(cases * case_size)Take paneer at the South Delhi store on a weekday evening. It sells 12 packs an hour, a van takes 3 hours, and the safety margin is 10. The reorder point is 46.
The position is 30 on hand minus 4 reserved, plus nothing on a van, so 26. That is below 46, so the planner orders. The target also covers the next 6 hours, so it is 118.
The shortfall is 92 packs. Paneer comes in cases of 24, and the rounding line turns 92 into 4 whole cases. Running restock_qty(12, 3, 10, 30, 4, 0, 24) returns 96 packs.
Run it again with on_van set to 96 and it returns 0, because the van already covers the gap. The planner treats a position exactly at the reorder point as time to order.
The sales rate is the hard part. A fixed number fails on festival days, cricket nights and the first hot week of summer. The team feeds the planner a forecast per store and hour, and store managers can raise it for local events.
What goes wrong with live inventory, and how do you fix it?
Stock counts drift from the shelf because of damage, theft, mistakes and items put in the wrong bin. You cannot stop drift, so you design to catch it early and correct it fast.
- Item not found. The picker taps Not found. The system sets that bin's on_hand to zero, offers a swap or refund, flags other orders holding that SKU and asks for a recount.
- Wrong bin. A crate of curd goes into the paneer bin. Pickers scan bin labels, so these mistakes show up as Not found reports at one bin.
- Damage and expiry. Torn packs and old milk leave without an order. Pickers log them as write-offs, with a reason, so the log stays complete.
- Lost events. A consumer crashes. Kafka keeps events after they are read, for 7 days under the default broker setting, and the consumer restarts from its last saved place.
- Cache and database disagree. A nightly job compares them for every store and rebuilds the cache from the database. The rebuild writes each row's version too, so a late event cannot overwrite it with older data.
The store also runs cycle counts. Each day the team counts a few bins, so every bin gets counted at least once a week. Fast sellers get counted daily.
A count is just another event. It sets on_hand to what the eye sees, and the gap goes into the log as loss. Drift alerts flag any bin where the gap keeps growing.
How do you explain quick commerce system design in an interview?
Start from the 10-minute promise and show that it forces stock close to customers. That makes live inventory per store the heart of the design, and delivery the easier part.
Clarify the promise
Ask the delivery time, area, number of stores, items per store and peak orders. For the Delhi setup: 38 stores, about 3,000 items each, 90,000 orders a day.
Map addresses to stores
Explain map cells, one store per cell, and a live switch for open, closed or a smaller area.
Model stock per store
One row per store and SKU, with on_hand and reserved. Every change also goes to a movement log.
Hold stock safely
A conditional update at Pay, a short expiry with a sweeper, and a primary key on the holds so a resent tap cannot hold twice.
Make reads fast
A cache fed by an outbox and an event stream, with versioned values so duplicate events do no harm.
Restock and correct
Reorder points driven by forecasts, a Not found flow and daily cycle counts.
Name the trade-offs
When to hold, how stale the cache may be, how big the buffer is, and when a hot item needs an atomic script.
Do the rough maths out loud. 90,000 orders a day is about 1 order a second on average. With a 4 times evening peak, that is about 4 a second, and 50 reads each means about 200 reads a second.
Those totals are small for one database. The hard part is one row: in a flash sale, one hot item can get dozens of holds a second. Saying this shows you see that correctness, not size, is the real problem.
Questions people ask
- What is a dark store in quick commerce?
- A dark store is a small warehouse that stocks a few thousand everyday items and serves only online orders. It sits inside the neighbourhood it serves, so a rider can reach most homes within minutes. Pickers inside pack each order from numbered bins, and no walk-in customers are allowed.
- How is quick commerce different from food delivery system design?
- Quick commerce owns the stock and the store, while food delivery mostly waits on a restaurant's kitchen. So in quick commerce, live inventory per store is the core problem. In food delivery, the harder parts are restaurant prep time, rider matching and delivery-time estimates.
- Should a quick commerce app keep inventory in SQL or NoSQL?
- Either can work if it supports an atomic conditional write and a way to hold a whole basket all or none. The write must check free stock and add the hold in one step. PostgreSQL does this with an UPDATE and a WHERE clause, and one transaction covers the basket. Amazon DynamoDB does it per item with a condition expression, and the write fails with "The conditional request failed" when the check is false. For a basket, you then need a multi-item transaction or code that releases earlier holds when a later one fails.
- How long should a stock hold last?
- Long enough for a normal payment to finish, plus a margin, often a few minutes. Measure how long real payments take and set the expiry just above the slow end. Too short, and paying customers lose their items to the sweeper. Too long, and abandoned checkouts block stock that others want.
- What happens if two dark stores can reach the same address?
- A simple design gives each map cell to exactly one store at a time, so stock and promises stay simple. If that store runs out of an item, the app shows it as unavailable rather than splitting the order. Splitting means two pickers, two riders and two chances to be late.
- How does a quick commerce app handle an item that is missing during picking?
- The picker marks it Not found, and the system offers the customer a swap or a refund for that item. It also sets the bin's on_hand to zero so nobody else buys it. Finally, it asks for a recount, which fixes the count and records the loss in the movement log.
Sources and further reading
- PostgreSQL 18 Documentation: 13.2 Transaction Isolation (Read Committed recheck, Repeatable Read error)
- PostgreSQL 18 Documentation: 13.3 Explicit Locking (row locks and deadlocks)
- PostgreSQL 18 Documentation: 19.12 Lock Management (deadlock_timeout default 1s)
- PostgreSQL 18 Documentation: UPDATE (FROM joins to more than one row, RETURNING)
- Redis Documentation: Scripting with Lua (atomic execution)
- Pattern: Transactional outbox (microservices.io)
- Apache Kafka Documentation: Introduction (partitions, keys, ordering, retention)
- Apache Kafka 4.3 Documentation: Producer configs (partitioner.class)
- Apache Kafka 4.3 Documentation: Broker configs (log.retention.hours)
- Apache Kafka 4.3 Documentation: Basic operations (adding partitions)
- Apache Kafka 4.3 Documentation: Design (consumer position, at-least-once)
- H3 Documentation: Introduction to the hexagonal grid system
- H3 Documentation: Tables of cell statistics across resolutions
- Amazon DynamoDB Developer Guide: condition expression CLI example