cf.completefrontend
My progress
SYSTEM DESIGN · #95

Design a food delivery app

The customer web app of a service like DoorDash, Uber Eats, Deliveroo, Swiggy, or Zomato: set a delivery address, browse nearby restaurants, build a cart from a menu with item options, place the order, and follow it until it arrives. The hard parts are an address that every other piece of data depends on, menus and prices that change under the customer, a cart the server prices, an order that must be placed exactly once, and a live status that stays calm even when events arrive late or twice.

The question

Design the customer web app for a food delivery service like DoorDash or Uber Eats. A customer sets where the food should go, browses the restaurants that deliver there, opens a menu, picks items with their options (size, toppings, sauces), checks out, and then follows the order live until it is at the door.

Most customers are on mid-range phones, often on a weak mobile network, and often hungry and impatient. Walk me through how you handle the delivery address, the restaurant list and menus, the cart and checkout, and the live order status.

Real-life examples: DoorDash web ordering, Uber Eats on the web, Deliveroo, Swiggy and Zomato mobile web

Requirements: what to ask before drawing anything

A food delivery app looks like a shop with a map, but it is really three apps in a row. First a local marketplace: what you can buy depends on where you are and what time it is. Then a configurable shop: a pizza is not one product but a size, a crust, up to three toppings, and a sauce, each with its own price.

Then a live tracker: for 30 to 50 minutes the customer watches a restaurant cook and a courier ride, and every update is driven by other people.

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

QuestionAnswer we assumeWhat it changes
Customer, restaurant, or courier app?Customer only. Restaurants use a tablet app, couriers a native app; both are out of scope.We consume order events from both sides; we never produce them.
Which steps are in scope?Address, restaurant list, menu, item options, cart, checkout, live tracking, and problems after delivery.Four main screens and one long order state machine.
Can a cart hold items from two restaurants?No. One cart, one restaurant, the default on most services.A conflict dialog when the customer adds from a second restaurant.
Who computes prices and fees?The server: subtotal, delivery fee, service fee, small-order fee, taxes, promos.The client shows a breakdown it never computes as the final truth.
Can the customer use several devices?Yes. Build a cart on a laptop at work, pay on the phone.The cart lives on the server, not only in local storage.
Ordering for later?Yes, in time slots up to about a week ahead, as DoorDash and Uber Eats allow.Opening hours and slots depend on the restaurant's time zone.
Which devices and networks?Mobile web first: a mid-range Android phone on a 4G network that drops to 3G speeds or loses signal in a lift.A tight JavaScript budget, lazy images, and real-time updates that survive drops.
How many markets?Many countries: several currencies, languages, and time zones.Prices formatted with Intl.NumberFormat, hours with the restaurant's IANA time zone.

Functional requirements

  • Set a delivery address from the device location, a typed address with suggestions, or a saved address (Home, Work).
  • See restaurants that deliver to that address, ranked, with open or closed state, delivery fee, and an ETA for each. Filter by cuisine, price, rating, and dietary needs.
  • Open a menu with categories, see items with photos and prices, and configure an item: required choices, optional extras, nested options.
  • Keep one cart per customer, from one restaurant, with a price breakdown the server computes, a minimum order, and tips.
  • Place the order now or for a later time slot, and pay with a saved method.
  • Follow the order live: accepted, preparing, courier assigned, picked up, arriving, delivered. Get a push notification when the app is closed.
  • Handle problems: approve a substitution, report a missing item, chat with the courier, and reorder a past order.

Non-functional requirements, with numbers

  • Fast first screen: the restaurant list shows real content (Largest Contentful Paint, LCP) within 2.5 seconds on a mid-range phone on 4G, the web.dev threshold for a good LCP.
  • Responsive taps: adding an item or opening the options sheet responds within 200 ms (Interaction to Next Paint, INP, the good threshold on web.dev).
  • Small bundles: about 150 KB of compressed JavaScript for the first route; the map and chat load only on the tracking screen.
  • Correct money: the price the customer confirms is the price charged, and one tap on Place order creates at most one order, even with retries.
  • Live but calm: status changes show within a few seconds, and the ETA does not jump up and down.
  • Peaks: assume lunch and dinner bring about three times the average traffic, with many people opening the same popular restaurants at once.
  • Accessible and local: every form works with a keyboard and a screen reader; prices, times, and units follow the customer's locale.

Out of scope, and why

  • The restaurant tablet and courier apps: other teams, other platforms.
  • Dispatch and ranking algorithms: the server decides; we only show the result and design the API around it.
  • Payment forms and card checks: the amazon guide covers checkout and payment in depth, so we only link to it.
  • The live courier map in detail: the uber guide covers smooth markers between sparse GPS updates.
  • Search with free text and facets in depth: the search-results-page guide covers it; here we use a small set of filters.

Check your understanding · Exercise 1

Which answers from the interviewer would change the client design the most? Select all that apply.

What makes this hard

Before picking any tools, name the traits of the problem. Each one drives a later chapter, and saying them out loud early shows the interviewer you see past the screens.

  • Everything is local. The same restaurant has a different delivery fee, ETA, and sometimes a different menu for two addresses 3 km apart. The address is not a setting; it is a key in almost every request.
  • Everything is timed. A restaurant is open, closed, or opening at 17:00 in its own time zone. Lunch menus disappear at 15:00. A quote for fees changes when a courier shortage hits at 19:30. Data that was true when the page loaded can be false five minutes later.
  • Items are small forms. A burger with a required patty choice, up to three extras, and a sauce that has its own spice level is a nested form with rules and price deltas. Get it wrong and the kitchen cooks the wrong food.
  • Money must match. The customer sees a subtotal, three fees, a tax, a promo, and a tip. The number on the Place order button must equal what the card is charged, and the order must be created once.
  • The last 40 minutes are live. After paying, the customer watches events from a restaurant tablet, a dispatch system, and a courier's phone. They arrive late, twice, or out of order, and the customer is staring at the screen the whole time.
  • The device is weak and the network flaky. People order from the sofa, the office, or the bus. Image-heavy lists and menus must stay fast on a mid-range phone, and the tracker must survive the phone sleeping.

The figure follows one order through the app. Notice how the kind of data changes along the way: the first part is mostly read-heavy and cacheable, the middle is a handful of writes that must be exact, and the end is a stream of server events.

One order through a food delivery app, in three bands. Browse, cached reads keyed by the address: Address, then Restaurant list, then Menu. Money, a few exact writes: Cart, then Checkout, then Place order. Live, a stream of events for 30 to 50 minutes: Status, then ETA, then Problems such as substitutions and chat.browse: cached reads, keyed by the addressAddresssaved or locatedRestaurant listranked per cellMenutwo layersmoney: a few exact writesCartserver pricedCheckoutre-validatePlace orderidempotentlive: server eventsStatusstate machineETAsmoothed rangeProblemssubstitutions, chat
The data changes kind along the journey: cacheable reads first, then a few writes that must be exact, then server events. Each band gets its own tools.

This split suggests the shape of the answer, and the rest of the guide takes the parts in order. The browse part wants caching keyed by location, lazy images, and small bundles. The money part wants the server as the single source of truth and idempotent writes (writes that are safe to send twice). The tracking part wants a state machine on the client that accepts only valid moves, fed by a real-time channel with fallbacks.

Think first

If you had only 10 minutes for deep dives in the interview, which two topics would you pick, and why?

Check your understanding · Exercise 2

Which of these are true differences between a food delivery app and a normal online shop? Select all that apply.

Location first: the delivery address is the root of all state

The naive design treats the address as a field you fill in at checkout, like in a normal shop. That breaks at the first screen: the restaurant list cannot be built without a location, because which restaurants deliver, what they charge, and how long they take all depend on it. So the address comes first, and every piece of browse data is keyed by it.

Call the pair of address and time the delivery context: where the food goes and when (now, or a scheduled slot). The restaurant list, each restaurant's fee and ETA, which menu is active, and which items are available are all functions of the delivery context. When it changes, they all may change.

Getting the first address

There are three sources. The Geolocation API (navigator.geolocation.getCurrentPosition) gives coordinates, but it needs a permission prompt, a secure context (HTTPS), and it gives a point, not a flat number or a gate code. A typed address with suggestions is precise but slow on a phone keyboard. A saved address (Home, Work) is instant but only exists for returning customers.

Decision How should the app pick the delivery address when the customer opens it?

Ask for geolocation on page load

Call getCurrentPosition as soon as the app boots and reverse-geocode the point to an address.

Pros

  • Zero typing when the customer says yes.
  • Often accurate to tens of metres outdoors.

Cons

  • A permission prompt with no context is often denied, and a denial is remembered for the site.
  • Browsers may stop showing a prompt for a while after the customer dismisses it several times.
  • A point in a 20-storey building is not a deliverable address.

Always make the customer type an address first

A full-screen address form with suggestions before anything else is shown.

Pros

  • Precise, includes the flat number and notes.
  • No permission prompt.

Cons

  • A wall before the customer has seen a single restaurant; many leave.
  • Slow and error-prone on a phone keyboard.

Layered: saved, then approximate, then precise on demand

Use the default saved address if signed in. Otherwise browse with an approximate area (the last used address, or a city-level guess from the server).

Offer a Use my location button that calls geolocation only on that tap, plus typed suggestions.

Require a full, precise address (with flat number) before checkout.

Pros

  • Content on the first screen for everyone.
  • The permission prompt appears after a tap, when the customer understands why.
  • Precision is asked for when it matters, at checkout.

Cons

  • Fees and ETAs shown for an approximate area can change once the precise address is set, so the UI must say they are estimates.

What we pick: Pick the layered approach. Browsing needs only a rough location, checkout needs a precise one, and the permission prompt converts better when it follows a tap on a clearly labelled button. Check navigator.permissions.query({ name: "geolocation" }) first: if it is already granted, use the location without asking; if denied, hide the button and show typed search instead.

What depends on the address, and what happens when it changes

DataDepends onOn address change
Restaurant list and rankingAddress cell, timeRefetch for the new cell; keep the old list visible but dimmed until the new one arrives.
Delivery fee and ETA per restaurantExact address, courier supplyRefetch; never reuse old numbers.
Which menu is activeTime (lunch, dinner), sometimes store branchUsually unchanged for the same restaurant; refetch if the branch changes.
Item availabilityBranch stockRefetch if the branch changes.
Cart prices and feesAddress, items, promosServer re-prices the cart; the client shows a diff of what changed.
PromotionsZone, customerRevalidate; a promo may not apply in the new zone.
The delivery context, the address plus the time, sits at the root. Six things depend on it, each with what happens when it changes: the restaurant list is refetched per cell; fees and ETAs are refetched for the exact address; the active menu stays the same unless the time changes; item availability is rechecked for the branch; cart prices are re-priced by the server; promotions are revalidated for the zone.Delivery contextaddress + timeRestaurant listrefetch per cellFees and ETAsrefetch, exactActive menusame unless timeItem availabilityrecheck branchCart pricesserver re-pricesPromotionsrevalidate zone
Nothing on the browse screens is independent of the address. Putting the delivery context in every query key turns this whole fan-out into automatic cache misses instead of hand-written invalidation.

The simplest way to make the invalidation automatic is to put the delivery context in every query key. With a server-state library such as TanStack Query, a key like the one below means that changing the address creates new cache entries instead of mutating old ones. Old entries stay cached, so switching back from Work to Home is instant.

ts
type DeliveryContext = {
  addressId: string | null; // null while browsing with an approximate area
  cell: string;             // coarse geohash of the point, for caching
  lat: number;
  lng: number;
  when: "asap" | { slotStart: string }; // ISO time in the restaurant's zone
};

const ctxKey = (c: DeliveryContext) =>
  [c.cell, c.addressId ?? "approx", c.when === "asap" ? "asap" : c.when.slotStart] as const;

export const keys = {
  feed: (c: DeliveryContext, filters: string) => ["feed", ...ctxKey(c), filters] as const,
  store: (c: DeliveryContext, storeId: string) => ["store", storeId, ...ctxKey(c)] as const,
};
The delivery context is part of every browse query key.

Changing the address with a full cart

The tricky case: the customer has three items from a pizza place, then switches from Home to Work, 9 km away. The pizza place may not deliver there. Silently emptying the cart is rude; silently keeping it leads to a failed checkout.

The fix is to ask the server first. The client sends the new address to a preview endpoint, and the server answers whether the cart's restaurant delivers there and what the new fees would be. Only then does the client commit the change or show a choice.

Sequence diagram for changing the address with a full cart, between the address sheet, the context store, and the server. The customer picks Work. The store posts a preview of the new address. The server replies that Pizza Place does not deliver there. The store shows a choice dialog. The customer chooses to switch and save the cart as a draft. The store sends the new address and moves the cart to drafts. The server replies with the new context and an empty cart. The store re-keys its queries and the list refetches.Address sheetContext storeServerpick WorkPOST preview-addressnot deliverableshow choice dialogswitch, keep cart as draftPUT address, cart to draftnew context, empty cartre-key, refetch list
The failure is found before anything changes: the preview call asks the server, and only the customer's choice commits. Because queries are keyed by the context, the commit alone makes the list refetch.

Check your understanding · Exercise 3

A customer with a cart switches the address to one the cart's restaurant does not deliver to. What should the app do? Select all that apply.

The restaurant list: ranked by the server, priced per address

The home screen is a ranked list of restaurants, often with a few rows on top (Fastest near you, Deals, Order again). Each card shows a photo, the name, the cuisine, a rating, a price level, the delivery fee, the ETA, and an open or closed badge.

The naive design downloads every restaurant in the city and sorts and filters on the client. A large city can have several thousand restaurants; a few hundred deliver to any one address. Sorting on the client means shipping all of them, and the client still cannot rank well: the ranking mixes distance, predicted ETA, courier supply, the customer's history, and paid promotions, which only the server knows. So the server ranks, and the client shows a page at a time.

Caching a list that depends on location

Two customers in the same block see almost the same list; two customers 5 km apart do not. Caching by exact coordinates shares nothing, and caching by city is wrong. The usual middle is to round the location to a cell, such as a geohash: a short string where each extra character narrows the area. Near the equator a 5-character geohash covers about 4.9 by 4.9 km and a 6-character one about 1.2 by 0.6 km; cells get narrower east to west further from the equator.

Geohash is not the only grid. Uber built and open-sourced H3, a grid of hexagons at 16 resolutions, for its pricing and dispatch analysis; a resolution 8 hexagon averages about 0.74 square km. Hexagons have the same distance from the centre to every neighbour, which helps when you also want nearby cells. For a cache key, either works: what matters is that nearby customers round to the same short string.

Decision How should the restaurant list be cached, given it depends on the address?

No shared cache, one request per exact address

The server ranks and prices everything for the exact point on every request, with Cache-Control: private, no-store.

Pros

  • Always exact.
  • Simple to reason about.

Cons

  • Every open at 19:00 hits the ranking service; nothing is shared at the CDN.
  • Slow first screen on weak networks because nothing is ever warm.

Cache the whole list per cell

Round the point to a 6-character geohash and cache the full list, fees included, at the CDN for 60 seconds.

Pros

  • Most requests at peak are served from the edge.
  • One request for the first screen.

Cons

  • Fees and ETAs for the cell centre can be wrong for an address at the cell edge.
  • Personal rows such as Order again cannot be shared.

Split: shared list per cell, personal numbers per address

The list and ranking for the cell come from a CDN-cacheable response (s-maxage=60, stale-while-revalidate=30).

Fee, ETA, and personal rows for the visible restaurant ids come from a small private request keyed by the exact address.

Pros

  • The heavy part (names, photos, ranking) is shared and fast.
  • The money part (fees, ETAs) is exact for the address.
  • Each part has its own freshness.

Cons

  • Two requests and a merge on the client.
  • Fee and ETA slots need a skeleton for a moment.

What we pick: Pick the split. The shared part is about 90 percent of the bytes and changes slowly; the personal part is small and must be exact because it is a price. The quotes request goes out as soon as the first page of the list arrives, for the store ids on it. Because the list usually comes from a nearby CDN edge, the second request adds little, and the CDN still serves the shared part at peak.

A three by three grid of geohash cells. Customers A and B are in the same cell, and customer C is in another. A and B share one CDN entry for the cell's ranked list, with s-maxage 60. Every customer gets a private quotes response with the fee and ETA for their exact address. C's cell misses the cache once, then is warm for its block.A BC6-character geohash cellsabout 1.2 by 0.6 kmCDN: list for the cells-maxage=60, shared by A and BQuotes: fee and ETAprivate, one per addressC's cell misses the cache once,then stays warm for its block
The heavy, shared part (names, photos, ranking) is cached per cell with s-maxage=60. The small, personal part (fee and ETA) is fetched per exact address and never shared.

Open, closed, and opening soon, in the restaurant's time

Opening hours belong to the restaurant's time zone, not the customer's. A customer in Lisbon ordering for a parent in London sees London hours. The server sends hours as local times plus an IANA zone name such as Europe/London, and also a computed status for the moment of the response. The client recomputes the status every minute so that a page left open shows Open at 17:00 without a reload.

ts
type Hours = { day: number; open: string; close: string }[]; // "HH:MM", day 0 = Sunday

function storeStatus(hours: Hours, timeZone: string, now = new Date()) {
  const parts = new Intl.DateTimeFormat("en-GB", {
    timeZone, weekday: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23",
  }).formatToParts(now);
  const get = (t: string) => parts.find((p) => p.type === t)!.value;
  const day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(get("weekday"));
  const time = `${get("hour")}:${get("minute")}`;
  const today = hours.filter((h) => h.day === day);
  if (today.some((h) => h.open <= time && time < h.close)) return { kind: "open" } as const;
  const next = today.find((h) => h.open > time);
  if (next) return { kind: "opens-later", at: next.open } as const;
  return { kind: "closed" } as const;
}
Status from weekly hours, computed in the restaurant's zone. Overnight hours (22:00 to 02:00) would need one more check.

Closed restaurants stay in the list but sink below open ones, with a Schedule button instead of a fee. Opening soon (within 30 minutes) is its own state, because many customers will wait for a favourite. Busy kitchens get a fourth state from the server, such as Paused, which the client cannot compute and must take as given.

Filters and images

Filters (cuisine, price level, rating 4.5 and up, vegetarian, halal, gluten free) live in the URL so a filtered list can be shared and survives a reload, and they run on the server, which owns the ranking. The search-results-page guide covers filter state, counts, and races in depth; nothing here is different except that the delivery context joins the filters in the query key.

Cards are photos first. Give each image a fixed aspect ratio box so the layout does not shift as photos load (Cumulative Layout Shift, CLS). Load the first two or three card photos eagerly, mark the first one fetchpriority="high" because it is often the LCP element, and give the rest loading="lazy".

Serve the photos through an image CDN with srcset widths of about 320, 480, and 640 pixels in AVIF or WebP. The image-optimization lesson explains each step; for an image-heavy list like this one, they often cut the first screen's image bytes several times over.

Check your understanding · Exercise 4

Why should the delivery fee not come from the CDN-cached list for the cell? Select all that apply.

The cart: one restaurant, priced by the server, on every device

The amazon and shopping-cart guides cover the general cart: optimistic adds, quantity steppers, and syncing across tabs. Three things are specific to food: a cart belongs to one restaurant, its fees depend on the address and the hour, and its items can stop existing while it sits there.

The one-restaurant rule

Most services default to one restaurant per order, because one courier picks up from one kitchen and the food must arrive hot. Some now let a customer add a second nearby store as an add-on with its own rules, such as DoorDash's DoubleDash or Uber Eats' multi-store orders, but the base cart still belongs to one store. So what happens when a customer with a pizza in the cart taps Add on a sushi roll?

Decision What should happen when the customer adds an item from a second restaurant?

Silently replace the cart

Clear the old cart and add the new item.

Pros

  • No interruption.

Cons

  • The customer loses a configured order without noticing, and finds out at checkout.

Ask: start a new cart?

Show a dialog: Your cart has items from Pizza Place. Start a new cart with Sushi Bar? with Cancel and New cart buttons.

Only on New cart does the server replace the cart.

Pros

  • Clear, cheap to build, matches what customers know from the big apps.

Cons

  • The old cart is gone after confirming, unless it is kept as a draft.

One saved cart per restaurant

Keep a separate cart for each restaurant the customer has added from; checkout handles one at a time.

Pros

  • Nothing is ever lost; customers can compare.

Cons

  • More state to sync and show; a list of stale carts with stale prices.
  • Customers sometimes expect both to arrive together.

What we pick: Pick the confirm dialog, and keep the replaced cart as a draft for a day so an undo is possible. It is the smallest design that never destroys work silently. Mention the per-restaurant carts as a product option; the data model below supports it by keying carts by store.

Sequence diagram for adding an item from a second restaurant, between the customer, the menu page, the cart service, and the server. The customer taps Add on a salmon roll. The menu page asks the cart service to add it for Sushi Bar. The cart service answers that another store is in the cart. The menu page shows the dialog: New cart? The customer picks New cart. The menu page asks the cart service to replace the cart. The cart service posts the replace with version 7. The server replies with cart version 8 and keeps the old cart as a draft. The cart service updates the badge to 1 and focus returns to the Add button.CustomerMenu pageCart serviceServerAdd: salmon rolladd: Sushi Barother store in cartdialog: New cart?New cartreplace(line)replace, v7v8, old as draftbadge 1, refocus
The cart already knows its store, so the dialog needs no round trip. Only the customer's confirm reaches the server, and the old cart survives as a draft for an undo.

A server cart, updated optimistically

Because the cart follows the customer across devices, it lives on the server with a version number. Each change is a small command (add line, set quantity, remove line, set tip) sent with the version the client last saw. The client applies the change optimistically, so the badge updates at once, and replaces its copy with the server's cart when the reply comes.

If the version does not match, another device changed the cart; the server answers with the current cart and the client shows it. Guests keep a cart in local storage that is merged into the server cart at sign-in. The optimistic-updates and retries-and-backoff lessons cover rollback and retry rules.

The price breakdown is the server's

The client can add up item prices, but it cannot know the rest: the delivery fee for this address and hour, the service fee percentage, the small-order fee, taxes by region, promo rules, and membership discounts. So every cart response carries a breakdown, and the client renders it line by line. While a change is in flight, the client may show its own subtotal greyed with an updating hint, but the total and the Place order button always use the server's numbers.

An example breakdown the server returns, in minor units, rendered with Intl.NumberFormat.
LineAmountWhy it is there
Subtotal1840Items with their option deltas.
Delivery fee299Distance band and current courier supply for this address.
Service fee184A percentage of the subtotal, with a floor and a cap.
Small order fee0Charged only below a threshold, such as 1000.
Promo WELCOME5-500Validated against zone, restaurant, and customer.
Tax153Computed for the region; may be included in prices in some countries.
Tip200Chosen by the customer; goes to the courier.
Total2176The number on the button and the number charged.

Minimums come in two flavours. A hard minimum blocks checkout below a subtotal; a small-order fee allows it but adds a charge. Either way, show progress rather than an error: Add 3.20 more to skip the small order fee, with a bar. The server sends the threshold and the gap so the client never hard-codes them.

Lines that become invalid while they sit in the cart

A cart can sit for an hour while the customer decides. Meanwhile the kitchen runs out of salmon, the lunch menu ends, or a price changes. The server re-checks the cart on every read and attaches issues to lines: unavailable, option_unavailable, price_changed, or store_closed for the whole cart.

The client shows each issue on its line with one clear fix (Remove, Choose another size) and disables Place order until no blocking issue remains. A price change is shown as old and new price, never applied silently.

Check your understanding · Exercise 7

Which of these should the client compute on its own, without waiting for the server? Select all that apply.

Placing the order: re-check, submit once, or schedule

Checkout is one screen: the address with flat number and drop-off notes, ASAP or a time slot, the payment method, the tip, the breakdown, and a Place order button showing the total. The payment form and card checks are covered in the amazon guide; this chapter is about the order itself.

The naive submit sends the cart's lines and the total to POST /orders. On a train, the request reaches the server, the order is created, the card is charged, and the reply is lost in a tunnel. The app shows an error, the customer taps again, and a second order is created and charged: two pizzas arrive. This classic double-submit is far more likely on mobile networks than on office Wi-Fi.

Decision How do we make sure one tap on Place order creates at most one order?

Disable the button while the request is in flight

Set a pending flag on tap and ignore further taps until a reply arrives.

Pros

  • Stops double taps.
  • Trivial to build.

Cons

  • Does nothing for a lost reply followed by a manual retry, a reload, or an automatic retry.

Check for a recent order before creating one

Before posting, ask the server whether an order from this cart was created in the last minute.

Pros

  • Catches many lost-reply cases.

Cons

  • A race: two requests can both check, both see nothing, and both create.
  • An extra round trip on every checkout.

Idempotency key on the request

The client creates a random key (crypto.randomUUID()) for this checkout attempt and sends it in an Idempotency-Key header.

The server stores the key with the result. A repeat with the same key returns the first result instead of creating a new order.

Pros

  • Safe to retry automatically or manually, even after a reload if the key is kept in sessionStorage.
  • The server enforces it atomically, so no race.

Cons

  • The server must store keys for a while (a day is common).
  • The client must reuse the same key on retry and make a new one only when the cart changes.

What we pick: Use the idempotency key, and also disable the button for a clean UI. The key is what makes retries safe; the disabled button only stops the easy case. Payment providers such as Stripe use the same Idempotency-Key header pattern, so the order service can pass its own key down to the charge. The amazon and travel-booking guides use the same key for their purchase buttons.

Re-checking prices and availability at submit

The customer saw a total of 21.76 on the button. Between opening checkout and tapping, the delivery fee rose because it started raining. So the request carries the cart version and the expected total, and the server re-validates everything: items available, store open, address in zone, slot free, promo valid, total equal to expected.

If anything differs, the server answers 409 Conflict with cart_changed and the new cart. The client shows what changed (Delivery fee is now 3.99, was 2.99) and asks for a second tap, so the customer never pays a number they did not see. A 409 can also mean the first request with this key is still running, a code the IETF Idempotency-Key draft suggests, so the client switches on the error code, not only the status.

ts
async function placeOrder(cart: Cart, expectedTotal: number) {
  const keyName = `order-key:${cart.id}:${cart.version}`;
  const key = sessionStorage.getItem(keyName) ?? crypto.randomUUID();
  sessionStorage.setItem(keyName, key);
  const body = JSON.stringify({ cartId: cart.id, cartVersion: cart.version, expectedTotal });
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const res = await fetch("/api/orders", {
        method: "POST",
        headers: { "Content-Type": "application/json", "Idempotency-Key": key },
        body,
        signal: AbortSignal.timeout(15000),
      });
      if (res.status === 409) {
        const err = await res.json();
        if (err.error === "cart_changed") return { kind: "changed", cart: err.cart } as const;
        throw new Error(err.error); // first try with this key still running: wait, same key
      }
      if (res.status >= 500) throw new Error("unclear"); // outcome unknown: retry, same key
      if (!res.ok) return { kind: "failed", status: res.status } as const;
      sessionStorage.removeItem(keyName);
      return { kind: "placed", order: await res.json() } as const;
    } catch {
      await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); // same key every time
    }
  }
  return { kind: "unknown" } as const; // check GET /orders?cartId= before telling the customer anything
}
Submit with a stable key, retry only when the outcome is unclear (network error, timeout, 5xx, or a first try still running), and a clear path for conflicts.
Sequence diagram for placing an order when the reply is lost, between checkout, the order API, and payments. Checkout posts the order with key K1. The order API charges the card with key K1, and payments reply charged. The order API's 201 reply is lost in a tunnel. Checkout retries with the same key K1. The order API replies 201 with the same order. One order, one charge.CheckoutOrder APIPaymentsPOST order, key K1charge, key K1charged201 lost in a tunnelretry: same key K1201: the same orderone order, one charge
The failure path is the normal path on mobile. Because the retry reuses Idempotency-Key: K1, the server returns the order it already made instead of making another.

After success, navigate to the tracking page with history.replaceState semantics (in a router, replace: true) so Back does not return to a checkout for a cart that no longer exists. If a card check redirects the customer to their bank, the order waits in a pending_payment state and the return URL carries the order id, so the app resumes on the tracking page, not on an empty cart.

The key protects retries from one tab, but two tabs each make their own key. The server adds a backstop: one cart version can become only one order, so a second tab's submit gets the existing order back instead of creating another.

Scheduling for later

Scheduled orders pick a slot from a list the server builds per restaurant and day: inside opening hours, after the kitchen's prep time, and with capacity left. Windows of 15 or 30 minutes are common; Uber Eats, for example, lets customers schedule up to a week ahead. Slots are shown in the restaurant's time zone; if the customer's device zone differs, label it (times in London time).

A slot can fill up while the customer decides, so the server re-checks it at submit and answers with fresh slots if it is gone. Changing from ASAP to a slot changes the delivery context, so the cart is re-priced and items on a menu that is not served at that hour are flagged.

Check your understanding · Exercise 8

The Place order request times out. What should the client do? Select all that apply.

Live tracking, part 1: the order is a state machine

After Place order, the tracking page becomes the whole app for 30 to 50 minutes. It shows a progress bar of stages, a headline (Your order is being prepared), an ETA, the courier's name and photo once assigned, a map once the courier is moving, and buttons for help and chat.

The naive design stores the latest status string the server sent and renders it. That works on a perfect network. On a real one, events arrive late, twice, or out of order: the reconnecting socket replays Preparing after Picked up already arrived, and the bar jumps backwards. Or a cancellation arrives before a stale Courier assigned, and the app shows a courier for a cancelled order.

The fix is to model the order as a state machine: a fixed set of states and the only moves allowed between them. The state-machines lesson covers the idea in general.

The order state machine. Placed moves to accepted, accepted to preparing, preparing to picked_up, picked_up to arriving, and arriving to delivered. Picked_up may also skip straight to delivered. Placed, accepted, and preparing can each move to cancelled. Delivered and cancelled are terminal. A note says the courier is a field next to the state, not a state.cancelledplacedacceptedpreparingpicked_uparrivingdeliveredallowed skipcourier: a field,not a state
Only these arrows are legal moves for a single event. An old event, such as preparing after picked_up, is dropped by its sequence number; a new event that is not a legal move makes the client fetch a snapshot instead of drawing it.
StateWho moves it hereWhat the customer sees
placedOur checkoutWaiting for the restaurant to accept, with a Cancel button.
acceptedRestaurant tabletThe restaurant has your order, and a first ETA range.
preparingRestaurant tabletBeing prepared; Cancel is gone or costs money now.
picked_upCourier appOn the way, the map, and a narrower ETA.
arrivingDispatch, by distanceArriving now; the drop-off notes are shown again.
deliveredCourier appDelivered, a photo for leave-at-door, and Rate and Report a problem.
cancelledCustomer, restaurant, or supportWhy it was cancelled and the refund status.

Notice what is not a state: courier assigned. A courier is often found while the food is still cooking, and can be reassigned if the first one drops out. So the courier is a separate field next to the state (none, assigned, at restaurant), not a step in the line. Putting it in the line forces an order between two things that happen in parallel, and the bar would have to go backwards on a reassignment.

Applying events that are late, doubled, or out of order

Every event from the server carries the order id, the new state or field, a time, and a sequence number that the server increments for each change to that order. The client keeps the last sequence it applied and follows three rules. First, if the event's sequence is not newer, drop it: it is a duplicate or a late replay.

Second, if it is newer by exactly one, apply it, but only if the state machine allows the move. Third, if there is a gap (we have 4, an event with 7 arrives), something was missed, so fetch a fresh snapshot of the order from GET /orders/:id and replace local state with it. These are the same rules the uber guide uses for a trip, where the number is called a version.

ts
const NEXT: Record<OrderState, OrderState[]> = {
  placed: ["accepted", "cancelled"],
  accepted: ["preparing", "cancelled"],
  preparing: ["picked_up", "cancelled"],
  picked_up: ["arriving", "delivered"],
  arriving: ["delivered"],
  delivered: [],
  cancelled: [],
};

type Tracked = { state: OrderState; seq: number; needsSnapshot: boolean };

function apply(t: Tracked, e: OrderEvent): Tracked {
  if (e.seq <= t.seq) return t; // duplicate or late
  if (e.seq > t.seq + 1) return { ...t, needsSnapshot: true }; // missed something
  if (e.type === "state" && !NEXT[t.state].includes(e.state)) {
    return { ...t, needsSnapshot: true }; // impossible move: trust the server's snapshot
  }
  return { ...t, seq: e.seq, state: e.type === "state" ? e.state : t.state };
}
A reducer that refuses impossible moves and asks for a snapshot on a gap.

The snapshot is the escape hatch for everything: a missed event, an unknown event type from a newer server, a reload, or a second device. It carries the full order and its current sequence, so any screen can be rebuilt from it alone; a snapshot with a lower sequence than the client already holds is stale and ignored. The event stream is only an optimisation that saves fetching the snapshot every few seconds.

Check your understanding · Exercise 9

The client is at state preparing with sequence 5. Which events should it apply directly? Select all that apply.

Live tracking, part 2: getting updates and keeping the ETA calm

The state machine decides what to do with an event. Now we need a way to get events to the phone: while the page is open, while the tab is in the background, and while the app is closed. Order events are rare (about ten per order, plus ETA updates every 30 seconds or so) and flow one way, from server to customer.

Decision How should the tracking page receive order updates while it is open?

Polling

GET /orders/:id every 10 to 15 seconds.

Pros

  • Works everywhere, through every proxy.
  • Each reply is a full snapshot, so nothing is ever missed.

Cons

  • Up to 15 seconds late.
  • Millions of open tracking pages at dinner make many requests that return nothing new.

Server-Sent Events

An EventSource on /orders/:id/events; the server pushes events as text over one HTTP response.

On reconnect the browser sends Last-Event-ID, so the server can replay what was missed.

Pros

  • Built-in reconnect and resume.
  • Plain HTTP; shares the HTTP/2 connection with other requests.
  • Fits one-way traffic exactly.

Cons

  • One-way only; actions such as cancel are normal requests.
  • On HTTP/1.1 it counts against the browser's 6-connection limit per host.

WebSocket

One socket per app session, subscribed to the order and, later, the chat.

Pros

  • Two-way and low overhead.
  • Could also carry chat messages.

Cons

  • Reconnect, resume, and heartbeats are all ours to build.
  • More server infrastructure for a stream that is almost entirely one-way.

What we pick: Pick SSE for order updates, with polling as a fallback when the stream fails to connect twice in a row. The traffic is one-way and sparse, which is what SSE is for, and Last-Event-ID plus our sequence numbers make resuming free. If the product already keeps a WebSocket open for courier chat, reusing it is fine; the reducer from the last chapter does not care where events come from. The server-sent-events and websockets lessons compare the two in depth.

When the tab is hidden or the app is closed

Mobile browsers freeze or discard background tabs to save battery, and the stream dies with them. The page listens to visibilitychange: when it becomes visible again, it fetches a snapshot at once and reopens the stream. A customer who comes back after ten minutes sees the truth within one round trip, not after the next event.

For the moments that matter while the app is closed (accepted, picked up, arriving, delivered, problems) the server sends a Web Push notification, which the service worker shows. On iPhone and iPad, Web Push works only for web apps added to the Home Screen (iOS 16.4 and later), so SMS or email remain the fallback there.

Ask for permission when the value is obvious: on the tracking page, a Notify me about this order button calls Notification.requestPermission() in its click handler. Firefox and Safari show the prompt only in response to a user gesture such as that click, and a prompt on page load is usually denied. The push-notifications lesson covers subscriptions and permission timing in depth.

How order updates reach the customer as the page changes state over time. Open: an SSE stream, with polling if it fails, about 1 second late. Hidden: the stream is frozen and events are missed, nothing until the page is back. Visible again: fetch a snapshot, then reconnect, one round trip. Closed: Web Push for key moments only, a few seconds late.page statechannelhow lateopenSSE streampoll if it failsabout 1 shiddenfrozenevents missednone until backvisible againsnapshotthen reconnectone round tripclosedWeb Pushkey moments onlya few secondstime during one order
No single channel covers the whole order. The stream covers the open page, a snapshot on visibilitychange repairs the hidden gap, and Web Push covers the closed app.

An ETA that does not jump around

The server's prediction changes as the kitchen and traffic change: 28 minutes, then 34, then 26, then 31. Shown raw, the customer sees the clock move back and forth and stops trusting it. The uber guide counts a pickup ETA down minute by minute, which suits a car a few minutes away; a meal still in the kitchen is far less certain, so a range fits better. Three techniques, used together, make it calm.

  1. Show a range and a clock time, not a countdown. Arriving 19:40 to 19:50 is honest about uncertainty, and a clock time does not tick every minute. Make the range wide early (10 minutes while preparing) and narrow later (5 minutes once picked up).
  2. Smooth the input. Blend each new estimate with the previous one, for example shown = shown + 0.4 * (new - shown), so one noisy update moves the range a little, and a real trend moves it steadily.
  3. Move only when it matters. Keep the shown range unless the smoothed value leaves it, or a stage change happens. Small wobbles inside the range change nothing on screen. When the range must move later by a lot, say why (The restaurant is busy).

Stage changes reset the smoothing, because they carry real information: Picked up replaces a guess about the kitchen with a measured trip. The lab below lets you send events in order, late, duplicated, and out of order, and watch both the state machine and the ETA range respond. It shows the range in minutes from now; the real screen turns those into clock times.

Events arrive
0 of 9 events
Order tracking with events arriving in order. The state machine is at placed, sequence 0, after 0 dropped events and 0 snapshots. The naive client shows placed and went backwards 0 times. Raw ETA none minutes changed 0 times; the shown range is not set and moved 0 times.state machine: sequence numbers, allowed movesplacedacceptedpreparingpicked_uparrivingdeliverednaive: the last event winsplacedacceptedpreparingpicked_uparrivingdeliveredraw ETA: - minraw changed 0 timesnaive went back 0 timesshown: waitingrange moved 0 timesdropped 0, snapshots 0
Pick how events arrive, then press Next event.

The map appears only once a courier is moving. Load the map library with a dynamic import() when the order reaches picked up, so the first tracking screen stays light. Smoothing the courier marker between GPS updates every 4 to 5 seconds, and keeping the map cheap on battery, are the same problems the uber guide solves for a car; reuse that design rather than repeating it.

Check your understanding · Exercise 10

Raw ETA updates arrive as 30, 33, 29, 31 minutes during preparing. What should the screen do? Select all that apply.

After ordering: substitutions, missing items, chat, and reorder

Things go wrong between the tap and the doorbell: the kitchen runs out of an item after accepting the order, the courier cannot find the building, or the bag arrives without the fries. Each needs a small flow on the tracking page, and each one is time sensitive.

Substitutions with a timer

When an item is out after the order was accepted (common for grocery and convenience stores on these apps, rarer for restaurants), the store proposes a replacement: No salmon roll; replace with tuna roll, same price? The kitchen cannot wait forever, so in this design the question has a deadline of a few minutes.

The server sends a substitution_requested event with the proposal and an expiresAt time. The app shows a sheet with Accept and Refund this item, and a visible countdown.

Decision What should happen when the customer does not answer a substitution before it expires?

Wait until they answer

The order is paused until the customer responds.

Pros

  • The customer always decides.

Cons

  • Food gets cold and the courier waits; one silent customer delays the whole order.

Always refund the item

On expiry, the server removes the item and refunds it.

Pros

  • Never charges for something unasked.

Cons

  • Customers who would have happily taken the replacement get less food.

Use a preference chosen at checkout

At checkout, a per-order or per-item setting: If something is out, replace with a similar item, or refund it.

On expiry, the server applies that preference and sends the outcome as an event.

Pros

  • The customer decides in advance, calmly, not in a 3 minute window.
  • The kitchen is never blocked.

Cons

  • One more choice at checkout; it needs a sensible default.

What we pick: Use the preference chosen at checkout, defaulting to refund. DoorDash and Uber Eats both ask grocery customers for a replacement preference before they place the order, for the same reason. The timer then never blocks the kitchen, and expiry has a predictable result the customer already agreed to. The countdown on screen is computed from the server's expiresAt, corrected by the offset between the server clock and the device clock, so a phone with a wrong clock does not show a timer that lies.

A timeline for a substitution request. At 19:30:00 the request arrives with expiresAt 19:35. At 19:30:02 the sheet opens with a countdown that uses the server time offset. At 19:35:00 the request expires and the customer's preference from checkout is applied. A late tap 5 seconds after expiry gets a 409 with the outcome. A note says a device clock 2 minutes fast is fixed by the offset from the server time.requestexpiresAt 19:35countdownserver time offsetexpirypreference applied19:30:0019:30:0219:35:00device clock 2 min fast?the offset from server time fixes itlate tap, +5 s409 with outcome
The deadline belongs to the server. The client only shows it, corrected by the clock offset, and a late answer is told what already happened instead of getting an error.

The server clock can come from the Date response header on a same-origin request, or from a serverTime field in the response (a cross-origin response hides Date unless the server lists it in Access-Control-Expose-Headers). Answering is a small write with an idempotency key, like placing the order. If the answer arrives after the deadline, the server returns 409 with the outcome already applied, and the client shows it (We refunded the salmon roll) instead of an error.

A push notification carries the same question when the app is closed. Chromium browsers, and Firefox since version 152, can show Accept and Refund as buttons on the notification itself, handled in the service worker's notificationclick event. Safari, including on iPhone, shows no buttons, so tapping the notification must open the substitution sheet.

Missing or wrong items

After delivered, Report a problem opens a short form built from the order snapshot: tick the lines that were missing or wrong, set how many, optionally add a photo, and choose a refund to the card or as credit. The form never asks the customer to type item names, because the lines are already known. The server decides the refund, often instantly for small amounts, and the reply updates the order's breakdown with a refund line.

Talking to the courier

Chat opens once a courier is assigned and closes a while after delivery. It is a small version of the chat-app guide: ordered messages, optimistic sends, read receipts, and quick replies such as I am coming down. Load the chat code only when the customer opens it, and route calls through a masked number so neither side sees the other's phone number.

Reorder and favourites

Order again is the fastest path in the app, because many customers come back for the same meal. The naive version copies the old lines into the cart with their old prices. Instead, call POST /orders/:id/reorder; the server rebuilds a cart from the old lines with today's menu, prices, and options, and returns it with issues for lines it could not rebuild (Garlic bread is no longer on the menu; Large is now 1.00 more).

The client opens the cart with those issues shown, using the same issue display as any stale cart. Favourite restaurants are a heart on each card, toggled optimistically and stored on the server.

Check your understanding · Exercise 11

The customer taps Accept on a substitution 5 seconds after it expired, on a slow network. What is the best behaviour? Select all that apply.

Architecture, data model, and API

With the hard parts settled, draw the whole client. It is a routed web app with four main screens (home list, menu, checkout, tracking) sharing a few stores and services. The boundaries follow the data: browse data is cached server state, the delivery context and cart are shared session state, and tracking is a stream fed into a reducer. All calls go through a backend for frontend (BFF): a thin server layer owned by the web team that shapes the backend services' data for these screens.

Architecture of the food delivery web app. The customer uses the Screens: list, menu, checkout, and tracking. Inside the page, the Screens read the Context store, which holds the address and time, and the Cart service, which sends commands with a version. The Context store keys the Query cache. The Order tracker holds the SSE stream, the reducer, and the ETA. The Query cache and Cart service go through one API client with keys and retries. Outside the page: the CDN serves HTML and shared lists to the Screens, the BFF serves REST and SSE to the API client and events to the Order tracker, the Push service wakes the service worker.customerpage (main thread)Screenslist, menu, checkout, trackingContext storeaddress + timeCart servicecommands, versionQuery cachekeyed by contextOrder trackerSSE, reducer, ETAAPI client: keys, retriesCDNBFFREST + SSEWorkerservice workerPush
The delivery context sits above the cache because every browse key includes it. The tracker gets its own box because it is the only part fed by a stream, and the worker sits outside the page because it must run when the page is closed.
PieceResponsibilityWhy it is separate
Delivery context storeThe selected address, approximate area, and ASAP or slot; synced to the server and other tabs.Every browse query reads it; one owner avoids two tabs or screens disagreeing.
Server-state cacheFeed pages, menus, option trees, quotes; keyed by the delivery context.Dedupes requests, caches by key, and refetches on focus, with no hand-written loading flags.
Cart serviceOptimistic commands, version checks, issues, and the breakdown from the server.The cart is shared by the menu, the cart sheet, and checkout, and must survive reloads and devices.
Checkout controllerIdempotency key, expected total, conflict handling, payment hand-off.Money logic stays in one place that is easy to test.
Order trackerSSE connection, polling fallback, snapshot fetch, the reducer, and ETA smoothing.Real-time logic is isolated from UI; it can run the same with any transport.
Service workerWeb Push, notification actions, and caching the app shell and images.Runs when the page is closed; nothing else can.
Lazy islandsMap, chat, and the help flow, loaded with dynamic import().They are big and needed by few screens, late in the journey.

Decision How should the pages be rendered: in the browser, on the server, or a mix?

Client-side single-page app

Ship a JavaScript bundle; it fetches the list or menu after it boots.

Pros

  • Simple hosting.
  • Fast navigation once loaded.

Cons

  • Nothing useful on screen until the bundle downloads and runs, which is slow on a mid-range phone.
  • Restaurant pages are poor for search engines and link previews.

Server render the public shell, hydrate personal parts

Store pages (/store/pizza-place-soho) and the list for an area are server rendered, with menus streamed as HTML.

Fees, ETAs, the cart, and the Order again row are filled in on the client.

Pros

  • Fast LCP: the menu and photos arrive as HTML.
  • Store pages are indexable and shareable.
  • The shared HTML can be cached at the CDN per store and area.

Cons

  • Hydration cost on the client; keep interactive parts small.
  • Two rendering paths to reason about.

What we pick: Server render the public parts and fill in the personal parts on the client. Store pages can be an important way new customers arrive from search, and server HTML is the most reliable way to a good LCP on mid-range phones. Keep the cart, fees, and tracking client-side, since they are personal and change often.

How data flows on the menu screen

Follow one screen end to end. The CDN serves the store HTML and the menu's light layer. The client reads the delivery context, asks the BFF for this address's fee and ETA, and fills them in. A tap on an item fetches the option tree; Add sends a cart command; the cart reply carries the new breakdown, which every subscribed component re-renders from.

Data flow on the menu screen. Reads, left to right: the services send the menu and quotes to the BFF, the BFF sends HTML and JSON to the CDN, the CDN caches the shared part for 60 seconds and fills the client cache, and the cache renders the menu UI. Writes, right to left: an Add tap sends a line to the cart store, which applies it optimistically and posts a command with version 7 through the BFF to the cart API. The cart API returns cart version 8 with the breakdown to the cart store, which re-renders the UI.readsServicesmenu, quotesBFFHTML, JSONCDNcached 60 sCacherenderMenu UIwritesAdd tapadd lineCartPOST, v7BFFcommandCart APIcart v8 + breakdown
Reads are shared and cacheable on the way in; writes are personal and go straight to the server. The write's reply carries the whole priced cart, so the client never adds up fees itself.

Check your understanding · Exercise 12

Which pieces should be loaded lazily rather than in the first bundle? Select all that apply.

Data model: what lives where

The client holds five kinds of data, and each belongs in a different place. Putting them in the right place is most of the data model: browse data in the server-state cache, the delivery context and cart in a small shared store backed by the server, filters in the URL, the tracking state in a reducer, and form choices in component state.

ts
type Money = { amount: number; currency: string }; // 1840, "GBP"

type StoreCard = {
  id: string; name: string; photo: string; cuisine: string[]; rating: number;
  priceLevel: 1 | 2 | 3 | 4; timeZone: string;
  status: { kind: "open" | "opens-later" | "closed" | "paused"; at?: string };
};
type StoreQuote = { storeId: string; fee: Money; etaMin: number; etaMax: number; expiresAt: string };

type MenuItem = { id: string; categoryId: string; name: string; priceFrom: Money;
  photo?: string; tags: string[]; available: boolean; hasOptions: boolean };

type CartLine = { key: string; itemId: string; selections: Record<string, string[]>;
  qty: number; note?: string; price: Money; issue?: "unavailable" | "option_unavailable" | "price_changed" };
type Cart = { id: string; version: number; storeId: string; addressId: string;
  when: "asap" | { slotStart: string }; lines: CartLine[];
  breakdown: { label: string; amount: Money; kind: string }[]; total: Money };

type Order = { id: string; seq: number; state: OrderState; courier: Courier | null;
  eta: { min: string; max: string } | null; lines: CartLine[]; total: Money;
  pending?: { kind: "substitution"; id: string; expiresAt: string } };
The main client entities. Money is always integer minor units with a currency code.
DataLives inFreshness
Store cards, menus, option treesServer-state cache, keyed by delivery contextAbout a minute, revalidated on focus.
Quotes (fee, ETA)Server-state cache, per exact addressUntil expiresAt, usually a few minutes.
Delivery contextShared store, mirrored on the server and in localStorage for guestsChanges only by the customer.
CartShared store, the server is the source of truthVersion checked on every write; refetched on focus.
Filters, sort, selected categoryURL search paramsAs long as the URL.
Active orderTracker reducer, rebuilt from the snapshotLive via SSE; snapshot on gaps and focus.
Modifier choices in an open sheetComponent stateGone when the sheet closes.
Where each client entity lives. The server-state cache holds StoreCard, StoreQuote, MenuItem, and ModifierGroup. The shared store and tracker hold DeliveryContext, Cart, and Order. The URL holds filters and sort. Component state holds the Selections of an open item sheet. DeliveryContext keys the cache; Selections become a cart line. A note says money is integer minor units with a currency code, and every entity can be rebuilt from a server response.server-state cacheStoreCardStoreQuoteMenuItemModifierGroupshared store, trackerDeliveryContextCartOrderURLfilters, sortcomponent stateSelectionsmoney: integer minor units + currency codeevery entity can be rebuilt from a server response
Each place has its own lifetime: the cache expires, the shared store follows the server, the URL follows the page, and component state dies with the sheet.

Endpoints

The API is REST through a BFF, plus one event stream per active order. Reads that can be shared carry the cell in the path so the CDN can cache them; personal reads and all writes are private. Every write that can be retried takes an Idempotency-Key.

http
GET  /api/feed?cell=gcpvj0&cursor=…&cuisine=thai&price=1,2   (shared, s-maxage=60)
  -> { rows: [{ title, storeIds }], stores: StoreCard[], nextCursor }
GET  /api/quotes?addressId=a_12&stores=s_1,s_2,s_3            (private)
  -> { quotes: StoreQuote[] }
GET  /api/stores/s_1/menu?at=asap                              (shared, s-maxage=60)
  -> { categories: [...], items: MenuItem[] }
GET  /api/items/i_9/options                                    -> { groups: ModifierGroup[] }

POST /api/cart/commands   { version: 7, op: "add", line: { itemId, selections, qty } }
  -> 200 Cart | 409 { error: "version_conflict", cart: Cart }
  -> 409 { error: "other_store", currentStoreId }   (show the new cart dialog)
POST /api/cart/preview-address  { addressId }  -> { deliverable, cart?: Cart }

POST /api/orders   Idempotency-Key: 6f1c…   { cartId, cartVersion, expectedTotal }
  -> 201 Order | 409 { error: "cart_changed", cart: Cart } | 409 { error: "request_in_progress" }
GET  /api/orders/o_5                                           -> Order (snapshot)
GET  /api/orders/o_5/events   (text/event-stream)
  id: 12  data: { "seq": 12, "type": "state", "state": "picked_up" }
POST /api/orders/o_5/substitutions/sb_1   { accept: true }  -> Order | 409 { outcome }
The main calls. Shared reads are CDN-cacheable; the rest are private.

Errors share one shape, { error, message, details? }, where error is a stable machine code the client switches on (other_store, cart_changed, slot_full, out_of_zone) and message is a localised sentence for the rare case the client has no special screen. Using the SSE id field equal to the order's sequence means the browser's automatic Last-Event-ID on reconnect tells the server exactly where to resume.

Check your understanding · Exercise 13

Why are prices stored as integer minor units, such as 1840 for 18.40? Select all that apply.

Performance, accessibility, and internationalisation

Hungry customers are impatient, and many of them order on a mid-range Android phone over a mobile network that drops in lifts and on trains. Set budgets per screen and design each screen to meet them, rather than tuning at the end.

ScreenBudgetHow we meet it
Home listLCP under 2.5 s, about 150 KB JSServer-rendered first rows, the first card photo with fetchpriority="high", the rest lazy, quotes streamed in after.
MenuLCP under 2.5 s, tab jumps under 100 msLight menu layer in the HTML, content-visibility: auto per category, option trees on demand.
Item sheetINP under 200 msOption tree prefetched on pointerdown; validation is a tiny pure function; no layout reads in handlers.
TrackingUseful status in one round tripSnapshot first, stream second; the map and chat load only when needed.

Prefetch with care. When a store card has been in view for a moment, prefetching its light menu makes the tap feel instant, but on a slow or metered connection it wastes data the customer pays for. Where the Network Information API is available (Chromium browsers), skip prefetching when navigator.connection.saveData is true; elsewhere, prefetch only the first few cards. Filter and sort changes are wrapped in startTransition so the chip responds at once and the list re-renders without blocking taps.

Designing for a network that drops

  • Reads show stale data, labelled. The cache keeps the last list and menu; on a failed refetch the screen stays usable with a small Offline, showing saved results note, and retries with backoff when online fires.
  • Cart writes retry, quietly. An Add is optimistic and retried with exponential backoff and jitter; after the last try fails, the line shows Not added, tap to retry instead of vanishing.
  • Order placement never queues offline. A queued order that fires an hour later is a wrong order. If the device is offline, Place order is disabled with a reason.
  • The app shell is cached by the service worker, so a reload in a dead zone still opens the tracking page and shows the last snapshot with its time (Last updated 19:32).
  • Long sessions do not leak. A tracking page left open for an hour closes its stream on pagehide, removes map listeners when unmounted, and caps stored ETA history to the last few values.

Measure on real devices in the field, not only in the lab. Real user monitoring with the web-vitals library, split by screen, device class, and network type, shows whether the menu's LCP regressed for mid-range phones in one country after a release. Track business-facing timings too: time from tapping a store to the first item being tappable, and the share of checkouts that hit a 409 conflict.

Check your understanding · Exercise 14

The customer loses signal right after tapping Add on an item. What is the right behaviour? Select all that apply.

Accessibility

Ordering food is an everyday task, so it must work for customers who use a screen reader, a keyboard, a switch, or large text. Three parts of this app need specific care: the modifier form, the dialogs, and the live status.

  • Modifier groups are fieldsets. Each group is a fieldset whose legend says the rule (Size, required, choose 1; Toppings, optional, up to 3). Required pick-one groups are native radio buttons and the rest are checkboxes, so arrow keys and Space work without custom code. Errors are linked to their group with aria-describedby, and on a failed Add, focus moves to the first group with an error.
  • Dialogs trap and return focus. The item sheet and the Start a new cart? dialog are modal dialogs (the native dialog element with showModal() does most of this): focus goes in, Escape closes, focus returns to the button that opened them.
  • Live status is polite. The tracking headline sits in a region with aria-live="polite", and only stage changes and real ETA moves update it, never every smoothed wobble. A customer listening with a screen reader hears Your order has been picked up, arriving 19:40 to 19:50 once, not a stream of numbers. A substitution with a deadline is the one case that justifies a more urgent announcement, and it should also move focus to its sheet.
  • The sticky nav is a list of links, with aria-current="true" on the active category, so screen reader users can jump by category too.
  • Motion respects preferences. Under prefers-reduced-motion: reduce, smooth scrolling becomes instant and the courier marker jumps instead of gliding.

Currencies, time zones, and languages

Money comes from the server as minor units plus an ISO currency code. It is formatted with Intl.NumberFormat(locale, { style: "currency", currency }), which knows that yen have no minor unit and where each locale puts the symbol. Times are always computed in the restaurant's IANA time zone and formatted with Intl.DateTimeFormat and the timeZone option, so opening hours, slots, and ETAs are right for a customer whose phone is set to another zone.

Lists of tags such as Thai, Vegan, Spicy use Intl.ListFormat. Menu text is the restaurant's own content, often in the local language only; show it as given, mark its lang, and translate only the app's own interface. Layouts use logical CSS properties so right-to-left languages such as Arabic mirror correctly.

Check your understanding · Exercise 15

How should the tracking page announce changes to screen reader users? Select all that apply.

Running it in the interview

In 45 minutes you cannot cover every chapter. Show that you see the whole shape, then go deep where food delivery is different from a normal shop. A plan that works:

  1. Minutes 0 to 5: requirements. Ask about one restaurant per cart, devices and networks, scheduling, and who prices the cart. State the numbers you assume.
  2. Minutes 5 to 12: the shape. Name the three parts (browse, money, live), draw the architecture with the delivery context store at the top, and say that the address keys every browse query.
  3. Minutes 12 to 32: two or three deep dives. Location and caching per cell; modifiers as a rule-driven form validated on both sides; the order state machine with sequence numbers and snapshots. Let the interviewer steer between them.
  4. Minutes 32 to 40: API and data model. Show the cart command with a version, the order request with an idempotency key and expected total, and the SSE event shape.
  5. Minutes 40 to 45: optimisations. LCP for image-heavy lists, flaky networks, the polite live region, and time zones.

Draw the state machine on the board early in the tracking discussion: it is a picture interviewers remember, and it makes the late and duplicated events question easy to answer. Say out loud which numbers come from the server (fees, totals, ETAs, availability) and which the client may preview (item price while choosing, the cart badge).

Think first

Follow-up: at 19:30 it starts raining and delivery fees double while many customers are in checkout. What happens in your design?

Think first

Follow-up: the product wants group orders, where friends add to one cart from their own phones. What changes?

Check your understanding · Exercise 16

Which of these are strong signals to give in this interview? Select all that apply.

Summary

  • The delivery context (address and time) is the root of all browse state: put it in every query key, and ask the server before changing it under a full cart.
  • Rank and price on the server. Cache the shared list per location cell at the CDN, and fetch fees and ETAs per exact address, privately.
  • Load menus in two layers: every item light for tabs and search, option trees on demand. Sync the sticky nav with an IntersectionObserver, not scroll math.
  • Modifiers are a rule-driven form: min and max per group, nested groups dropped with their parent, price deltas previewed on the client and charged by the server.
  • The cart is one restaurant, on the server, versioned, with issues per line. The client shows the server's breakdown and never invents the total.
  • Place orders with an idempotency key and an expected total, so retries never double-order and price changes are always confirmed.
  • Track the order as a state machine fed by sequenced events, with snapshots on gaps, SSE while open, polling as a fallback, and Web Push when closed.
  • Show ETAs as a smoothed clock-time range that moves only when it matters, and announce status politely.

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