Design Amazon Locker
Remix this answer and make it yours, or start blank. Either way you work beside the reference, one chapter at a time, and it stays private until you publish it.
Problem statement
Let customers pick a locker location at checkout, assign a compartment that fits the package, and give them a code to collect it within a few days.
Requirements
We are building the locker service behind a parcel-locker network: at checkout a customer picks a nearby locker location, the system holds a compartment big enough for the package, the carrier deposits it, and the customer gets a one-time code to collect it within 3 days.
Functional requirements
- Search locker locations near an address or point, showing only those that can take this order's package size on the expected delivery date.
- Reserve a compartment of the right size class (S, M, L, XL) at checkout; confirm or release it when the order commits or is abandoned.
- Driver deposit: the driver scans the package at the kiosk, a door opens, and the deposit is recorded.
- Customer pickup: a 6-digit code (or app QR) opens the right door; the pickup is recorded and the compartment freed.
- Expiry: uncollected packages after 3 days are flagged for return; the driver pulls them on the next visit and the compartment is reclaimed.
- Notifications: "ready for pickup" with code, a reminder at 48 h, and a final notice before return.
- Operators can mark compartments out of service (jammed door, broken lock).
Non-functional requirements
- Never assign one compartment to two packages. Overbooking is worse than showing a location as full.
- Checkout search under 200 ms p99; reservation under 500 ms p99.
- Kiosks keep working offline for pickup and deposit for hours; the backend is the source of truth once they reconnect.
- Availability shown at checkout can be a little stale (seconds to a minute) as long as the reservation itself is strongly consistent.
- Pickup codes must resist guessing and must not be reusable.
Back-of-the-envelope estimation
- 50,000 locations × ~60 compartments = 3M compartments.
- 1.5M locker deliveries/day normal, 5M/day on peak days. Each compartment turns over about once every 1–2 days.
- Reservations: 5M/day ≈ 60/s average, ~600/s peak at checkout surges. Searches run 10–20× higher: ~10k/s peak.
- Kiosk events (deposit, pickup, door state, heartbeat every 60 s): 50k heartbeats/min ≈ 850/s, plus ~150/s door events at peak.
- Compartment state: 3M rows × ~200 B = 600 MB. Reservations kept 90 days: 450M rows × 300 B ≈ 135 GB. Small; one sharded relational cluster is enough.
API design
Checkout-facing:
-
GET /v1/locker-locations?lat=..&lng=..&radius_km=5&size=M&date=2026-10-15→[{location_id, name, distance_m, hours, available: true, free_by_size: {"M": 7}}] -
POST /v1/reservations{order_id, location_id, size_class, expected_delivery_date, idempotency_key}→201 {reservation_id, compartment_size, hold_expires_at}or409 {code: "no_capacity", alternatives: [...]} -
POST /v1/reservations/{id}/confirm(order placed) andDELETE /v1/reservations/{id}(cart abandoned or order cancelled).
Kiosk-facing (mutual TLS, per-kiosk certificate):
-
POST /v1/kiosks/{kiosk_id}/deposits{package_barcode, driver_id}→{compartment_id, door: 14}— the backend picks the exact door at deposit time. -
POST /v1/kiosks/{kiosk_id}/pickups{code}→{compartment_id, door}or403 {code: "invalid_code", attempts_left: 2} -
POST /v1/kiosks/{kiosk_id}/eventsbatch of{event_id, type: door_opened|door_closed|jam|heartbeat, compartment_id, ts}; idempotent onevent_id.
Async events (Kafka): reservation.created|released|expired, package.deposited, package.picked_up, package.return_flagged, compartment.out_of_service. Notifications, order tracking and the availability cache all consume these.
Data model
Relational store (Postgres or Aurora MySQL), sharded by location_id. All writes for one locker hit one shard, so reservation is a single-shard transaction.
| Table | Key columns | Notes |
|---|---|---|
locations |
location_id PK, lat, lng, geohash6, hours, status
|
Index on geohash6 for nearby search |
compartments |
compartment_id PK, location_id, size_class, door_no, state (free, held, occupied, out_of_service) |
Index (location_id, size_class, state)
|
size_capacity |
(location_id, size_class, date) PK, total, committed
|
The counter reservations draw against |
reservations |
reservation_id PK, order_id unique, location_id, size_class, status, compartment_id null until deposit, hold_expires_at, pickup_deadline
|
Index (status, pickup_deadline) for the expiry sweep |
pickup_codes |
reservation_id PK, code_hash, attempts, used_at
|
Hash only, never the plain code |
kiosk_events |
(kiosk_id, event_id) PK |
Dedupe of kiosk retries |
Read side: Redis hash avail:{location_id} → {S: 4, M: 7, L: 1, XL: 0, updated_at}, plus a geo index (GEOADD lockers) for radius search.
High-level design
flowchart LR
CO[Checkout] --> LS[Locker search]
LS --> RC[(Redis geo and availability)]
CO --> RS[Reservation service]
RS --> DB[(Locker DB sharded by location)]
K[Kiosk] --> KG[Kiosk gateway]
KG --> RS
RS --> Q[Kafka events]
Q --> AV[Availability updater]
AV --> RC
Q --> NS[Notification service]
Q --> EX[Expiry worker]
EX --> DB
-
Find and reserve. Checkout calls search with the package's size class. Search does a geo-radius query in Redis and filters locations whose cached count for that size and date is above zero. When the customer picks one, the reservation service decrements
size_capacity.committedin one transaction and returns a hold good for 30 minutes; order confirmation turns it into a firm reservation. -
Deposit. The driver scans the barcode at the kiosk. The backend maps barcode to reservation, picks a free compartment of that size (or larger) at that location, sets it
occupied, and returns the door number. Once the door-closed event arrives, it emitspackage.deposited; the notification service generates and sends the pickup code. - Pickup. The customer enters the code. The gateway checks the hash, opens the door, marks the code used, and frees the compartment on door close.
-
Expiry. A worker scans
status = deposited AND pickup_deadline < now(), flags returns, and adds them to the next driver's manifest. Unconfirmed holds pasthold_expires_atare released the same way. -
Availability cache. Every capacity change emits an event; the updater rewrites
avail:{location_id}. A full recompute from the DB every 5 minutes fixes drift.
Deep dives
Reserving capacity under concurrency. Two questions: which counter, and when to bind a door. Binding a specific compartment at checkout wastes capacity, because the package may arrive a day late or not at all, and the box may turn out smaller than predicted. Instead reserve against a per-(location, size, date) counter and bind the actual door at deposit time.
UPDATE size_capacity
SET committed = committed + 1
WHERE location_id = ? AND size_class = ? AND date = ?
AND committed < total - out_of_service;
One row update, checked by affected-row count: no read-then-write race, and no lock held across a network call. At 600 reservations/s spread over 50k locations, contention per row is tiny; even a popular urban locker sees a few per second. Size fallback: if M is full, try L, but only if L has slack beyond its own forecast, so small parcels don't eat the scarce large doors. The idempotency key (unique on order_id) makes checkout retries safe.
| Option | Overbooking risk | Utilisation | Notes |
|---|---|---|---|
| Bind compartment at checkout | None | Low: doors idle while package is in transit | Simple; wastes 1–2 days per door |
| Counter per size and date, bind at deposit | None if counter is checked | High | Pick this |
| Allow 5% overbooking like airlines | Some packages rerouted | Highest | Only on peak days, with a reroute plan |
Expiry and reclamation. The state machine is held → confirmed → deposited → picked_up | return_flagged → returned. Every transition is a conditional update (WHERE status = 'deposited'), so the expiry worker and a late pickup can't both win. The expiry worker runs every minute per shard on the (status, pickup_deadline) index, in batches of 500. A package flagged for return can still be picked up until the driver actually removes it; the return becomes a no-op. Self-healing: a nightly reconcile compares compartments.state with kiosk-reported door sensors and reservation status, and fixes orphans (a compartment occupied with no live reservation is freed after a physical check by the next driver). Capacity counters for a past date are recomputed from reservations, so a crashed transaction can't leak capacity forever.
Availability freshness at checkout. Search reads Redis, which can lag by a few seconds. That's acceptable because search only suggests; the reservation's conditional update is the real check. If it fails, return 409 no_capacity with the next three nearest locations that have space, so the customer picks again in one click. Keep the gap small: the updater consumes events within ~1 s, and search hides a size when its cached count is 1 or less, which avoids showing the last slot to many shoppers at once. The 5-minute recompute catches missed events.
Kiosk offline and jammed doors. Each kiosk caches the hashes of pickup codes for packages currently inside it, plus an allowlist of driver credentials, so pickup and deposit work through a network outage. Events queue locally with event_id and upload when the link returns; the backend dedupes on (kiosk_id, event_id). A jam report marks the compartment out_of_service, which lowers total in the capacity rows, and any package inside gets a new door assignment or a driver visit. If a whole kiosk goes offline for more than 2 hours, search hides it and new reservations route elsewhere.
Pickup code security. 6 digits = 1M combinations per kiosk, and only packages in that kiosk are valid, so a guess hits about 60 in 1M. Lock the keypad for 5 minutes after 3 wrong tries, alert after 10 in an hour, and store only HMAC(code, kiosk secret). Codes are single-use, tied to one kiosk, and expire with the reservation. The app QR carries a signed, short-lived token instead, which can't be guessed. Door-open commands from the backend are signed so a spoofed network message can't open a door.
Trade-offs
- Counter-based reservation with late door binding gives high utilisation, but needs a reconcile job; binding a door at checkout is simpler and wastes capacity.
- Sharding by location keeps each reservation on one shard, but makes "all reservations for a customer" a scatter query; serve that from a separate per-customer index fed by events.
- A cached availability view makes search fast and cheap; the cost is occasional 409s at reserve time, which we turn into an alternatives list.
- Offline kiosk mode keeps pickups working in outages; the cost is a window where the kiosk and backend disagree, settled by idempotent event replay.
- Holding back large doors from small packages lowers fill rate on quiet days in exchange for not turning away XL parcels on busy days.
Interviewer expectations
Mid-level (L4)
- Clear entities (location, compartment, reservation, code) and a state machine for a package.
- A reservation that can't double-book, using a transaction or conditional update.
- Working deposit, pickup and expiry flows with notifications.
Senior (L5)
- Separates the fast, slightly stale search path from the strongly consistent reservation path, and handles the 409 gracefully.
- Reserves capacity by size and date and binds the door at deposit, with size fallback rules.
- Idempotent kiosk events, offline kiosk operation, and secure single-use codes with rate limits.
Staff (L6+)
- Self-healing reconciliation across counters, compartment states and physical sensors.
- Peak-day strategy: forecasting, controlled overbooking with rerouting, and capacity hold-back for large parcels.
- Clear failure semantics for each component (cache down, kiosk offline, shard down) and the metrics that would catch a capacity leak.