Gopuff is a convenience store with no storefront. The company runs hundreds of small warehouses, micro-fulfillment centers stocked with a few thousand fast-moving items each, and it owns the inventory outright rather than brokering someone else's shelves, which is what lets it pick, pack, and hand an order to a driver in under ninety seconds. The version of the problem worked here strips away payments, driver routing, search, and cancellations, and keeps the two operations that make the product work. A customer asks what is available for delivery to their location within an hour, and a customer places an order for several items at once.
Those two operations pull in opposite directions, and the tension between them is the entire design. Availability is the union of inventory across every warehouse close enough to deliver in an hour, a number that has to come back in under a hundred milliseconds to power browsing, and one that is allowed to be slightly wrong. Ordering is the opposite contract. Two customers must never buy the same physical unit, so the write path gives up latency for a hard guarantee. Most of the mistakes people make in this design come from applying the guarantees of one path to the other.
quantity = quantity - n WHERE quantity >= n, makes check-and-take a single atomic step. Keep inventory and orders in the same database so the guarantee is one COMMIT, and resist the Redis locks, sagas, and shards that the numbers here never ask for.Scope and requirements
Functionally the system does two things. It answers availability queries by location, where the effective quantity of an item is the sum of that item's stock across every distribution center able to deliver to the customer within an hour, and it accepts orders containing multiple items at once. Payments, driver routing and dispatch, the product catalog and search, and cancellations and returns all sit below the line. That scoping is worth stating up front, because the moment you let search in, the design grows an Elasticsearch cluster and a sync pipeline that eat attention you need elsewhere.
Non-functionally, the two paths carry different contracts. Availability must return in under one hundred milliseconds, since it backs every page render and every search result a shopper scrolls past. Ordering must be strongly consistent, meaning two customers can never purchase the same physical unit, and a customer is never promised stock that was already sold. The system supports ten thousand distribution centers and one hundred thousand catalog items, with order volume on the order of ten million a day. The asymmetry is deliberate and it is the whole game. The read path chooses latency and tolerates staleness, the write path chooses consistency and tolerates a slower confirmation, and the design keeps a clean seam between them.
Sizing the problem
Ten million orders spread over the roughly one hundred thousand seconds in a day is about a hundred order transactions a second on average, maybe four hundred at the evening peak. Each transaction touches a handful of inventory rows and inserts an order with its lines. That is an ordinary write load for a single well-provisioned Postgres leader, which sustains a thousand or more short transactions a second with synchronous replication on unremarkable hardware. Engineers who have only run demo databases on tiny instances consistently overestimate this number's difficulty, and the overestimate is what sends them toward sharded inventory and distributed transactions they do not need.
Reads are where the load actually lives. A shopper looks at something like ten pages across the home screen and search before deciding, and only about one session in twenty ends in a purchase. Backing out from orders, a hundred orders a second times ten page views divided by a five percent conversion rate is twenty thousand availability queries a second, two hundred times the write rate. Every one of those queries wants the summed stock of dozens of items across several distribution centers, inside the hundred-millisecond budget.
Storage stays small. The worst case for the inventory table is ten thousand centers times one hundred thousand catalog items, a billion rows, but a real micro-fulfillment center stocks closer to three thousand items, so tens of millions of rows is the honest estimate. That fits in memory on a single replica. The conclusion that shapes everything downstream is that the write path is a correctness problem and the read path is the scaling problem, which is the reverse of how most candidates budget their time.
The API
Two endpoints cover the requirements, and both take the customer's location, because availability is a function of where you are standing and an order has to be validated against the same one-hour constraint that produced the numbers the customer saw. Identity comes from the session token rather than the request body, for the usual reason that anything a client can send a client can forge. The availability response is paginated so a busy region does not push thousands of items at a phone.
The read is advisory and says so. quantityAvailable is a recent sum, not a hold, and nothing is reserved by looking. The write is where truth is enforced, and when it fails it fails usefully. A 409 names the lines that could not be filled so the client can offer a substitution or drop the line and retry, rather than making the customer guess which of twenty items killed the order. POST /v1/orders also carries an idempotency key, so a client that times out and retries lands on its original order instead of creating a second one.
GET /v1/availability?lat=39.95&long=-75.16&page=1
-> 200 { items: [ { itemId, name, quantityAvailable } ], nextPage }
POST /v1/orders (Idempotency-Key: <client-generated>)
{ "lat": 39.95, "long": -75.16,
"items": [ { "itemId": "cheetos_xl", "quantity": 2 },
{ "itemId": "paper_towels", "quantity": 1 } ] }
-> 200 { orderId, lines: [ { itemId, quantity, dcId } ] }
-> 409 { outOfStock: [ "paper_towels" ] }Core entities and the data model
The distinction that unlocks the data model is Item versus Inventory, the same split as a class versus an instance. An Item is a catalog concept, Cheetos with a name and a description, and it is all the customer ever thinks about. Inventory is physical stock of an item at a specific distribution center, and it is all the warehouse ever thinks about. A DistributionCenter is ten thousand rows of id, latitude, and longitude. An Order records who bought, and its OrderItem lines record which item, how many, and from which center, because the center that fulfills a line is a fact the picker and the driver both need.
Inventory is a counter, one row per item per center holding (item_id, dc_id, quantity) with a database check constraint that quantity >= 0. The alternative, a row per physical unit with a status flag, means a million packs of gum become a million rows that exist only to be flipped from available to ordered. Per-unit rows earn their keep when every unit is unique, a specific concert seat or a specific room on a specific night, which is why the hotel reservation design tracks individual resources and this one deliberately does not. Convenience stock is fungible, and fungible stock wants arithmetic, not identity. The check constraint is the last line of defense, because whatever concurrency bugs slip through the application, Postgres will refuse to drive a count below zero.
All of it, items, inventory, orders, and order lines, lives in one Postgres database on purpose. A larger e-commerce platform would split the catalog into its own service with a search index in front, and would eventually split inventory from orders along team boundaries. Here the colocation is the design decision that makes the ordering guarantee cheap, because checking stock, decrementing it, and recording the order become one local ACID transaction instead of a distributed protocol. The cost of splitting is the subject of the ordering deep dive below.
The pieces, and how they fit
Three services hang off the gateway. The Availability Service owns the read path. The Orders Service owns the write path. Between them sits a shared Nearby Service whose only job is answering which distribution centers can deliver to a given latitude and longitude within an hour. One Postgres database backs everything, with the Orders Service writing to the leader and the Availability Service reading from replicas, so twenty thousand reads a second never contend with the transactions that move money and stock.
An availability request walks through in two hops. The client sends its coordinates, the Availability Service asks the Nearby Service for deliverable centers and gets back a short list of DC ids, then runs one query against a replica that joins inventory to items for those ids, sums quantity per item, and returns the item list with counts. Both hops have to be fast, which is why the nearby lookup ends up in memory and the inventory lookup ends up behind a cache, and each gets its own deep dive.
An order re-runs the same nearby resolution rather than trusting the earlier one, because traffic and stock both change between browsing and checkout. The Orders Service resolves which centers can serve the address, chooses which center fulfills each line, preferring a single center so one driver makes one stop, and then submits a single transaction to the leader that decrements inventory and records the order together. If any line cannot be covered, the transaction rolls back and the customer gets the 409 naming the lines that failed.
Both routes resolve deliverable centers through the same Nearby Service. The Availability Service sums inventory on read replicas, and the Orders Service decrements inventory and records the order in one transaction on the leader.
An hour away is drive time, not distance
A radius check on latitude and longitude is the natural first cut and a wrong final answer. A center six miles away across a river with one bridge can be forty minutes out, a center across a state line can be close in miles and unservable in practice, and the area reachable in an hour at three in the afternoon is not the area reachable at five. Haversine distance, the great-circle math that corrects straight-line distance for the curvature of the earth, is a fine primitive for pruning and nothing more, because the functional requirement is a drive-time fact and drive time is a function of roads and traffic.
The shape of the working answer follows from one observation, ten thousand centers that almost never change is a tiny dataset. The Nearby Service syncs the whole DC table into process memory every five minutes, warehouses come online with days of notice so even that is generous, and filters candidates with haversine against a sixty-mile ceiling, set comfortably past what an hour of real delivery driving covers once picking and city traffic take their cut. That scan over ten thousand in-memory rows costs well under a millisecond. Only the handful of survivors go to an external travel-time service that knows the road network and live traffic. Flipping the direction of the question works too, isochrone APIs like Mapbox's or Valhalla's return the polygon reachable from a point within a time budget, so a worker can refresh each center's one-hour polygon every ten minutes and the runtime check becomes point-in-polygon with no per-request API call at all.
The last piece is sharing answers between neighbors. Two customers on the same block have the same deliverable set, so the service caches the resolved DC list keyed by a coarse geohash cell, five or six characters, with a TTL around ten minutes, long enough to amortize the travel-time calls and short enough to track rush hour as it builds. Geohash has a known sharp edge, two points a street apart can land in cells that share no prefix, exactly the way 1999 and 2000 are adjacent numbers with no common digits, so the lookup checks the neighboring cells too and accepts that boundary customers cost a few extra cache reads.
Serving twenty thousand availability reads a second
Pointing twenty thousand queries a second at the inventory table is not an instant catastrophe, a read replica with the working set in memory serves tens of thousands of simple indexed reads, but it is fragile, expensive, and it wastes the structure of the workload. Availability answers are highly repetitive, everyone in a neighborhood asks about the same centers and mostly the same popular items, and the answers tolerate a minute of staleness because the read is advisory. Repetitive plus staleness-tolerant is the exact profile caching exists for.
The cache is Redis, keyed by the resolved DC set and the item or page being viewed, holding the summed counts with a TTL of about one minute. A request checks the cache, and on a miss queries a replica and fills the entry. The one refinement that matters is expiring affected keys when an order commits, so the count corrects within milliseconds of a sale instead of at TTL expiry, while the TTL remains the backstop for missed invalidations and replica lag. This is the same fight DoorDash describes in its inventory platform, which absorbs an enormous volume of per-store item updates and still singles out price and availability as the attributes too time-sensitive to serve stale.
Under the cache, the table layout leans on the query's locality. An availability query only ever touches a few nearby centers, so assigning each center a region id, in practice a prefix of its zip code, and partitioning the inventory table by region means a query lands on one or two partitions instead of scanning the world. Keep that as partitioning inside one Postgres instance, not sharding across separate clusters. Sharding inventory across databases by region quietly destroys the ordering guarantee, because a customer near a region border can be served by centers in two shards, and the single-transaction order path becomes a distributed transaction. Replicas scale reads without touching write semantics, shards do not, and knowing which lever you are pulling is most of the judgment in this section.
Reads land on a one-minute cache keyed by the resolved DC set and item, and misses fall through to region-partitioned replicas. The Nearby Service prunes its in-memory DC list by radius before the travel-time call, and every committed order expires the cache keys it touched.
One order, and nobody oversold
Double booking is the classic shape here, the same one as seats and hotel rooms. Checking stock, recording the order, and decrementing inventory must happen as one unit, or two customers pass the check together and both walk away owning the last bag of ice. Because inventory and orders share a database, the unit is a single transaction on the leader, and the core move inside it is the conditional decrement. UPDATE ... SET quantity = quantity - n WHERE quantity >= n collapses check-and-take into one statement, and the database's own row lock holds from the update to the commit, so a concurrent order for the same row waits, re-evaluates the predicate against the new quantity, and cleanly fails if the stock is gone. Zero rows updated means insufficient stock, and the service rolls back, or first retries the line against another deliverable center that showed stock.
Isolation level is the natural follow-up. SERIALIZABLE works and is easy to defend, Postgres detects conflicting concurrent histories and aborts one, but under contention on a popular item it aborts freely and pushes retry loops into the application. The conditional update needs less. Under plain READ COMMITTED, a blocked update re-checks its WHERE clause on the committed row after the lock releases, which is exactly the semantics overselling prevention needs, at the cheapest isolation Postgres offers. The one discipline the application must add is updating rows in a fixed key order, sorted by item and center id, because two multi-item orders locking the same rows in opposite orders is the textbook deadlock.
Now the alternatives, because they are where the signal is. A Redis lock with a TTL is the reflex from ticket-selling designs, and it is wrong here twice over. The lock can expire mid-transaction and readmit the race it exists to prevent, and it creates a second source of truth with no durability in front of the database that already provides real locks with real durability. Reservation holds in general belong to scarce unique resources where losing the race is unacceptable, and fungible snack counters are not that. Splitting inventory and orders into separate services with separate databases is the microservice reflex, and it forces a saga, reserve stock, insert the order, compensate on failure, plus an outbox, plus a sweeper for orders stranded mid-flight by a crashed coordinator. That machinery is a legitimate answer at Amazon's scale or across organizational boundaries, and it is pure overhead at a hundred writes a second. Queueing orders through Kafka makes placement asynchronous, but a quick-commerce customer stares at the screen waiting for a yes, so you end up simulating a synchronous answer with polling on top of a queue that solved no problem the leader was not already absorbing.
BEGIN;
-- one conditional decrement per line, rows touched in sorted key order
UPDATE inventory SET quantity = quantity - 2
WHERE item_id = 'cheetos_xl' AND dc_id = 'dc_phl_04' AND quantity >= 2;
-- rowcount 0 => not enough stock here: retry another deliverable DC or ROLLBACK
UPDATE inventory SET quantity = quantity - 1
WHERE item_id = 'paper_towels' AND dc_id = 'dc_phl_04' AND quantity >= 1;
INSERT INTO orders (order_id, user_id, address, status)
VALUES ('o_18242', 'u_5521', '...', 'placed');
INSERT INTO order_items (order_id, item_id, dc_id, quantity)
VALUES ('o_18242', 'cheetos_xl', 'dc_phl_04', 2),
('o_18242', 'paper_towels', 'dc_phl_04', 1);
COMMIT; -- decrement and order record become visible together, or not at allquantity >= 0 check constraint as the backstop. Off the menu at this scale: Redis TTL locks, sagas across split databases, and async placement through a queue, each of which imports a harder problem to avoid an easy one.The reservation reflex, and other corrected intuitions
This problem looks like hotel reservations wearing a delivery jacket, and the resemblance misleads. Reservation systems protect scarce, unique resources, one seat, one room on one night, where losing a concurrency race means a customer loses the specific thing they chose, so holds, TTLs, and reservation tables earn their complexity. Convenience inventory is deep and interchangeable. The chance that the last identical bag of chips disappears between cart and checkout is small, the harm when it happens is a substitution prompt, and so this design takes no hold when an item enters a cart, and spends its consistency budget entirely at the moment of purchase.
The all-or-nothing order is the second thing to hold loosely. The stated requirement makes a twenty-item order fail because one item sold out, and this design builds exactly that, atomic and honest. In production, the funnel data would kill it within a quarter, because refusing an order over one line costs far more than the line, which is why real grocery and quick-commerce apps confirm the order and resolve misses with per-line substitutions or refunds at pick time. Saying both, here is the atomic version you asked for, and here is why the business would soften it, is the senior move, not a dodge.
The last correction is about what strong consistency can even mean when shelves are involved. Stock gets dropped, miscounted, damaged, and stolen, so the database's count drifts from physical truth no matter how perfectly transactions serialize. The transaction prevents concurrent overselling, the picker and the substitution flow absorb physical drift, and no amount of locking moves that boundary. Once you see that the system was never end-to-end exact, letting the availability read be a minute stale stops feeling like a compromise and starts being the same judgment applied consistently, precision exactly where it pays, tolerance everywhere else.
- The availability number is advisory. Nothing is reserved by browsing, and only the order transaction enforces truth.
- Ten million orders a day is about a hundred writes a second. It does not need Kafka, sagas, or sharded inventory, and reaching for them is the most common way to overbuild this design.
- Deliverable-within-an-hour is drive time with traffic, not a radius. The radius is the in-memory prefilter that keeps the travel-time bill sane.
- Region-partitioning inside one database scales reads. Region-sharding across databases breaks the single-transaction order path for border customers.
- A Redis TTL lock in front of Postgres adds a failure mode, not a guarantee. The row lock you already paid for is the lock.
- Per-unit inventory rows model uniqueness you do not have. Counters plus a non-negative check constraint model fungible stock exactly.
Questions and answers
The core ideas as questions with the answers given outright. Each wrong multiple-choice option is marked with why it is wrong, and the ordering ones show the correct sequence.
- ✓The sum of Cheetos inventory across all four deliverable centers, served from a cache or replica and possibly up to a minute stale
- ✗The stock at the single nearest center, since one driver fulfills the order from one place. Restricting to the nearest center hides stock the customer can legitimately buy, and fulfillment choice happens at order time, not at browse time.
- ✗The stock at the largest center in range, as the best proxy for what is orderable. Center size is irrelevant to the requirement, and picking any single center understates the union the product promises.
- ✗A strongly consistent read of the Postgres leader at request time, since customers must never see a wrong count. Sending twenty thousand reads a second to the leader defeats the replicas and the cache, and the design deliberately does not offer consistency on the read path.
- ✗True. The two paths carry different contracts, and applying the write path's guarantee to the read path is exactly the intuition this design corrects.
- ✓False
- ✓The check, the decrement, and the order insert commit or fail together in one durable ACID transaction, with no lock-expiry window and no second source of truth
- ✗Redis is too slow to hold locks at this request rate. Redis is faster than Postgres at lock operations, speed was never the objection. The objections are expiry semantics and durability.
- ✗Redis cannot represent inventory counts, only string keys. Redis holds counters fine, and that is beside the point, because the problem is making it the authority over stock it does not durably own.
- ✗SERIALIZABLE isolation prevents deadlocks and Redis offers no isolation levels. No isolation level prevents deadlocks, fixed lock ordering does, and the isolation comparison was never the reason to avoid Redis.
- The Nearby Service resolves which distribution centers can deliver to the customer's address within an hour, using fresh traffic data
- The Orders Service assigns each order line to a deliverable center, preferring a single center for the whole order
- Inside one transaction on the leader, each line runs a conditional decrement, quantity minus n where quantity is at least n, rows touched in sorted key order
- The order and its order-item rows are inserted in the same transaction, recording which center fulfills each line
- The transaction commits and affected cache keys are expired, and the customer gets a synchronous confirmation, or the rollback path returns a 409 naming the failed lines
- ✓About a hundred transactions a second on average, an ordinary load for one well-provisioned Postgres leader, so the real scaling problem is the read side at twenty thousand queries a second
- ✗The write volume requires a Kafka buffer so the database can absorb orders asynchronously. A queue adds latency to a path customers wait on synchronously, and it buffers a load the leader was never struggling with.
- ✗Inventory must be sharded across clusters by item id to spread the write load. Sharding by item breaks multi-item order transactions across shards, and the write rate never justified sharding anything.
- ✗A relational database cannot sustain this write rate, so inventory belongs in a NoSQL store. The write rate is trivial for Postgres, and abandoning ACID here means rebuilding, by hand, the exact transactional guarantee the requirement demands.
- ✓It prunes ten thousand centers to a handful in under a millisecond, so the expensive per-candidate travel-time estimate only runs on plausible survivors
- ✗The radius is the actual deliverability answer, and the travel-time call is a refinement for display purposes. Backwards. Drive time is the requirement and the radius is only the coarse screen, a center twenty miles away across a bridge can still fail the hour.
- ✗Travel-time APIs require a bounding radius parameter to function. Isochrone and matrix APIs take origins and destinations, no radius required. The radius serves the caller's budget, not the API's contract.
- ✗Sixty miles guarantees one-hour delivery regardless of traffic conditions. It guarantees nothing of the sort, sixty miles in rush-hour traffic is far beyond an hour, which is exactly why the travel-time check follows.
References
- Gopuff, A Peek Behind the Curtain: Gopuff's Unique Business Model, First-party description of the vertically integrated model, owned inventory across hundreds of micro-fulfillment centers and orders picked, packed, and sent out in under ninety seconds.
- DoorDash Engineering, a write-heavy scalable and reliable inventory platform, Production account of per-store inventory for convenience and grocery, naming price and availability as the most time-sensitive attributes the pipeline has to keep fresh.
- PostgreSQL documentation, Transaction Isolation, The exact semantics of READ COMMITTED re-checking an UPDATE's WHERE clause after a lock wait, which is what makes the conditional decrement sufficient.
- PostgreSQL documentation, Explicit Locking, Row-level lock modes and deadlock behavior behind the fixed-key-order discipline for multi-item order transactions.
- Mapbox Isochrone API, Drive-time polygons from a point within a time budget, contours up to sixty minutes, the primitive for precomputing each center's one-hour coverage instead of estimating per request.