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.
| Question | Answer we assume | What 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.
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?
Pick the address as the root of state, because it shapes caching, the cart, and checkout, and most candidates treat it as a plain form field.
Then pick live tracking as a state machine, because it shows you can handle real-time events that are late, duplicated, or out of order, which is where food delivery differs most from a normal shop. Modifiers are a strong third choice if the interviewer likes forms.
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?
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
| Data | Depends on | On address change |
|---|---|---|
| Restaurant list and ranking | Address cell, time | Refetch for the new cell; keep the old list visible but dimmed until the new one arrives. |
| Delivery fee and ETA per restaurant | Exact address, courier supply | Refetch; never reuse old numbers. |
| Which menu is active | Time (lunch, dinner), sometimes store branch | Usually unchanged for the same restaurant; refetch if the branch changes. |
| Item availability | Branch stock | Refetch if the branch changes. |
| Cart prices and fees | Address, items, promos | Server re-prices the cart; the client shows a diff of what changed. |
| Promotions | Zone, customer | Revalidate; a promo may not apply in the new zone. |
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.
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,
};
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.
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?
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.
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.
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;
}
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?
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.
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.
| Line | Amount | Why it is there |
|---|---|---|
| Subtotal | 1840 | Items with their option deltas. |
| Delivery fee | 299 | Distance band and current courier supply for this address. |
| Service fee | 184 | A percentage of the subtotal, with a floor and a cap. |
| Small order fee | 0 | Charged only below a threshold, such as 1000. |
| Promo WELCOME5 | -500 | Validated against zone, restaurant, and customer. |
| Tax | 153 | Computed for the region; may be included in prices in some countries. |
| Tip | 200 | Chosen by the customer; goes to the courier. |
| Total | 2176 | The 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?
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.
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
}
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.
| State | Who moves it here | What the customer sees |
|---|---|---|
placed | Our checkout | Waiting for the restaurant to accept, with a Cancel button. |
accepted | Restaurant tablet | The restaurant has your order, and a first ETA range. |
preparing | Restaurant tablet | Being prepared; Cancel is gone or costs money now. |
picked_up | Courier app | On the way, the map, and a narrower ETA. |
arriving | Dispatch, by distance | Arriving now; the drop-off notes are shown again. |
delivered | Courier app | Delivered, a photo for leave-at-door, and Rate and Report a problem. |
cancelled | Customer, restaurant, or support | Why 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.
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 };
}
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?
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.
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.
- 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).
- 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. - 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.
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?
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.
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.
| Piece | Responsibility | Why it is separate |
|---|---|---|
| Delivery context store | The 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 cache | Feed 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 service | Optimistic 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 controller | Idempotency key, expected total, conflict handling, payment hand-off. | Money logic stays in one place that is easy to test. |
| Order tracker | SSE 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 worker | Web Push, notification actions, and caching the app shell and images. | Runs when the page is closed; nothing else can. |
| Lazy islands | Map, 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?
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.
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.
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 } };
| Data | Lives in | Freshness |
|---|---|---|
| Store cards, menus, option trees | Server-state cache, keyed by delivery context | About a minute, revalidated on focus. |
| Quotes (fee, ETA) | Server-state cache, per exact address | Until expiresAt, usually a few minutes. |
| Delivery context | Shared store, mirrored on the server and in localStorage for guests | Changes only by the customer. |
| Cart | Shared store, the server is the source of truth | Version checked on every write; refetched on focus. |
| Filters, sort, selected category | URL search params | As long as the URL. |
| Active order | Tracker reducer, rebuilt from the snapshot | Live via SSE; snapshot on gaps and focus. |
| Modifier choices in an open sheet | Component state | Gone when the sheet closes. |
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.
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 }
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.
| Screen | Budget | How we meet it |
|---|---|---|
| Home list | LCP under 2.5 s, about 150 KB JS | Server-rendered first rows, the first card photo with fetchpriority="high", the rest lazy, quotes streamed in after. |
| Menu | LCP under 2.5 s, tab jumps under 100 ms | Light menu layer in the HTML, content-visibility: auto per category, option trees on demand. |
| Item sheet | INP under 200 ms | Option tree prefetched on pointerdown; validation is a tiny pure function; no layout reads in handlers. |
| Tracking | Useful status in one round trip | Snapshot 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
onlinefires. - 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
fieldsetwhoselegendsays 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 witharia-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
dialogelement withshowModal()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:
- 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.
- 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.
- 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.
- 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.
- 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?
Quotes have an expiresAt, so the checkout screen refreshes the breakdown when the quote expires or the page regains focus. Customers who tap Place order with the old total get a 409 cart_changed with the new breakdown, see the fee change explained, and confirm again. Nobody is charged a number they did not see.
On the list, fees come from the private quotes call, not the cached cell list, so they update within a minute without purging the CDN.
Think first
Follow-up: the product wants group orders, where friends add to one cart from their own phones. What changes?
The cart gains participants, each line gains an owner, and edits from several devices now happen at the same time, so the version check becomes the normal path, not the rare one. Push cart changes to all participants over the same event stream used for tracking, and let the host lock the cart before checkout so prices stop changing while they pay.
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.
References
- Geolocation API Permissions, accuracy options, and secure-context rules for the location button.
- Intersection Observer API The basis of the sticky category nav and lazy loading.
- Using server-sent events EventSource, event ids, and reconnection for order updates.
- Push API Notifications when the tracking page is closed.
- Largest Contentful Paint (LCP) Thresholds and how image-heavy pages hit them.
- Interaction to Next Paint (INP) The responsiveness target for item sheets and filters.
- Idempotent requests, Stripe API reference A widely used design of the Idempotency-Key header.
- H3: Uber's hexagonal hierarchical spatial index Why Uber built a grid of hexagon cells for location-based pricing and dispatch.
- Notification: actions property Action buttons on notifications, and which browsers show them.
- Intl.DateTimeFormat Formatting opening hours and slots in the restaurant's time zone.
- WAI-ARIA Authoring Practices: Radio Group Keyboard behaviour expected from required single-choice modifier groups.
Concepts behind it
- 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.
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Server-sent eventsOne HTTP response that never finishes: the server keeps writing small text events to it, and the browser's
EventSourceparses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client. - Web push notificationsMessages your server sends through the browser's push service, which wake your service worker to show a notification even when your site isn't open: powerful for re-engagement, and easy to ruin with a bad permission prompt.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- 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.
- Cache invalidationHow every copy of a piece of data, from the server cache to the CDN to the open tab, learns that it is out of date: by time, by event, or by a new name, in the right order and without a stale read sneaking back in.