cf.completefrontend
My progress
SYSTEM DESIGN · #96

Design a grocery delivery slot picker

The grid of days and delivery windows where a grocery shopper books a van to their door, each window with limited capacity that sells out in minutes before a holiday. The hard parts are a slot model that depends on the address and the basket, availability that stays seconds fresh while thousands of people want the same Saturday, a long hold that belongs to the basket, local time and daylight saving days, and a grid that works with a keyboard and a screen reader.

The question

Design the delivery slot picker for an online grocery store like Tesco, Ocado, or Instacart. The shopper enters a postcode, sees the next 7 to 21 days split into 1-hour or 2-hour windows, each with a price, and books one. The slot is kept for them while they finish their shopping and check out.

Every window has limited room in the vans. Before Christmas, the slots for the week of the 23rd are released at a known time and most of them go within an hour. Walk me through what a slot is, how the grid stays honest while it drains, how booking works, how time zones and clock changes are handled, and how a keyboard or screen reader user picks a slot.

Real-life examples: Tesco and Sainsbury's delivery slots, Ocado delivery slots, Instacart scheduled delivery, Amazon Fresh delivery windows, BigBasket scheduled delivery slots

Requirements, and what makes this hard

A delivery slot picker looks like a small calendar. It is really a window into a capacity system: a fleet of vans, a team of pickers in a store or warehouse, and a set of roads, all sold in one-hour or two-hour pieces. Each piece is a slot: a time window on a date when a van promises to arrive. The picker's job is to show which slots this shopper can really have, sell one without overselling, and keep it until they pay.

Spend the first five minutes on the questions whose answers change the design. The table lists the ones this guide asks, the answer it assumes, and what each answer changes.

QuestionAnswer we assumeWhat it changes
How many days and windows?The next 21 days, 1-hour windows from 07:00 to 23:00, so about 16 windows a day and about 340 slots in the grid.340 cells is small enough to render as real DOM. Rendering speed is not the hard part; freshness and correctness are.
Do slots depend on the address?Yes. A postcode maps to a delivery zone served by one store or warehouse, each with its own vans and hours.No global grid. The grid is keyed by zone, and changing the address throws it away.
Delivery only, or click and collect too?Both. Collection slots come from a different capacity pool, often with different windows and prices.Fulfilment type is part of the slot key and a tab in the UI.
Do slots have prices?Yes. Peak windows such as Saturday morning cost more, quiet ones less, and delivery can be free over a basket value.Price is live data per slot, and the shown price must match the price charged at checkout.
When is the slot booked?Before shopping, like Tesco and Ocado: the shopper books first, then fills the basket, and the booking is kept for a limited time.A long server-side hold tied to the basket, a visible expiry, and a re-check when the basket changes.
How busy does it get?Normal days are calm. On the pre-Christmas release, hundreds of thousands of shoppers open the picker within minutes of a known time.A waiting room or staggered release, edge caching for the public view, and graceful 409s when a slot is taken.
Which devices?Most traffic is phones, often in the evening on home Wi-Fi, and many shoppers are older or less confident online.A mobile layout that is not a squashed table, big tap targets, and plain wording.

Functional requirements

  • Pick a fulfilment type (home delivery or click and collect) and an address or store.
  • See a grid of days and windows, each showing its price and whether it is available, nearly full, full, or closed.
  • Book a slot. The booking holds the slot for the shopper for a fixed time, shown on screen, and can be extended or changed.
  • Jump to the next available slot with one button, and filter to cheap or free slots.
  • Be told clearly when a slot is lost: it was taken by someone else, the hold ran out, or the basket grew too big for it.
  • Carry the booked slot into checkout, where it is confirmed together with the order.

Non-functional requirements

  • Honest: the grid never promises a slot the server will refuse. A slot shown as available is at most about 30 seconds old while the picker is open (our target, set by the business).
  • Correct: the server is the only judge of capacity. Two shoppers can never both get the last place in a van, and a retried request never books twice.
  • Fast: the grid is usable within about 1.5 seconds on a mid-range phone on 4G, and booking answers within about 500 ms in normal load.
  • Clear about time: every time is shown in the store's local time, including on the two days a year when clocks change.
  • Accessible: WCAG 2.2 AA (the Web Content Accessibility Guidelines, level AA), including a hold timer the shopper can extend before it runs out.
  • Survives peaks: the release of holiday slots must not take the site down or reward the fastest bot.

Out of scope

Route planning for the vans, the warehouse picking system, and payment are out of scope: they are the server's job, and the checkout-flow guide covers the checkout. On-demand delivery in 15 to 30 minutes, with a live ETA and a courier on a map, is a different product; the food-delivery guide covers it. We design the slot picker, the hold, and the hand-off to checkout.

What makes this hard

The grid itself is easy: 21 columns, 16 rows, a few hundred buttons. Five properties of the data around it drive every decision in the rest of this guide.

  1. Personal. The same Saturday 10:00 slot can be open for one shopper and closed for the next. It depends on the zone, the fulfilment type, the shopper's basket size, and sometimes their loyalty plan.
  2. Scarce and draining. A van has room for a fixed amount of work, for example 20 drops in a 4-hour shift (an estimate; real numbers vary by retailer and area). On a busy day, a window can go from 12 places to 0 in a few minutes. What the shopper saw 2 minutes ago may be false now.
  3. Held for a long time. A seat on a seat map is held for about 10 minutes while the fan pays. A grocery slot is booked before the shopper starts shopping and is kept for an hour or more while the basket fills. The hold outlives the tab, the session, and sometimes the device.
  4. Tied to local time. A slot is a promise about a wall clock at the shopper's door: 10:00 to 11:00 in London, not a timestamp. Clock changes, cut-offs like 'order by 23:45 the night before', and bank holidays all bend the grid.
  5. Spiky. Most days are quiet. A few days a year, a crowd arrives at the same minute for the same few hundred slots, and fairness matters as much as speed.

Compare this with the neighbours. The seat-map guide has thousands of seats to draw and a 10-minute hold on exact seats. The travel-booking guide has fares that change price between search and payment. The food-delivery guide has no slots at all: it promises an ETA for now.

Our component sells capacity, not a specific object, so two shoppers never fight over 'seat 14B'. They fight over the last few minutes of van time in a window, and the server decides who fits.

Check your understanding · Exercise 1

Which answers to scope questions change the design of the slot picker the most? Select all that apply.

The slot model: what a slot really is

The naive model is a list of times per day, ['10:00', '11:00', ...], plus a boolean available. It breaks as soon as real requirements arrive: which store, which kind of fulfilment, how full, and at what price? 'Not available' could mean full, past the cut-off, or a store closed on Christmas Day. Each needs a different message on screen, so each needs a field.

A slot is identified by four things together: the capacity pool (a store or warehouse plus a zone it serves), the fulfilment type (delivery or collection), the local date, and the window (a start and end wall-clock time, 1 or 2 hours long). The server gives each slot a stable id built from those parts, so the client never has to build one.

The slot id lon-e14, delivery, 2026-12-19, 10:00 is made of four parts: the pool (a store plus a zone), the fulfilment type, the local date, and the window start. Two sources describe it: the public zone grid with status, remaining hint and price band, and the personal eligibility call with fits basket, personal price and a reason. Both merge into one slot cell that reads Saturday 10:00, 4 pounds 50, only 2 left. A note lists the four statuses: available, limited, full, closed.slot idlon-e14pool: store + zonedeliveryfulfilment2026-12-19local date10:00windowZone grid (public)status, hint, price bandEligibility (personal)fitsBasket, price, reasonSlot cellSat 10:00, £4.50, 2 leftavailablelimitedfull, closedstricter statuswins
The id names the slot; the server builds it, so the client never has to. Status comes from the shared grid, fit and price from the personal call, and the cell shows the merge: the stricter status wins, and the personal fitsBasket decides whether this shopper can book.
ts
type SlotStatus = "available" | "limited" | "full" | "closed";

type Slot = {
  id: string;              // "lon-e14:delivery:2026-12-19:10:00"
  poolId: string;          // store or warehouse + zone
  fulfilment: "delivery" | "collection";
  date: string;            // local date "2026-12-19"
  start: string;           // ISO with offset "2026-12-19T10:00:00+00:00"
  end: string;
  timeZone: string;        // "Europe/London"
  status: SlotStatus;
  remainingHint?: number;  // only when low, e.g. 2 for "Only 2 left"
  fitsBasket: boolean;     // server's answer for THIS basket
  price: { amount: number; currency: string; freeOver?: number };
  reason?: "cutoff-passed" | "store-closed" | "basket-too-large";
  cutoffAt: string;        // last moment to book, ISO with offset
};
The slot as the client sees it, merged from the zone grid and the shopper's eligibility. The client never computes status or price.

Four fields deserve a word. status has four values because each one reads differently: available (book it), limited (book it soon, often with 'Only 2 left'), full (nobody can book it now, but a cancellation may reopen it), and closed (nobody will book it: the cut-off passed or the store is shut).

remainingHint is a hint, not the real count: the server sends it only when it is small, and may round it. fitsBasket is the server's verdict for this shopper's basket, which the next chapter explains. reason lets the UI say why a slot is greyed out, and cutoffAt says when it will close.

Prices are part of the slot

Grocery retailers price windows by demand: a Saturday morning slot can cost several pounds more than a Tuesday afternoon one, and many waive the fee above a basket value. Amazon Fresh in the US, for example, charges a delivery fee on smaller orders and waives it over a threshold for Prime members. That threshold and the fees have changed several times, so the client must never hard-code them.

The server sends the price per slot and a freeOver threshold, and the UI shows 'Free over £50' next to it. When the basket crosses the threshold, the shown price must update, which is one more reason eligibility is fetched again when the basket changes.

Decision Where should the grid of slots come from?

A fixed timetable in the client

Ship a list of windows per weekday in the JavaScript bundle.

Ask the server only 'is slot X free?' when the shopper clicks.

Pros

  • Instant first paint with no network call.
  • Very little server work.

Cons

  • Wrong for most zones: hours, windows, and closures differ.
  • Shows slots that are full, so most clicks end in a refusal.
  • Prices and holidays need a new deploy.

One grid per zone, the same for every shopper

GET /slots?pool=lon-e14&type=delivery returns status and price per slot.

Cache it at the CDN for a few seconds, since everyone in the zone sees the same thing.

Pros

  • Cheap to serve at peak: one cached response for thousands of shoppers.
  • Simple to reason about.

Cons

  • Cannot say whether this basket fits.
  • Cannot show personal prices or member-only slots.
  • Shows 'available' for slots a big basket cannot use.

A zone grid plus a personal eligibility layer

The zone grid (status, price band) is public and edge-cached for a few seconds.

A second small call, keyed by basket and shopper, returns fitsBasket, personal price, and the shopper's current booking.

The client merges both: the personal price and fit win, and for status the stricter answer wins.

Pros

  • Most bytes come from the edge cache, even at peak.
  • Every shopper still gets an honest answer for their own basket.
  • The personal call is small: only flags and prices, not the whole grid.

Cons

  • Two requests and a merge step in the client.
  • The two parts can disagree for a few seconds; the server's booking check is still the final word.

What we pick: Pick the zone grid plus a personal eligibility layer. The public part is the same for everyone in a zone, so it can be served from a cache during the holiday rush; the personal part is tiny and answers the question the public part cannot: does my basket fit? On a quiet day you could fold both into one uncached call, and that is a fine first answer in an interview. Mention the split when the interviewer asks about peak load.

Check your understanding · Exercise 2

A slot shows as greyed out. Which fields let the UI tell the shopper something useful instead of just 'unavailable'? Select all that apply.

Capacity is not one number

The naive grid stores one count per slot: 'this window takes 20 orders, 18 are booked, so 2 are left'. The client shows 'Only 2 left' and lets anyone book. It looks right and it is wrong, because a delivery window is limited by several things at once, and an order uses a different amount of each.

  • Van time. Each stop costs driving time plus handover time at the door. A far address or a top-floor flat with 12 crates costs more minutes than a 3-crate drop next to the depot.
  • Van space and weight. A van holds a fixed number of crates (often called totes) and a maximum payload. A basket with six packs of bottled water can fill the weight limit long before the crate limit.
  • Picking time. Someone in the store or warehouse picks the order before the van leaves. 140 items take longer to pick than 20, and the picking team has its own shifts.
  • One van per order. An order normally travels in one van, so 30 free crates spread across three vans do not fit a 16-crate order.

A worked example with made-up but plausible numbers (estimates, not any retailer's real figures). Saturday 10:00 to 11:00 in zone E14 has two vans. Van A has 6 crates, 70 kg, and 25 minutes left; van B has 5 crates, 60 kg, and 30 minutes left.

A count-based grid says 'available'. A shopper with a 3-crate, 20 kg basket near the depot fits either van. A shopper with a 9-crate, 110 kg family shop fits neither, even though 11 crates are free in total. The same slot is open for one shopper and closed for the next.

Decision Who decides whether a slot can take this shopper's order?

The client, from a remaining count

The server sends remaining: 2 per slot.

The client shows the slot as bookable if the count is above zero.

Pros

  • Simple, one number per slot.
  • Easy to cache for everyone.

Cons

  • Wrong for big baskets: they see a slot, book it, and get refused.
  • Leaks exact capacity to competitors and bots.
  • Any change to the capacity rules needs a client release.

The client, from the full capacity data

The server sends crates, weight, and minutes left per van.

The client runs the same fitting logic as the server.

Pros

  • Accurate answers without a personal request.

Cons

  • Two copies of complex logic that drift apart.
  • The client does not know drive time to the address or the picking plan.
  • Sends far more data than the UI needs.

The server, for this basket

The client sends the basket id (and the address) with the eligibility request.

The server sizes the basket and returns fitsBasket and a reason per slot.

The booking request runs the same check again inside the reservation.

Pros

  • One source of truth, right for every basket.
  • The UI can explain a refusal ('your order is too big for this slot').
  • Rules can change any day without touching the client.

Cons

  • A personal request that cannot be shared through a CDN.
  • Must be refreshed when the basket changes size.

What we pick: Let the server decide, per basket. The client renders fitsBasket and reason and never does capacity maths, which is why the previous chapter put fit in the personal call. The booking call checks again, because the eligibility answer can be a few seconds old.

Basket
Address
Your order needs 9 crates, 110 kg and 14 van minutes. Sat 08:00: the count says 5 left; the server says fits van A. Sat 10:00: the count says 2 left; the server says no, crates + weight. Sat 12:00: the count says 6 left; the server says fits van A. Sat 18:00: the count says 3 left; the server says fits van A. The count says 4 of 4 windows are open. The server says 3 fit this order, so 1 tap would end in a refusal.your order: 9 crates, 110 kg, 14 mincount saysserver saysvan used or closestSat 08:005 leftfits van AA: 10c 150kg 40mSat 10:002 leftno: crates + weightA: 6c 70kg 25mSat 12:006 leftfits van AA: 16c 120kg 50mSat 18:003 leftfits van AA: 15c 200kg 18m
Each row is one window. The middle column is what a count-based grid shows everyone, and it calls every window open; the right column is the server's check for this order against each van's crates, weight, and minutes. Numbers are made up for the lab.

The count says 4 of 4 windows are open. The server says 3 fit this order, so 1 tap would end in a refusal.

Two details make this pleasant to use. First, when a slot does not fit, offer a way out instead of a dead end: suggest the nearest slot that fits, or a 2-hour window that has a second van.

Second, re-check eligibility when the basket changes. The client cannot know where the limit is, so it asks again after any basket change, debounced by about a second so that adding ten items in a row costs one request. If the booked slot stops fitting, the reservation chapter explains what the shopper sees.

Check your understanding · Exercise 3

A shopper with a 14-crate party order sees Saturday 10:00 as available, taps it, and the booking is refused. What went wrong in the design? Select all that apply.

Keeping availability fresh while it drains

The first naive approach fetches the grid once, when the picker opens. On a quiet Tuesday that is fine. On a Sunday evening in December, a shopper reads the grid for three minutes, compares prices, checks with someone at home, and taps Saturday 10:00, which went full two minutes ago. Every tap on a stale cell is a refused booking, and refusals are what make shoppers give up.

The second naive approach refreshes every second. With 200,000 pickers open at a holiday release (our estimate for a large UK grocer), that is 200,000 requests a second, most of them returning the same bytes. The answer sits between the two: fresh enough that a tap usually succeeds, cheap enough to survive the peak. Our target from the requirements is a grid that is at most about 30 seconds old.

Decision How should the open picker learn that slots have filled or reopened?

Short polling with conditional requests

Every 20 seconds while the tab is visible, GET the zone grid with If-None-Match.

The CDN answers from a copy at most 5 seconds old; unchanged grids return 304 Not Modified with no body.

Pause when document.visibilityState is hidden, refetch at once when it becomes visible.

Pros

  • Works with plain HTTP caching, so the edge absorbs the crowd.
  • No long-lived connections to keep alive at peak.
  • Easy to slow down from the server with a Retry-After or a longer interval in the response.

Cons

  • Up to one interval plus the cache age out of date.
  • Wasted requests on days when nothing changes.

Server-sent events per zone

Open an EventSource to /zones/lon-e14/slot-events.

The server pushes small slot-changed messages when a status changes.

Pros

  • Changes arrive within a second.
  • No polling traffic when nothing changes.

Cons

  • One open connection per shopper: 200,000 at the release, each held by a server or a special edge.
  • Still needs a full refetch after a reconnect to fill gaps.
  • Hard to cache: every connection is its own stream.

WebSocket

A two-way socket carries slot changes and maybe the booking itself.

Pros

  • Lowest latency, one channel for everything.

Cons

  • Data flows one way here, so two-way adds nothing.
  • Same connection cost as SSE, plus more infrastructure.

What we pick: Poll. Slot status changes over minutes, not milliseconds, and the booking call is the final check anyway, so a grid that is 25 seconds old is honest enough when the UI handles the occasional refusal well. Polling lets the CDN serve the crowd, which matters more than speed on release day. Server-sent events are a fair upgrade for a small, hot set, such as the one day the shopper is looking at; the choosing-realtime lesson covers that trade-off.

Timeline of one slot over 60 seconds. Truth on the server: 4 left until 18 seconds, 2 left until 34 seconds, then full. The edge copy refreshes every 5 seconds: 4, 4, 4, 4, then 2 from 20 seconds, then 0, full, from 35 seconds. The shopper polls every 20 seconds and sees 4 left from 0, 2 left from 20, and full from 40. A tap at 36 seconds, on a cell that still says 2 left, gets a 409.truth4 left2 leftfulledge444422200000shopper4 left2 leftfulltap at 36 s: 4090 s20 s40 s60 s
Edge age plus the poll interval sets the worst case: here about 25 seconds. The tap at 36 seconds is why the booking call checks again and why a 409 is a normal, designed path.
ts
const POLL_MS = 20_000;

export function useZoneGrid(poolId: string, fulfilment: Fulfilment) {
  return useQuery({
    queryKey: ["slots", poolId, fulfilment],
    queryFn: ({ signal }) =>
      api.get<ZoneGrid>(`/pools/${poolId}/slots?type=${fulfilment}`, { signal }),
    // Pauses on its own while document.visibilityState is "hidden".
    // Up to 3 s of random jitter so thousands of timers do not fire together.
    refetchInterval: (query) => (query.state.data?.pollMs ?? POLL_MS) + Math.random() * 3_000,
    refetchOnWindowFocus: true,
    staleTime: 5_000,
    placeholderData: keepPreviousData,
  });
}
Poll the zone grid only while the picker is visible, with jitter. TanStack Query keeps the last good grid on screen during a refetch.

Showing scarcity honestly

Between full refreshes, three rules keep the grid trustworthy. First, a refresh changes cells in place: it never re-sorts, never moves focus, and never closes a panel the shopper has open. Second, the cell the shopper has selected or focused gets special care: if it turns full, say so next to it, in text, and offer the nearest open slot. Third, 'Only 2 left' comes from remainingHint, never from a client guess, and it is shown as text and an icon, not a colour.

One more rule is about optimism. The optimistic-updates lesson shows how to paint a 'like' before the server agrees; do not do that here. Pressing Reserve shows the slot as pending ('Reserving...') with the rest of the grid still usable, and it becomes 'Reserved' only when the server answers. A false 'Reserved' that flips back is worse than a 300 ms wait, because the shopper may close the tab believing they have a slot.

Check your understanding · Exercise 4

The picker polls every 20 seconds and the CDN keeps the zone grid for 5 seconds. Which statements are true? Select all that apply.

Reserving: a long hold that belongs to the basket

The seat-map guide covers hold mechanics in depth: a hold with a time to live, a countdown driven by the server's expiry time, release on cancel, and a 409 Conflict when two buyers race for the same seat. We reuse all of it and only describe what is different when the thing held is a share of van capacity, booked before the shopping starts.

Decision When should the slot be secured for the shopper?

Only at checkout

The shopper picks a preferred slot, which is just saved in the basket.

Checkout tries to book it and fails if it filled up.

Pros

  • No held capacity sitting idle.
  • Simple server.

Cons

  • The shopper spends 40 minutes filling a basket, then loses the slot at the last step.
  • At peak, most checkouts fail and must loop back to the grid.

A short hold, like a seat

Book the slot for about 10 minutes, then checkout must happen.

Pros

  • Capacity comes back quickly if the shopper leaves.

Cons

  • A weekly shop takes longer than 10 minutes, so the hold expires mid-shop.
  • Forces shoppers to rush or to book late, when slots are gone.

A long hold attached to the basket

Book first; the server ties the booking to the basket and the account, with an expiresAt an hour or two away.

The shopper can extend it a limited number of times.

Checkout turns the hold into a confirmed order slot; the order can then be changed until a cut-off.

Pros

  • Matches how people shop: secure the time, then fill the basket.
  • Survives reloads, new tabs, and a switch from laptop to phone.
  • Peak demand is spread: people book early and shop later.

Cons

  • Capacity is tied up by shoppers who never check out.
  • The basket can grow past what the slot can carry.

What we pick: Use a long hold on the basket. UK grocers work this way: at the time of writing, Tesco's help pages say a booked slot is held for 2 hours and can be extended by a further 2, and Ocado's help pages have given 60 minutes to check out. The exact length is a business setting the server sends, never a constant in the client. The cost, capacity held by people who wander off, is handled on the server: holds expire, and a retailer can shorten them on peak days.

What changes compared with a seat hold

AspectSeat mapGrocery slot
What is heldOne exact seatA share of van and picker capacity in a window
Hold lengthAbout 10 minutesAbout 1 to 2 hours, extendable (retailer setting)
Owned byThe checkout sessionThe basket and account, on any device
After paymentFinalCan be changed until a cut-off, often the night before
Can break while heldRarelyYes: the basket can grow too big for the slot

Because the hold belongs to the basket, the client never stores it as the source of truth. The basket response carries the booking: slot id, expiresAt, how many extensions are left, and whether the basket still fits. Every page that shows the basket shows the booking from the same cached query, so the header chip, the basket page, and the checkout agree. A second tab or a phone reads the same booking from the server.

State machine of a slot booking. Selected, not held yet, goes to reserving on Reserve. Reserving goes to held on a 201 reply, or to refused on a 409 with the nearest slots; from refused the shopper tries the next slot and returns to selected. Held, until 15:42, goes to confirmed at checkout, where the order can change until the cut-off. Held goes to expiring when 10 minutes are left, which warns the shopper; extend returns to held. Expiring goes to expired at zero, and the slot is released. Held goes to no-fit when the basket grows too big.selectednot held yetreservingReserving...helduntil 15:42confirmeduntil cut-offrefused409 + nearestexpiring10 min: warnno-fitbasket too bigexpiredslot releasedReserve201checkout409try nexttimerextendbasket grew0:00
The client never moves from reserving to held on its own: only a server reply does. The two ways to lose a slot, the timer and the basket, both get a visible state with a way out, and expiring exists so the shopper can extend before time runs out.

The state machine shows the paths. held is the normal state; 10 minutes before expiry it becomes expiring, and the shopper is offered an extension (the accessibility chapter explains why that warning is required, not just polite). extend returns to held with a new expiresAt, and checkout moves it to confirmed. Two paths lose it: the timer runs out (expired), or the basket grows past the slot (no-fit).

When the basket no longer fits

A shopper books Saturday 10:00 with a small basket, then adds a 24-pack of water and a party's worth of drinks. The server re-sizes the basket on every change and may answer 'this booking no longer fits'. Silently cancelling the booking is the worst response, because the shopper finds out at checkout. Blocking the add-to-basket is hostile.

The good answer keeps the booking, marks it no-fit with a reason, and shows a banner on the basket ('Your order is now too big for Saturday 10:00 to 11:00') with the nearest slots that fit. Checkout is blocked until the shopper picks a fitting slot or trims the basket.

Changing slot is a swap, not a cancel followed by a book. POST /bookings with replaces: "bk_123" lets the server reserve the new slot and release the old one in one transaction. If the new slot is gone, the shopper keeps the old one. Cancel-then-book has a gap in which the shopper holds nothing, and at peak the old slot is taken in that gap.

ts
type Booking = {
  id: string;                 // "bk_123"
  slotId: string;
  state: "held" | "confirmed";
  expiresAt?: string;         // only while held, ISO with offset
  extensionsLeft: number;     // e.g. 1
  fits: boolean;              // re-checked on every basket change
  fitReason?: "weight" | "crates" | "time";
  price: { amount: number; currency: string };
  amendUntil?: string;        // cut-off for changes once confirmed
};

type Basket = {
  id: string;
  version: number;
  booking?: Booking;
  // items, totals...
};
The booking lives on the basket. The client shows it; the server owns every field.

Check your understanding · Exercise 5

A shopper books a slot on a laptop, then continues shopping on their phone an hour later. Which design choices make this work? Select all that apply.

Errors and races: sold out, lost replies, and two tabs

Three failures happen every day at scale, and many times a second on a busy release morning. The request-races and api-contracts-and-errors lessons cover the general tools; this section applies them to booking a slot.

Sold out between looking and tapping

The grid said 'Only 1 left'; by the time the shopper tapped Reserve, someone else had it. The server answers 409 Conflict, as in the seat-map guide: the request is valid but clashes with the slot's current state (412 Precondition Failed is for a failed conditional header such as If-Match, which this call does not send). The naive client shows 'Something went wrong' and leaves the cell looking available, so the shopper taps again and gets the same error.

A good 409 body carries everything the client needs to recover in one step: an error code, the fresh state of that slot, and up to three nearby slots that fit this basket. The client writes the fresh slot into its cache (the cell now says 'Full'), refetches the grid in the background, and keeps focus where it was. An alert says 'Saturday 10:00 to 11:00 just filled. Nearest: Saturday 11:00 to 12:00, £4.50', with a button that selects the suggestion.

Sequence for reserving a slot. The shopper presses Reserve. SlotGrid shows Reserving on the cell and calls the booking client with a new key. The booking client sends POST bookings to the Slot API. On success the API replies 201 held, and the shopper sees Reserved until 15:42. On the failure path the API replies 409 with the nearest slots, the booking client writes the cell as Full in SlotGrid, and the shopper gets an alert offering 12:00.ShopperSlotGridBookingSlot APIReserveReserving...reserve(key)POST /bookings201 heldReserved, 15:42or 409 + nearestcell: Fullalert: try 12:00
The cell is pending until the server answers, never Reserved. A 409 carries the fresh slot and alternatives, so one reply updates the cell and offers a way forward without another round trip.

The reply that never arrives

The shopper taps Reserve on a train, the request reaches the server, the booking is made, and the reply is lost in a tunnel. The client does not know whether it has a slot. Retrying a plain POST might book a second slot; giving up might leave a held slot the shopper does not know about.

The fix is an idempotency key: a random id the client creates when the shopper presses Reserve and sends as an Idempotency-Key header on that request and every retry of it. The server stores the result under the key for a while, for example a day, and a repeat of the same key returns the same booking instead of making a new one.

ts
export async function reserve(basketId: string, slotId: string, replaces?: string) {
  const key = crypto.randomUUID();
  const body = JSON.stringify({ basketId, slotId, replaces });
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const res = await fetch("/api/bookings", {
        method: "POST",
        headers: { "Content-Type": "application/json", "Idempotency-Key": key },
        body,
        signal: AbortSignal.timeout(8_000),
      });
      if (res.status === 409) return { kind: "conflict", ...(await res.json()) };
      if (res.ok) return { kind: "held", booking: await res.json() };
      if (res.status < 500 && res.status !== 429) throw new Error(`HTTP ${res.status}`);
    } catch (error) {
      // Retry only network failures and timeouts; rethrow everything else.
      const retryable = error instanceof TypeError || (error instanceof DOMException && error.name === "TimeoutError");
      if (!retryable) throw error;
    }
    await sleep(500 * 2 ** attempt + Math.random() * 250);
  }
  return { kind: "unknown" }; // caller re-reads the basket to find out
}
One key per press of Reserve, reused by every retry of that press.
Sequence for a lost reply. The shopper presses Reserve. The booking client sends POST with idempotency key K1. The Slot API books bk_124 and replies 201, but the reply is lost. After a timeout the booking client tells the shopper it is checking, and retries with the same key K1. The API recognises K1 and replies with the same booking bk_124, and the shopper sees Reserved. If every retry fails, the booking client reads the basket, and the API says whether a booking exists.ShopperBookingSlot APIReservePOST, key K1201 bk_124: lostChecking your booking...retry, same key K1same bk_124Reservedall failed: GET basketbooking: yes or no
The key turns a dangerous retry into a safe one: the server returns the first result instead of booking twice. When even retries fail, the basket, not the client, says what happened.

If all retries fail, the result is unknown, not failed. The client says 'Checking your booking...' and reads the basket, which is the source of truth. If the basket shows the booking, the shopper has it; if not, they can try again with a fresh key. While a reservation is pending, the Reserve button is disabled, so a double tap cannot start a second request; the server's rule of one held booking per basket is the last line of defence.

Two tabs, one basket

A shopper reserves Saturday in one tab and Sunday in another. Because the booking belongs to the basket, the server sees two requests for one basket. The second must say which booking it replaces; if that does not match the basket's current booking, the server answers 409 BOOKING_CHANGED with the current booking, and the second tab updates instead of overwriting blindly.

The basket's version number does the same job for other edits. A BroadcastChannel message after each booking lets other open tabs refetch at once instead of waiting for their next poll; the cross-tab-sync lesson covers the channel.

Check your understanding · Exercise 6

A reserve request times out after 8 seconds. What should the client do? Select all that apply.

Time zones, clock changes, cut-offs, and closures

A slot is a promise about the clock on the wall at the delivery address: 'Saturday, 10:00 to 11:00' in London. It is not an instant in UTC that happens to show as 10:00. Two naive habits break that promise.

The first formats slot times in the device's time zone. A daughter in Dubai booking her parents' December shop in London sees '14:00 to 15:00' and books the wrong window. The second builds the grid with millisecond maths: take the first slot, add 3_600_000 for each window and 86_400_000 for each day.

Millisecond maths fails twice a year. In the UK, clocks go forward at 01:00 on the last Sunday of March and back at 02:00 on the last Sunday of October, so 29 March 2026 has 23 hours and 25 October 2026 has 25. Delivery windows from 07:00 to 23:00 never contain the missing or repeated hour, which lulls teams into thinking clock changes do not matter.

They still do. On the October weekend, 'Saturday 10:00 plus 24 hours' lands on Sunday 09:00 local time, so every slot after the change is drawn an hour early. And a 2-hour hold that starts at 00:30 on that Sunday really lasts 2 hours, even though the wall clock moves only one.

The window 10:00 to 11:00 London time on three days around the October 2026 clock change. Saturday 24 October is British Summer Time, plus one hour, so 10:00 is 09:00 UTC. Sunday 25 October is 25 hours long and 10:00 is 10:00 UTC. Monday 26 October is GMT, plus zero, and 10:00 is 10:00 UTC. The naive path adds 86,400,000 milliseconds to Saturday 10:00 and lands on Sunday 09:00 to 10:00, which is wrong. A note says the server sends the local date and window and the client never adds time. The repeated hour, 01:00 to 02:00, is outside delivery hours.Sat 24 OctBST, +01:0010:00 to 11:0009:00 UTCSun 25 Oct25 hours long10:00 to 11:0010:00 UTCMon 26 OctGMT, +00:0010:00 to 11:0010:00 UTCnaive: Sat 10:00 + 24 h09:00 to 10:00+ 86,400,000 msserver sendslocal date + windowclient never addsrepeated hour 01:00 to 02:00: outside delivery hours
The wall clock stays 10:00 while UTC moves an hour. Adding 24 hours of milliseconds keeps UTC fixed instead, so every slot after the change is drawn an hour early.

The fix is a clear split of jobs. The server computes slots in the store's zone and sends each one with its local date, its start and end as ISO strings with the offset for that day, and the IANA zone name such as Europe/London. The client never adds hours. It groups cells by the local date string, and formats times with Intl.DateTimeFormat using the slot's timeZone, so every device shows the store's wall clock. The locale-formatting lesson covers the formatter options; here only one option matters.

ts
const windowFormat = new Intl.DateTimeFormat("en-GB", {
  hour: "2-digit",
  minute: "2-digit",
  timeZone: "Europe/London",   // from slot.timeZone, never the device
});

export function windowLabel(slot: Slot): string {
  return windowFormat.formatRange(new Date(slot.start), new Date(slot.end));
}
// Device in Dubai, slot "2026-10-24T10:00:00+01:00" to "...T11:00:00+01:00"
// windowLabel(slot) === "10:00–11:00"  (London wall clock)
Format in the store's zone, not the device's. formatRange joins the two times the way the locale prefers: '10:00–11:00' in en-GB.

If the shopper's device is in a different zone from the store, say so once at the top: 'Times are UK time'. The Temporal API makes local-date arithmetic much safer than Date. It has shipped in Firefox 139 and Chrome 144, but at the time of writing (late 2026) no stable Safari release supports it, so check MDN's compatibility table and load a polyfill if you need it. With the server doing the date maths, the picker mostly needs formatting, which Intl already handles everywhere.

Cut-offs and closures

Each slot has a cut-off: the last moment it can be booked or changed, which for same-day slots may be a few hours before the window so the picking team has time. A confirmed order can usually be changed until the evening before; at the time of writing, Tesco's help pages give 23:45 the night before for most orders. The server sends cutoffAt per slot, and the UI shows it in words near the hold: 'You can change this order until 23:45 on Friday.'

Cut-offs move cells from available to closed while the shopper watches. The poll catches this within the interval, but the client can also close a cell itself at cutoffAt, using the server's clock (a serverTime field in the grid response gives the offset from the device clock). The server still enforces it.

Closures such as Christmas Day or a store refit come as closed with reason: "store-closed". Keep the closed day as a column labelled 'Closed' instead of dropping it, so the days do not jump left and the shopper is not left wondering where the 25th went.

Check your understanding · Exercise 7

Which practices keep slot times right for every shopper and every day of the year? Select all that apply.

The grid on a laptop and on a phone

On a wide screen the natural layout is a timetable: days as columns, windows as rows, each cell showing a price and a status. Seven days fit across a laptop; the other two weeks sit behind 'Next 7 days' and 'Previous 7 days' buttons, or a horizontal scroll with the day headers and the time column made sticky (position: sticky) so the shopper never loses which row or column a price belongs to. With 16 windows and 7 days, that is 112 cells on screen: plain DOM, no virtualisation needed.

Squeezing that table onto a 360-pixel phone gives cells about 45 pixels wide: big enough to tap, but too narrow to show '£4.50' and a status in readable text. So the phone gets a different layout from the same data: a strip of day tabs at the top (each tab shows the weekday, the date, and 'from £1' or 'Full'), and below it the chosen day's windows as full-width rows with time, price, and status in words.

The date-picker and calendar-week-view guides cover calendar layout and date keyboard handling in general. Here the cells are choices with prices and live status, which changes what each cell must say.

Wireframe of the slot picker on a laptop and on a phone. Laptop: an Earliest slot button for Thursday 18:00, then a timetable with days Thursday 17 to Sunday 20 as columns and windows 08:00, 10:00, 12:00 and 18:00 as rows. Thursday's early windows read Closed, several Saturday cells read Full, two cells read 2 left, the rest show prices from 3 pounds to 5 pounds or Free, and Saturday 12:00 is selected. Phone: day tabs Fri, Sat and Sun with Sat chosen, then Saturday's windows as full-width rows: 08:00 Full, 10:00 Full, 12:00 5 pounds 2 left, 18:00 Full, and a Reserve button. Notes: sticky day headers and time column, price and status in words, the earliest slot is the server's pick; on the phone, day tabs then one day as rows, and Reserve is a separate button.Book a slot: laptopEarliest: Thu 18:00Thu 17Fri 18Sat 19Sun 2008:00Closed£3Full£410:00Closed2 leftFull£512:00Closed£42 left£418:00£3FullFullFreephoneFriSatSun08:00 Full10:00 Full12:00 £5, 2 left18:00 FullReservesticky day headers and time columnprice and status in wordsearliest slot: the server's pickday tabs, thenone day as rowsReserve: own button
Same data, two layouts. Every cell says its price or status in words, closed and full cells stay in place, and the phone shows one day at a time with room for time, price and status on one row.
  • Status is never colour alone. A full cell says 'Full', a nearly full one says 'Only 2 left' with an icon, a closed one says 'Closed'. Colour adds to the words; it does not replace them (WCAG 1.4.1, covered in the visual-accessibility lesson).
  • Price is always visible. '£1.50', 'Free', or 'Free over £50' in each cell, because price drives the choice as much as time.
  • Next available slot. A button above the grid reads 'Earliest slot: Thursday 18:00 to 19:00, £3'. The server computes it for this basket (nextAvailable in the response), so it never suggests a slot that will not fit.
  • Full days stay in place. On the phone, a full day's tab says 'Full' and stays selectable so the shopper can confirm it; on the laptop the column stays in place with every cell reading 'Full'.
  • The view lives in the URL. The fulfilment type and the selected week or day go in the query string (?type=delivery&day=2026-12-19), so the Back button and a refresh return to the same place. The hold does not: it lives on the basket. The url-as-state lesson covers the mechanics.

The lab below puts the previous chapters together. Pick a postcode and a basket size, let other shoppers drain the grid, reserve a slot, let the hold run down and extend it, and lose a race for the last place. Switch the layout to see the same data as a phone shows it.

Postcode
BasketLayout
Slot grid for E14, 1-hour windows, laptop layout. Thu 17: 08:00 Closed, 10:00 Closed, 12:00 Closed, 18:00 £3.50, 2 left; Fri 18: 08:00 £3, 10:00 £4.50, 1 left, 12:00 £3, 18:00 Full; Sat 19: 08:00 £4, 10:00 £5.50, 12:00 £4, 2 left, 18:00 £4.50; Sun 20: 08:00 £4, 1 left, 10:00 £5.50, 12:00 Full, 18:00 £4.50; Mon 21: 08:00 £3, 10:00 £4.50, 2 left, 12:00 £3, 18:00 £3.50, 1 left.Thu 17Fri 18Sat 19Sun 20Mon 2108:0010:0012:0018:00ClosedClosedClosed£3.502 left£3£4.501 left£3Full£4£5.50£42 left£4.50£41 left£5.50Full£4.50£3£4.502 left£3£3.501 left
Days across, windows down. Green is your hold, the outlined cell is selected, hatched cells are nearly full, red cells are too big for your basket, faded cells are full or closed.
Day

Pick a day and a window, then press Reserve.

Check your understanding · Exercise 8

Which choices make the slot grid usable on a 360-pixel phone? Select all that apply.

Keyboard, screen readers, and a timer you can extend

A slot grid is a two-dimensional set of choices with prices and states. The naive build makes every cell a div with a click handler: a keyboard user cannot reach it, and a screen reader user hears 'four fifty' with no day, time, or status. The next naive build makes every cell a button, which works, but reaching Saturday 18:00 can take dozens of presses of Tab. The aria-patterns and keyboard-navigation lessons cover the patterns; the decision is which one fits.

Decision Which pattern should the slot cells use?

A button per cell, in Tab order

Each cell is a button with a full accessible name.

Tab moves cell by cell.

Pros

  • Native, no scripting for focus.
  • Works in every screen reader.

Cons

  • Up to 112 Tab stops before the 'Continue' button.
  • No way to move down a column or across a row.

One radio group per day

A fieldset with a legend such as 'Saturday 19 December' and a native radio per window.

Tab moves between days; arrow keys move within a day.

Pros

  • Native semantics: 'radio, 3 of 16, not checked'.
  • Fits the phone layout, where one day is shown at a time.

Cons

  • Arrow keys also select the radio, so selecting must not reserve.
  • On the wide table, moving across days needs Tab, not Left and Right.

An ARIA grid

role="grid" on the table, column headers for days, row headers for times.

One Tab stop; arrow keys move between cells, Home and End along a row, with a roving tabindex.

Enter or Space selects the focused cell.

Pros

  • Matches the two-dimensional layout: Down moves to the next window, Right to the next day.
  • One Tab stop for the whole grid.

Cons

  • All keyboard handling is ours to write and test.
  • Screen reader users must know grid navigation; the cell's own name must still carry day and time.

What we pick: Use the ARIA grid for the wide table and a native radio group per day on the phone, both rendered from the same slot data. In both, choosing a cell only selects it; a separate 'Reserve this slot' button commits. That matters because native radios select on arrow keys, and a shopper moving through Saturday must not fire five reservations. Full and closed cells stay focusable with aria-disabled="true", so a screen reader user learns they exist and why they cannot be chosen.

Every cell gets a complete name, not just the price: 'Saturday 19 December, 10:00 to 11:00, £4.50, only 2 left'. Grid headers help, but screen readers differ in how much header text they read when focus moves, so the name must stand alone.

A refresh that changes a cell announces nothing unless it is the focused or selected cell; then a polite live region says 'Saturday 10:00 is now full'. Results of actions use the same region: 'Reserved. Held until 15:42.' A refused booking uses role="alert", because the shopper must act on it.

Announcing the hold timer

The hold countdown is the easy place to get this wrong. A countdown inside a live region that ticks every second reads out 'one hour fifty-nine minutes fifty-eight seconds' forever and drowns everything else. Show the time visually, in minutes ('Held for 1 h 52 min'), with the absolute time next to it ('until 15:42'), and keep it out of any live region. Announce only at milestones: when the hold starts, when 10 minutes are left, and when it ends.

WCAG 2.2.1 Timing Adjustable applies, because the hold is a time limit set by the content and is shorter than 20 hours. One way to meet it is to warn the user before time runs out, give them at least 20 seconds to extend with a simple action, and allow at least ten extensions. So at 10 minutes left, a non-modal banner appears with an 'Extend by 2 hours' button, and the polite region announces it.

The business may want fewer extensions, such as the single 2-hour extension Tesco describes, so that idle holds do not block van capacity. The criterion's Understanding document gives a ticket site as an example where a short hold counts as essential, because holding scarce stock longer would change the activity. A capped slot hold can make the same argument, but record it as an accessibility decision and keep the cost small: when the hold ends, the shopper loses the slot, never the basket.

The countdown-timer guide covers drift and background tabs. Base the remaining time on expiresAt and the server clock offset, never on a timer started in the tab.

Check your understanding · Exercise 9

Which behaviours meet the accessibility goals for the slot picker and its hold? Select all that apply.

Release day: surviving the pre-Christmas rush

Most grocers open Christmas week slots on an announced day, and some give paying delivery-pass members early access (Tesco has done this for Delivery Saver customers). Everybody who cares knows the minute. The naive plan opens every slot to everybody at 09:00 and relies on autoscaling.

At 09:00:00, a few hundred thousand pickers load at once, every one of them polls, and the slot service slows down. Requests time out and clients retry, which multiplies the load; the fastest scripts win, and real shoppers see error pages. This is not hypothetical: in March 2020 Ocado's app and website were overloaded, it put shoppers in a virtual queue, and for a few days it stopped taking new orders altogether.

Decision How do we protect the release of holiday slots?

Scale the servers and hope

Add capacity before the day; let everyone in at 09:00.

Pros

  • No extra product to build or explain.

Cons

  • The bottleneck is the vans, not the servers: a zone with room for 600 orders that week cannot serve 5,000 shoppers, however far it scales.
  • Retry storms and bots still win.
  • One slow dependency takes the whole site down.

A waiting room in front of the picker

Shoppers who arrive before 09:00 get a random place in a queue; later arrivals join the end.

The room lets a fixed number per minute through, each with a signed, short-lived token the slot API checks.

Commercial waiting rooms, such as Queue-it or Cloudflare Waiting Room, can run this check at the CDN edge.

Pros

  • Load on the slot service is a number you choose.
  • Fair: a random order for early arrivals removes the reward for refreshing at 08:59:59.
  • Shoppers see a position and an estimate instead of errors.

Cons

  • Waiting is frustrating, and an estimate that grows feels worse than none.
  • Must be switched off on normal days and tested before the big one.

Staggered release

Open slots in waves: members first, then everyone; or one week, region, or day at a time.

Announce each wave's time in the picker itself.

Pros

  • Spreads one huge spike into several smaller ones.
  • Rewards loyalty in a way the business wants anyway.

Cons

  • Each wave is still a spike.
  • More rules to explain to shoppers.

What we pick: Combine them: staggered waves to cut the size of each spike, and a waiting room switched on only for the release windows. Underneath both, make the read path cheap with edge caching. None of this replaces the server's capacity check; it only makes sure the people reaching it are real and arrive at a rate the system can serve.

Release morning. Shoppers, about 200,000 at 09:00, arrive at a waiting room, which lets a fixed number per minute through with a token. Grid reads go to the edge CDN, which caches the public zone grid for 5 seconds and passes only misses to the Slot API. Bookings carry the token and skip the cache, going straight to the Slot API, which checks capacity and makes holds. Notes: early arrivals get a random place; the public grid is cached with s-maxage 5 and personal data is no-store; clients add jitter, follow pollMs and obey Retry-After.Shoppers200k at 09:00Waiting roomN per minuteEdge CDNgrid, 5 sSlot APIchecks, holdsarrivetokenmissesbookings, no cacherandom orderfor early arrivalspublic: s-maxage=5personal: no-storejitter, pollMs,Retry-After
Each layer cuts the crowd before the next one. The waiting room sets the rate, the edge turns thousands of polls into one request per zone every few seconds, and only bookings and personal reads reach the origin, where the real capacity check lives.

Edge caching does most of the work for reads. The zone grid carries no personal data, so it can be served as Cache-Control: public, s-maxage=5, stale-while-revalidate=10: the CDN serves one copy per zone for 5 seconds and refreshes it in the background. With 2,000 zones behind a shield cache (one shared layer that every edge location asks before the origin), the origin sees about 400 grid requests a second for each fulfilment type, however many shoppers poll.

The personal eligibility call is private, no-store, much smaller, and refetched only when the basket changes, not on every poll. The cdn-caching lesson covers these headers; the point here is that the split from the slot model chapter is what makes caching possible.

The client has its own duties on release day. Before 09:00, the picker shows 'Christmas slots open at 09:00' instead of an empty grid, and does not poll. Polls carry random jitter of a few seconds so 100,000 timers do not fire on the same tick, and the response's pollMs lets the server slow everyone down from one place.

A 429 or 503 with Retry-After is obeyed, with exponential backoff and jitter after that (see the retries-and-backoff lesson). The shopper sees 'Busy, retrying in 8 seconds' instead of an error page.

Check your understanding · Exercise 10

On release morning, which client and edge behaviours reduce load without hurting fairness? Select all that apply.

Architecture, API, and data flow

With the decisions made, the parts fall out of them. The guiding rule is that the server owns every fact (capacity, fit, price, holds, time), and the client owns only how those facts are shown and what the shopper is doing right now.

PartResponsibilityWhy it is separate
SlotPicker pageReads type and day from the URL, picks the wide or phone layout, wires the parts together.One place that knows about routing and layout.
useZoneGridPolls the public zone grid through the CDN, pauses when hidden, obeys pollMs.Cacheable and shared by everyone in the zone.
useEligibilityFetches fitsBasket, personal prices, and nextAvailable, keyed by basket id and version.Personal and uncacheable; refetches when the basket changes.
useBasketThe basket with its booking: the only source of truth for the hold.Shared with the header chip, basket page, and checkout.
selectSlotViewPure function merging grid, eligibility, booking, and server clock into cells to render.Easy to unit test, including clock change days.
SlotGrid and DayTabsThe two layouts, rendering the same cells; keyboard handling lives here.Layout and accessibility differ; data does not.
HoldBannerCountdown, the 10-minute warning, Extend.Visible on every page while a hold exists.
bookingClientReserve, swap, extend, with idempotency keys and backoff.All write rules in one module.
AnnouncerOne polite live region and one alert region for the whole picker.Avoids several regions talking over each other.
Architecture. In the browser, top row: SlotGrid, an ARIA grid for wide screens; DayTabs, radio groups for phones; HoldBanner with the countdown and Extend. Middle row: selectSlotView, which merges and applies precedence and feeds both layouts; useBasket, which holds the booking and feeds selectSlotView and the HoldBanner; the Announcer with the live regions. Bottom row: useZoneGrid, polling the public grid, and useEligibility, per basket version, both feeding selectSlotView; bookingClient, which reserves and extends with idempotency keys, writes its results into useBasket and speaks through the Announcer. On the server side: useZoneGrid polls the Edge CDN with an ETag, useEligibility calls the Slots API with the basket id, and bookingClient posts to the Bookings API with an idempotency key.BrowserSlotGridwide: ARIA gridDayTabsphone: radiosHoldBannercountdown, extendselectSlotViewmerge, precedenceuseBasketthe bookingAnnouncerlive regionsuseZoneGridpoll, publicuseEligibilityper basketbookingClientreserve, extend, keysEdge and serverEdge CDNzone grid, 5 sSlots APIfit, pricesBookings APIholds, keysETagbasket idPOST, key
Reads and writes take different roads. The two layouts are thin views over one selectSlotView; every write goes through bookingClient and lands in the basket, which is the single source of truth for the hold.

Data moves in two loops. The read loop runs every poll: the CDN returns the zone grid (often a 304), selectSlotView merges it with the cached eligibility answer and the booking, and only the cells whose values changed re-render. Eligibility joins the loop only when the basket version changes or a booking is refused.

The write loop runs when the shopper acts: bookingClient posts the reservation, and the response (or the 409 body) is written straight into the basket and grid caches. The banner and the cell update, the announcer speaks once, and a BroadcastChannel message tells other tabs to refetch.

Data flow. Read loop: the slot service sends the grid JSON to the CDN, the CDN answers the browser's poll with 200 or 304 into the query cache, the slot service sends the uncached fit answer straight to the query cache when the basket changes, and the query cache, holding the zone grid plus the fit answer, feeds selectSlotView, which gives cell props to the cells. Write loop: a cell sends Reserve with a key to the booking client, the booking client sends POST to the Bookings API and gets 201 or 409, writes the booking into the basket cache, and the basket cache feeds the hold into selectSlotView. A note says a BroadcastChannel message makes other tabs refetch.Slot serviceoriginCDN5 s copyQuery cachegrid + fitselectSlotViewmergegrid200 or 304zone + fitfit, no-storeBookings APIholdsbookingClientkey, retryBasket cachebookingCellsgrid or tabscell propsReserve + keyPOST: 201 or 409holdBroadcastChannel: other tabs refetch
Two loops meet in selectSlotView. A poll that returns 304 costs a few hundred bytes and no re-render; a reserve updates the basket once, and every place that shows the hold reads it from there.

Think first

Why is selectSlotView a pure function rather than logic inside the SlotGrid component?

The API and where each piece of data lives

The API follows the split we chose: a public, cacheable read; a personal read; and a small set of writes that are safe to retry. The rest-api-design lesson covers naming and status codes in general; these are the shapes this picker needs.

http
GET /pools?postcode=E14%205AB&type=delivery
200 { "poolId": "lon-e14", "timeZone": "Europe/London", "store": "Canary Wharf" }

GET /pools/lon-e14/slots?type=delivery&from=2026-12-14&days=21
Cache-Control: public, s-maxage=5, stale-while-revalidate=10
ETag: "g-48121"
200 { "serverTime": "2026-12-13T19:02:11Z", "pollMs": 20000,
      "slots": [{ "id": "lon-e14:delivery:2026-12-19:10:00",
                  "date": "2026-12-19", "start": "2026-12-19T10:00:00+00:00",
                  "end": "2026-12-19T11:00:00+00:00", "status": "limited",
                  "remainingHint": 2, "priceBand": "peak",
                  "cutoffAt": "2026-12-18T23:45:00+00:00" }] }

GET /baskets/b_77/slot-eligibility?pool=lon-e14&type=delivery
Cache-Control: private, no-store
200 { "basketVersion": 14, "nextAvailable": "lon-e14:delivery:2026-12-17:18:00",
      "slots": { "lon-e14:delivery:2026-12-19:10:00":
                 { "fits": false, "reason": "weight", "price": 450 } } }
Reads. The first is public and edge-cached; the second is personal and never cached.
http
POST /bookings
Idempotency-Key: 6f1c0e2a-...
{ "basketId": "b_77", "slotId": "lon-e14:delivery:2026-12-19:11:00", "replaces": "bk_123" }
201 { "id": "bk_124", "state": "held", "expiresAt": "2026-12-13T21:02:30Z",
      "extensionsLeft": 1, "fits": true, "price": { "amount": 450, "currency": "GBP" } }
409 { "code": "SLOT_FULL",
      "slot": { "id": "...:11:00", "status": "full" },
      "alternatives": ["...:12:00", "...:2026-12-20:09:00"] }

POST /bookings/bk_124/extend          -> 200 Booking | 409 { "code": "NO_EXTENSIONS_LEFT" }
DELETE /bookings/bk_124                -> 204
Writes. Every reserve carries an idempotency key; a conflict carries the way out.

Prices are in minor units (450 means £4.50) so nothing is lost to floating point. Conflict codes are a closed list the client switches on: SLOT_FULL, NO_FIT, BOOKING_CHANGED (another tab replaced it), CUTOFF_PASSED, and NO_EXTENSIONS_LEFT. Each maps to one message and one recovery action. Unknown codes fall back to a generic message plus a grid refetch, so the server can add codes without breaking old clients.

DataWhere it livesWhy there
Fulfilment type, selected day or weekURL query stringBack, refresh, and shared links keep the view.
Zone gridServer-state cache, key pool and typePolled, shared by both layouts, kept during refetch.
EligibilityServer-state cache, key basket id and versionA new basket version is a new key, so stale fit answers are never reused.
BookingInside the basket queryOne source of truth on every page and device.
Focused cell, pending reserve, open bannerComponent stateBelongs to this screen only.
Server clock offsetA small module value from serverTimeUsed by the countdown and cut-off closing.

As a reusable component, the picker takes basketId, fulfilment, and an optional initialDay as props, and emits onReserved(booking) and onHoldLost(reason) so the page around it can update the header or route to the basket. It does not take slots as a prop: fetching, polling, and the merge are its job, so every page that embeds it gets the same honest behaviour.

Check your understanding · Exercise 11

Why is eligibility keyed by basket id and basket version in the client cache? Select all that apply.

Measuring the picker

A few events answer the questions the business will ask: slot_grid_viewed with the zone and the days scrolled into view (one event per day shown, not one per cell); slot_reserve_result with held, conflict, no-fit, or unknown; and hold_extended, hold_expired, and hold_confirmed at checkout. Batch them and send them with navigator.sendBeacon when the page is hidden, so leaving the page does not lose them.

The conflict rate per zone and window shows planners where demand outruns vans. A high expiry rate says holds are too long or checkout is too slow.

Running it in the interview

Forty-five minutes is enough if you spend it on the parts that are special to slots and wave through the parts other guides cover. A plan that works:

MinutesWhat to doWhat to say out loud
0 to 5Scope questions: address-dependent, book before or at checkout, prices, peak days, devices.'A slot is capacity, not a seat, and it depends on my basket.'
5 to 10Draw the wireframe: wide grid and phone tabs, with price and status in words.'Same data, two layouts; status never by colour alone.'
10 to 20Slot model and eligibility: zone grid plus personal fit, server decides.'The client never does capacity maths.'
20 to 30Freshness and the hold: polling with edge cache, long hold on the basket, no optimistic 'Reserved'.'The grid is a hint; the reservation is the check.'
30 to 40Failures and peak: 409 with alternatives, idempotency key, waiting room, jitter.'A lost reply is unknown, not failed.'
40 to 45Accessibility and time: grid keys, milestone announcements, extend before expiry, store time zone.'WCAG 2.2.1 applies to the hold.'

Draw the wireframe first, then the architecture with the two read paths and the one write path. Leave the state machine of the hold for when the interviewer asks about expiry. If time is short, the two ideas worth protecting are the server-decided fit and the idempotent reservation: they are what separates this from a calendar.

Think first

Follow-up: 'Product wants to add a 30-minute express option next to the slots.' What changes?

Think first

Follow-up: 'Can shoppers ask to be told when a full Saturday slot frees up?'

Check your understanding · Exercise 12

You have 5 minutes left and have not discussed failures. Which points give the most value? Select all that apply.

Summary

  • A delivery slot sells a share of van and picker capacity in a window, not a seat. Capacity has several limits at once, so the server decides whether this basket fits; the client never does capacity maths.
  • Split the grid into a public zone view that can be cached at the edge for a few seconds and a small personal eligibility call keyed by basket version.
  • Poll while the picker is visible, with conditional requests and jitter; update cells in place; show 'Only 2 left' only from the server's hint; never paint 'Reserved' before the server says so.
  • The hold belongs to the basket and account, lasts an hour or two, can be extended, survives devices, and can break if the basket grows. Changing slot is an atomic swap.
  • Slots are store wall-clock times: format with the slot's time zone, group by local date, and never build days with millisecond maths across clock changes.
  • Use an ARIA grid on wide screens and a radio group per day on phones; selecting never reserves; name each cell fully; announce only milestones; offer to extend before the hold expires (WCAG 2.2.1).
  • On release day, stagger the waves, put a waiting room in front, cache public reads, and obey Retry-After. A 409 carries alternatives, and an idempotency key makes a lost reply safe to retry.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next