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.
| Question | Answer we assume | What 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.
- 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.
- 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.
- 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.
- 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.
- 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.
fitsBasket decides whether this shopper can book.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
};
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?
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?
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.
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?
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.
409 is a normal, designed path.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,
});
}
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?
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
| Aspect | Seat map | Grocery slot |
|---|---|---|
| What is held | One exact seat | A share of van and picker capacity in a window |
| Hold length | About 10 minutes | About 1 to 2 hours, extendable (retailer setting) |
| Owned by | The checkout session | The basket and account, on any device |
| After payment | Final | Can be changed until a cut-off, often the night before |
| Can break while held | Rarely | Yes: 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.
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.
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...
};
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.
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.
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
}
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 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.
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)
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.
- 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 (
nextAvailablein 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.
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?
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?
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.
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.
| Part | Responsibility | Why it is separate |
|---|---|---|
SlotPicker page | Reads type and day from the URL, picks the wide or phone layout, wires the parts together. | One place that knows about routing and layout. |
useZoneGrid | Polls the public zone grid through the CDN, pauses when hidden, obeys pollMs. | Cacheable and shared by everyone in the zone. |
useEligibility | Fetches fitsBasket, personal prices, and nextAvailable, keyed by basket id and version. | Personal and uncacheable; refetches when the basket changes. |
useBasket | The basket with its booking: the only source of truth for the hold. | Shared with the header chip, basket page, and checkout. |
selectSlotView | Pure function merging grid, eligibility, booking, and server clock into cells to render. | Easy to unit test, including clock change days. |
SlotGrid and DayTabs | The two layouts, rendering the same cells; keyboard handling lives here. | Layout and accessibility differ; data does not. |
HoldBanner | Countdown, the 10-minute warning, Extend. | Visible on every page while a hold exists. |
bookingClient | Reserve, swap, extend, with idempotency keys and backoff. | All write rules in one module. |
Announcer | One polite live region and one alert region for the whole picker. | Avoids several regions talking over each other. |
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.
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?
Both layouts need exactly the same answers: which cells are open, closed, full, too small, held by me, or pending. Computing them once keeps the phone and the laptop in agreement.
It can be tested with fixed inputs, including the October clock change, a basket that stopped fitting, and a 409 that just arrived, without rendering anything.
It also keeps the precedence rules in one place: my own hold beats everything, then 'closed', then 'full', then 'too big for this basket', and between the grid and eligibility the stricter status wins.
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.
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 } } }
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
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.
| Data | Where it lives | Why there |
|---|---|---|
| Fulfilment type, selected day or week | URL query string | Back, refresh, and shared links keep the view. |
| Zone grid | Server-state cache, key pool and type | Polled, shared by both layouts, kept during refetch. |
| Eligibility | Server-state cache, key basket id and version | A new basket version is a new key, so stale fit answers are never reused. |
| Booking | Inside the basket query | One source of truth on every page and device. |
| Focused cell, pending reserve, open banner | Component state | Belongs to this screen only. |
| Server clock offset | A small module value from serverTime | Used 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:
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Scope 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 10 | Draw the wireframe: wide grid and phone tabs, with price and status in words. | 'Same data, two layouts; status never by colour alone.' |
| 10 to 20 | Slot model and eligibility: zone grid plus personal fit, server decides. | 'The client never does capacity maths.' |
| 20 to 30 | Freshness 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 40 | Failures and peak: 409 with alternatives, idempotency key, waiting room, jitter. | 'A lost reply is unknown, not failed.' |
| 40 to 45 | Accessibility 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?
Express is on-demand delivery, a different capacity system with a live ETA instead of a window, as the food-delivery guide describes. Show it as its own row or card above the grid, not as a fake slot.
It has no hold: availability is checked at checkout. Its price and ETA change minute to minute, so it polls more often than the grid, and it is never cached at the edge.
Think first
Follow-up: 'Can shoppers ask to be told when a full Saturday slot frees up?'
Yes, as a server-side waitlist: POST /slot-alerts with the slot id and basket, and a push or email when a cancellation frees space that fits this basket. Do not keep a tab polling a full slot.
Notify, do not auto-book: the space may suit several waiting shoppers, and the first to reserve gets it through the same 409-safe path. Be honest in the UI: 'We will tell you if it frees up; it may not.'
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.
References
- WCAG 2.2: Understanding Success Criterion 2.2.1 Timing Adjustable Warn before a time limit ends and let the user extend it.
- WAI-ARIA Authoring Practices: Grid pattern Keyboard model for the wide slot table.
- WAI-ARIA Authoring Practices: Radio group pattern Keyboard model for one day's windows on a phone.
- WCAG 2.2: Understanding Success Criterion 2.5.8 Target Size (Minimum) The 24 by 24 pixel minimum for slot cells.
- MDN: Intl.DateTimeFormat The timeZone option and formatRange.
- MDN: Temporal Safer date arithmetic and its browser support table.
- MDN: Cache-Control s-maxage, stale-while-revalidate, private, no-store.
- MDN: 409 Conflict The status for a slot taken by someone else.
- IETF draft: The Idempotency-Key HTTP header field Making POST retries safe.
Concepts behind it
- Short pollingThe browser asks the server "anything new?" on a timer: the simplest way to keep a page fresh, and the right answer more often than people think.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Syncing state across tabsEvery open tab of your app is a separate program with its own memory, so a change in one tab is invisible to the others until you send it across with the storage event, BroadcastChannel, a SharedWorker, or a leader tab chosen with Web Locks.