Skip to main content
PProfileMatch

EasyProblem 4 of 36 in the system design series

Design a Local Delivery Service: Quick Commerce Without Overselling

Prefer watching? This article is the written companion to the video above, from the Shubham Aher YouTube channel.

Quick-commerce apps promise groceries and household basics at the door in around ten to twenty minutes. They pull this off by stocking a few thousand popular products in small neighbourhood warehouses, often called dark stores, and sending a rider from the closest one. The customer experience is simple: browse, tap order, watch a dot move across a map.

The interview version is popular because it combines three distinct problems in one product. The catalogue a customer sees depends on where they are standing. Inventory is genuinely scarce, so two people competing for the last carton of eggs must not both win. And once an order ships, the customer expects a live view of the rider, which means a continuous stream of location updates.

Each problem pulls the design in a different direction. Browsing wants speed and tolerates slight staleness; checkout demands strict correctness; tracking wants high-frequency, low-value writes. A strong answer recognises those differences and gives each path the consistency level it actually needs, rather than applying one rule everywhere.

This is an interview practice design for a Local Delivery Service-like service, built from first principles. It is not a description of how Local Delivery Service is actually built.

Functional Requirements

Customers browse products, but the list is limited to products that a nearby warehouse both holds and can deliver within the promised window. A product sitting on a shelf across the city is irrelevant, so location is part of every catalogue query. Customers then place an order containing several products and pay for it. The whole cart is accepted together or rejected together; a half-confirmed basket would be confusing and hard to fix.

After ordering, the customer tracks progress from packing through to the rider's live position on a map. Rider earnings, discount campaigns and warehouse replenishment are real parts of the business but are excluded here so the discussion stays on availability, reservation and tracking.

Browse nearby stock

See only items in stock at warehouses that can deliver to you.

by location

Place an order

Order several items and pay; the whole cart is confirmed or none.

all or nothing

Track the delivery

Order status plus the rider's live location.

live map

  • Out of scope today: rider payouts, promotions, restocking

Non-Functional Requirements

Availability lookups need to be very fast, assume under 100 ms, because stock information appears on the home screen, category pages, search results and the cart. If that check is slow, every screen is slow. Orders, on the other hand, demand strong consistency: the system must never confirm a product that is not physically on the shelf, because a missing item becomes a refund, an angry customer and a wasted rider trip.

Demand is spiky. Evening hours and festival sales can bring roughly five times normal traffic, so the design must scale out without manual intervention. Tracking must feel live, which in practice means the rider's position refreshes about every five seconds. Faster is possible but quickly becomes expensive in battery and server load for little visible benefit.

Fast availability

Every page shows stock, so the check must be instant.

< 100 ms

No overselling

Never confirm an item that isn't on the shelf.

strong consistency

Handle peaks

Evening rush and festival sales bring big spikes.

5× normal traffic

Fresh tracking

Rider location updates every few seconds.

every 5 s

Scale Estimation

Assume about 1,000 dark stores, each carrying roughly 5,000 products, which gives about 5 million inventory rows. That is a small dataset. Assume 2 million orders per day, which averages only about 23 per second and perhaps 120 per second at peak. Order writes are therefore modest.

Availability reads are a different story. Because nearly every screen shows stock, assume around 100 million availability reads per day: roughly 1.2K per second on average and near 6K per second at peak. Add a fleet of 20,000 riders each reporting GPS every five seconds, and location updates reach about 4K per second. The lesson is that reads dwarf orders, so availability should come from a fast cache, and the system should apply strict checks only at the moment of checkout.

1000stores

Warehouses

5K items each → 5M rows

2million

Orders per day

≈ 23 / sec · ≈ 120 at peak

1.2K / sec

Availability reads

100M / day · ≈ 6K at peak

4K / sec

Rider location pings

20K riders · every 5 s

Reads ≫ orders → cache availability, be strict only at checkout

Core Entities & API

Four entities describe the domain: an item in the catalogue, a warehouse, the inventory that records how many units of a given item a particular warehouse holds, and the order. Modelling inventory as its own entity, keyed by warehouse and item, is what allows per-store stock to be cached and updated independently.

Browsing uses GET /items with the customer's lat, lng and an optional query q, returning only items in stock nearby. Checkout is POST /orders with a list of item IDs and quantities, a delivery address and payment details, returning an order_id, a status and an estimated arrival time. Tracking uses a WebSocket at /orders/{id}/track that streams status transitions and rider coordinates, since polling for a position that changes every few seconds would waste requests.

Core entities

ItemWarehouseInventoryOrder

API

  • GET /items?lat&lng&q→ { items[] in stock nearby }
  • POST /orders { items[{id, qty}], address, payment }→ { order_id, status, eta }
  • WS /orders/{id}/track→ status + rider location stream

High-Level Design

The customer app connects through an API gateway for authentication and routing. Browse requests go to an availability service, which works out which warehouses serve the customer's location and reads per-store stock counts from a Redis cache. Checkout requests go to an order service, which reserves inventory inside a single Postgres transaction, since Postgres holds the authoritative stock counts.

Every committed stock change also flows back into the Redis cache so browsing stays close to reality. Confirmed orders are published to Kafka as events. A delivery service consumes those events, assigns a nearby available rider, and receives that rider's GPS position every five seconds from the rider app. It then forwards the order status and the rider's location to the customer over a WebSocket.

Using an event stream between ordering and delivery means a slow dispatch step never blocks checkout, and other consumers such as notifications or analytics can consume the same events without any change to the order service, which keeps that critical path small.

nearby stockreserve stockstock changesorder placedGPSlive locationCustomer appbrowse · orderAPI gatewayauth · routingAvailability svczones · nearby stockStock cacheRedis · per storeOrder servicereserve · payInventory DBPostgres · truthOrder eventsKafkaDelivery servicedispatch · trackingRider appGPS every 5 s
High-level design. Dashed arrows are taken only on a cache miss or as a fallback. Scroll sideways on small screens.

Deep Dive 1 — Nearby Availability

The first step happens offline: each warehouse is assigned a delivery zone, a polygon covering the area a rider can reach in roughly fifteen minutes using real road travel times rather than straight-line distance. A river or a highway with few crossings can make a nearby address unreachable in time, and the polygon captures that.

At request time, the customer's address is converted to a geohash cell, and a precomputed table maps each cell to the warehouses whose zones cover it. Finding candidate stores is therefore a single key lookup instead of a costly point-in-polygon query. For each candidate store, Redis holds a map of item to available quantity, and the service shows only items with stock above zero. When more than one warehouse covers the customer, it prefers the nearest store able to fulfil the entire cart, so the order ships from one place.

Freshness comes from streaming every reservation and restock into the cache within a second or two. The cache may occasionally be slightly behind, and that is acceptable here because checkout performs the authoritative check against the database. Fast and approximate for browsing, exact at payment time.

  1. 1Precompute zonesEach store gets a polygon it can reach in 15 min
  2. 2Location → storesGeohash cell → list of serving warehouses
  3. 3Read stock from cacheRedis: item → available quantity, per store
  4. 4Filter and chooseShow in-stock items; prefer the closest store
  5. 5Keep it freshEvery stock change streams into the cache
  • Cache = fast and slightly stale · database = final truth at checkout

Deep Dive 2 — Reserving Stock

The naive checkout reads the current quantity, confirms it is sufficient, then writes the reduced value. Between the read and the write, a second customer can read the same quantity, and both orders succeed for one remaining unit. Under evening load that race is not hypothetical; it happens regularly.

A distributed lock per item, for instance in Redis, closes the race but adds another system to operate, and a lock that expires mid-checkout can still let two buyers through. The chosen approach lets the database enforce the rule atomically with a conditional statement such as UPDATE inventory SET qty = qty - n WHERE item_id = ? AND store_id = ? AND qty >= n. If zero rows change, that item is unavailable. Running one such statement per cart line inside a single transaction means the whole basket is reserved or none of it is.

Reserved units are held while payment completes, with a timeout of around ten minutes; if payment fails or expires, a background job returns them to available stock. Every checkout request also carries an idempotency key, so when a flaky mobile network causes the app to resend, the server recognises the duplicate and returns the original order instead of creating a second one.

Read, then write

  • + Simple code
  • − Two buyers, one last item
  • − Oversells under load

Lock per item

  • + Works across services
  • − Extra moving part
  • − Lock expiry edge cases

Conditional UPDATEChosen

  • + Atomic in the database
  • + Whole cart in one txn
  • − Hot items wait on a row
  • UPDATE … SET qty = qty − n WHERE qty ≥ n → 0 rows = out of stock
  • Hold for 10 min while paying → released if payment fails
  • Idempotency key → a retried checkout never orders twice

Deep Dive 3 — Live Tracking

Each rider app reports its coordinates every five seconds, around 4K writes per second across the fleet. These updates are frequent and individually low-value, so they are not written to the main relational database. Instead Redis keeps just the most recent position under one key per rider, and Redis GEO commands let the dispatcher quickly locate idle riders close to a warehouse when a new order arrives.

To reach the customer, the WebSocket server handling that customer's connection listens on a pub/sub channel for their order and relays each new position as it arrives. Order progress is modelled as a small state machine moving from placed to packed to picked up to delivered, and every transition is published as a Kafka event so push notifications and support tools stay in sync. A background process also archives the full location trail to inexpensive storage, where it helps resolve delivery disputes and train arrival-time prediction models.

  • Rider app sends GPS every 5 seconds

    About four thousand updates per second across the fleet.

  • Keep only the latest location in Redis

    One key per rider; Redis GEO finds free riders near a warehouse.

  • Push to the customer over WebSocket

    The server subscribes to the order's channel and forwards each update.

  • Order status is a state machine

    Placed → packed → picked up → delivered; each change is a Kafka event.

  • Store the trail in the background

    Cheap storage, used for disputes and arrival-time models.

Trade-offs

Serving browse pages from cached stock keeps the app fast, but now and then a product disappears at checkout because the cache lagged; the app can then offer a substitute. Relying on a conditional database update rather than a Redis lock is simpler and correct, though a single very popular item forces concurrent buyers to queue on one row.

The reservation timeout is a tuning knob: a longer hold reduces payment failures caused by slow banks, while a shorter hold puts unpaid stock back on the shelf sooner. The tracking interval is another: five seconds looks smooth on a map, and anything faster spends rider battery and server capacity for little gain. The unifying principle is to give browsing fast, slightly stale reads and to keep checkout strict and atomic.

  • Cached vs live stock

    Fast browsing — but an item can occasionally vanish at checkout.

  • Database update vs Redis lock

    Simple and correct — but a very hot item makes buyers wait on one row.

  • Reservation timeout

    Long: fewer failed payments. Short: stock returns to the shelf sooner.

  • Location update interval

    Every 5 s looks smooth — faster pings cost battery and servers.

Fast, slightly stale reads. Strict, atomic writes at checkout.

Key takeaways

  • Availability reads vastly outnumber orders, so serve browsing from a per-store Redis cache.
  • Precompute delivery zones and map geohash cells to warehouses for one-lookup store selection.
  • A conditional UPDATE inside one transaction reserves a whole cart without overselling.
  • Time-limited reservations and idempotency keys handle failed payments and app retries.
  • Keep only each rider's latest position in Redis and push updates over a WebSocket.
  • Be fast and slightly stale when browsing, strict and atomic at checkout.

Interview questions

How do quick-commerce apps prevent overselling inventory?

A common design reserves stock with a conditional database update that subtracts the ordered quantity only when enough units remain. If no row is updated, the item is sold out. Running these updates for every cart line in one transaction makes the reservation all or nothing, and the database's row-level locking prevents two buyers from claiming the same last unit.

How does a delivery app decide which warehouse serves a customer?

Each warehouse is given a delivery zone polygon based on road travel time, often around fifteen minutes. These zones are precomputed into a lookup from geohash cell to the warehouses that cover it. At request time the address becomes a geohash, a single key lookup returns candidate stores, and the system prefers the closest one that can fulfil the full cart.

What happens to reserved stock if payment fails?

The reservation is time-limited, typically around ten minutes. If the payment provider reports a failure, or the window passes without a successful payment, a background job releases the held units back to available inventory. The cache is updated through the normal change stream, so other customers can see and buy those items again within a second or two.

How do you build live rider tracking for a delivery app?

The rider app sends GPS coordinates every few seconds. The backend stores only the latest position per rider in Redis, which also supports geo queries for dispatch. The customer's WebSocket server listens on that order's pub/sub channel and forwards each update immediately. The full trail is archived asynchronously to cheap storage for disputes and arrival-time modelling.

Why use an idempotency key when placing an order?

Mobile networks drop responses, so an app may resend a checkout request it believes failed. With an idempotency key attached, the server records the first result under that key and returns the same order for any repeat. Without it, a retry could reserve stock and charge the customer twice, creating duplicate orders that support teams must clean up.

Keep practising