Design Uber's rider web app
The mobile web app a rider uses to book a car: pick a pickup point and a destination, compare products and upfront fares, request, watch the driver arrive on a live map, ride, pay, and rate. The hard parts are a trip that is a server-owned state machine the client must mirror through reloads and lost connections, a moving car that looks smooth even though updates arrive every few seconds, a request that can never book twice, and all of it fast on a low-end phone on a weak network.
The question
Design the rider web app for Uber, the one at m.uber.com. A rider opens it on a phone browser, sets a pickup point and a destination, sees the ride options with a price for each, and requests a car. The app then shows the search for a driver, the driver coming on a map with a live ETA, the trip itself, and finally the receipt and a rating.
Assume many riders are on cheap Android phones and on slow or flaky mobile networks, and some open the app because they have no room for a native app. Walk me through how you model the trip, how you show the moving car, how a ride gets requested safely, and how you keep the app fast and light.
Real-life examples: m.uber.com, Uber's web rider app, Lyft and Bolt ride booking on the web, Grab and Ola web booking, Food delivery live order tracking
Requirements: what to ask before drawing anything
A ride app looks simple: a map, two address boxes, and a big button. It is really a long-running transaction that lasts 10 to 60 minutes, that the server drives, that has money at the end of it, and that the rider watches on a device that may lose signal, sleep, or reload at any moment. The web version adds two more constraints: no native background process, and a much smaller budget for code and battery than a native app has.
Spend the first five to seven 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 |
|---|---|---|
| Rider, driver, or both? | Rider only. The driver app is native and out of scope. | We consume driver positions and trip events; we never produce them. |
| Which steps of the trip are in scope? | All of them: choosing pickup and destination, products and fares, requesting, matching, driver arriving, on trip, payment, rating, and cancellations. | The trip is a long state machine, so modelling it is the core of the design. |
| Which devices and networks? | Mobile first. The target is a low-end Android phone (4 slow cores, 2 GB of RAM) on a 3G-like connection with 300 ms round trips, plus desktop as a bonus. | A tight JavaScript budget, a map that loads late, and a design that tolerates dropped connections. |
| Who owns the trip state? | The server. Matching, driver acceptance, and cancellations all happen there. | The client never decides a state change on its own; it asks and then mirrors what the server says. |
| Can the rider use more than one device? | Yes. A rider may request on a laptop and follow the trip on a phone, or reload the tab mid-trip. | Every screen must be rebuildable from the server's current trip, not from local memory. |
| Is the price fixed before the ride? | Yes, an upfront fare per product, valid for a few minutes, sometimes with surge pricing. | Quotes have an id and an expiry, and the request must carry the quote id. |
| Which payment methods? | Saved cards, wallets such as Apple Pay and Google Pay, and cash in markets that allow it. | Card checks such as 3-D Secure can redirect the rider away from the app mid-flow. |
| How many markets? | Hundreds of cities, dozens of languages, right-to-left scripts, many currencies, kilometres and miles. | Formatting with Intl APIs and a layout that mirrors for right-to-left languages. |
Functional requirements
- Set the pickup point from the device location, a draggable pin, a suggested pickup spot, or a typed address. Set the destination by typing or by picking a saved place.
- See ride products (for example UberX, Comfort, XL) with an upfront fare and a pickup ETA for each, and nearby cars on the map.
- Request a ride and see its progress: searching for a driver, driver assigned with name, car, and plate, driver arriving with a live ETA and a moving car, driver arrived, on trip, completed.
- Cancel before or after a match, and see clear screens when no driver is found, the driver cancels, or the payment fails.
- Pay with a saved method, pass any extra card check, then rate the driver, tip, and see a receipt.
- Share the live trip with a contact and reach an emergency button during the trip.
Non-functional requirements, with numbers
- Small and fast on cheap phones. The core booking flow ships about 50 KB of minified and gzipped JavaScript before the map, the size Uber reported for m.uber's core, and the first screen is usable within about 3 seconds on a slow 2G or 3G connection. The map can arrive later.
- Correct trip state. After a reload, on a second device, or after 30 seconds offline, the screen shows the server's current state within one round trip. It never shows a state the trip has already left.
- Never book twice. A double tap, a retry after a timeout, or a reload during the request creates at most one trip.
- Live but light. The assigned driver's position refreshes every few seconds and the car moves smoothly at 60 frames per second in between, without draining the battery when the tab is hidden.
- Responsive input. INP (Interaction to Next Paint, the delay from a tap to the next frame) at or under 200 ms on the target phone, even while the map animates.
- Accessible. WCAG 2.2 AA. Every trip state and ETA is available as text, not only on the map, and changes are announced to screen readers.
Out of scope, said out loud
This guide leaves out: the driver app, the matching and pricing algorithms, routing and map data on the server, Uber Eats, scheduled rides, shared rides with several riders, and the support centre. It also leaves out how map tiles are drawn: the google-maps guide covers tiles, zoom levels, and markers, and this guide links to it instead of teaching it again.
Check your understanding · Exercise 1
Which of these answers would change the client design the most? Select all that apply.
What makes a ride app hard
Five traits drive almost every decision in this guide. Name them early in the interview: they explain why the design is not just a form that posts to an API.
- The trip is a long, server-driven state machine. About a dozen states, and most changes come from other people: a driver accepts, a driver cancels, the matching system gives up. The rider's screen is a view of a state that lives somewhere else, and the client must never invent a state the server did not send.
- The trip outlives the page. A rider locks the phone while waiting, switches to a messaging app, or the browser drops the tab to free memory. When they come back, the page may be brand new. Everything on screen must be rebuildable from one call to the server.
- Movement must look continuous while data is not. The driver's phone sends a GPS fix every few seconds, and the network adds jitter on top. Drawn naively, the car teleports 40 metres every 4 seconds. Riders read a jumping car as a broken app, and a smooth car as a trustworthy one.
- Money and side effects. Requesting a ride commits a driver and, at the end, charges a card. A retry after a timeout must not create a second trip. A price shown on screen must be the price charged, or the rider must be told before they commit.
- Hostile devices and networks. The rider may be outdoors, on 3G, with 15% battery, on a phone that takes 4 times longer than a laptop to run the same JavaScript. Every kilobyte, every timer, and every map redraw has a cost the rider feels.
Compare it with two guides you may know. The google-maps guide is about drawing a huge map fast; here the map matters, but it is a backdrop for the trip and should never block booking. The chat-app guide is about an ordered stream of messages; here the stream is a sequence of trip events, but only the latest state matters, so a missed event can be repaired by fetching the current trip.
Think first
A rider requests a ride, then the phone loses signal in a lift for 90 seconds. During that time a driver accepts, then cancels, and a second driver accepts. What should the screen show when the signal returns, and how does it get there?
It should show the second driver on the way, with their name, car, and a fresh ETA. It should not replay "driver assigned", "driver cancelled", "driver assigned" as if they were happening now.
The client gets there by asking the server for the current trip as soon as the connection is back, and replacing its mirror with that snapshot. Missed events in between do not matter, because the snapshot already contains their result.
At most, the app can add a short note such as "Your first driver cancelled. We found you another one", if the trip snapshot carries that history. That is a message, not a replay of states.
The trip as a state machine the server owns
Start with the naive version, because many real apps ship it. Each screen keeps its own flags: isSearching, driver, isOnTrip, showRating. Buttons flip them: tapping Request sets isSearching = true, a socket message sets driver, and so on. It works in the demo, and then three things happen in production.
- Impossible combinations. A late "driver assigned" message arrives after the rider cancelled. Now
isSearchingis false,driveris set, and the cancel screen is showing a car. Four flags allow 16 combinations, and many of them describe no real moment of a trip. - Reload amnesia. The rider reloads during "driver arriving". The flags start as false, so the app shows the home screen and offers to book again. The rider books a second car.
- Two devices disagree. The rider cancels on the laptop. The phone never hears about it and keeps showing the driver coming.
The fix is to make the trip one value with one status field, owned by the server, and to treat the client as a mirror of it. The state-machines lesson covers the general idea of explicit states and events; here is what it looks like for a trip.
| Status | Who moves it here | What the rider sees |
|---|---|---|
draft (client only) | The rider, by picking places | Pickup and destination boxes, the map, nearby cars. |
quoting (client only) | The client, once both places are set | Product list with fares and ETAs, a Request button. |
requested | The server, after accepting the request | "Finding your driver" with a pulsing marker and a Cancel button. |
driver_assigned | The server, when a driver accepts | Driver name, photo, car, plate, rating, and a pickup ETA. |
driver_arriving | The server, as the driver moves | The car moving on the map, the ETA counting down. |
driver_arrived | The server, when the driver taps Arrived | "Your driver is here", maybe a waiting-time notice. |
on_trip | The server, when the driver starts the trip | Route to the destination, arrival time, share and safety buttons. |
completed | The server, when the driver ends the trip | Fare, receipt, rating, and tip. |
cancelled | The server, after a rider or driver cancels | Who cancelled, any fee, and a one-tap "Request again". |
no_drivers | The server, when matching gives up | "No drivers available", with try again or another product. |
payment_failed | The server, when the charge fails | Fix the payment method before the next trip. |
Notice the split. draft and quoting exist only in the browser: no trip exists yet, so the client owns them. From requested onward a trip exists on the server and only the server moves it. The client can ask for a change ("cancel please"), but the change is real only when the server says so.
Decision How does the client keep its copy of the trip correct?
What we pick: Snapshot plus versioned events. The server keeps a version on each trip that goes up by one on every change. The client holds the highest version it has seen and ignores anything older or equal. A gap means it missed something, so it asks for the whole trip, which is cheap: a trip snapshot is about 2 KB.
The reconcile rules, exactly
Write the rules down. They are short, and they are what the interviewer wants to hear. Each incoming message carries the trip id, the version, and either a full snapshot or a small event.
type Incoming =
| { kind: "snapshot"; trip: Trip }
| { kind: "event"; tripId: string; version: number; patch: Partial<Trip> };
function reconcile(held: Trip | null, msg: Incoming): Trip | "refetch" | null {
if (msg.kind === "snapshot") {
// A snapshot wins unless it is older than what we already show.
if (held && held.id === msg.trip.id && msg.trip.version < held.version) return held;
return msg.trip;
}
if (!held || held.id !== msg.tripId) return "refetch"; // event for a trip we don't hold
if (msg.version <= held.version) return held; // duplicate or late: ignore
if (msg.version > held.version + 1) return "refetch"; // gap: we missed one
return { ...held, ...msg.patch, version: msg.version };
}
Walk through the bad cases with it. A duplicate driver_assigned (version 7 when we hold 7) is ignored, and so is a late driver_arriving (version 6) after cancelled (version 8). A driver_arrived with version 10 when we hold 8 means version 9 is missing, so the client fetches the snapshot. The machine can never go backwards, and it can never skip ahead on a guess.
Notice there is no special rule for finished trips. The version alone orders everything, including the one change that follows completed: a failed charge moves the trip to payment_failed with a newer version, and the client applies it like any other event.
Reload and the second device
On every page load the app asks one question before drawing the booking screen: does this rider have an active trip? GET /trips/current returns the trip snapshot or 204 No Content. If there is a trip, the app routes straight to the screen for its status and opens the live channel; if not, it shows the booking screen.
The URL can hold the trip id too (/trip/abc123), which makes a shared or bookmarked link land on the right screen, but the server is still asked, because the URL cannot know the status.
A second device works the same way. The laptop and the phone each hold a mirror and each subscribe to the rider's trip channel. When the laptop cancels, the server moves the trip to cancelled with a new version and pushes it to both, so the phone updates without having done anything. If the phone was asleep, it fetches a snapshot when it wakes: the same code path, with no special case.
Check your understanding · Exercise 2
The client holds the trip at version 12, status driver_arriving. Which incoming messages should it apply directly, without a refetch? Select all that apply.
Choosing the pickup point
The pickup point is the most expensive thing to get wrong. A wrong destination costs a detour; a wrong pickup costs a phone call, a 5-minute wait, a cancelled trip, or a driver on the wrong side of a highway. The naive design takes the GPS fix from the browser, turns it into a street address, and sends it. Here is how that fails.
- The fix is not precise. Indoors or between tall buildings, the browser may report a position tens or even hundreds of metres off, and on a laptop without GPS it may come from Wi-Fi or the IP address and be off by much more. The
accuracyfield ofGeolocationCoordinatestells you the radius in metres, and the naive design ignores it. - An address is not a place to stand. A shopping centre has one address and six entrances. A driver needs a spot by a kerb where stopping is legal.
- Permission is not a given. The rider may deny location, dismiss the prompt, or have location turned off for the whole browser. The app must still work with a typed address.
Getting the location politely
The Geolocation API only works in a secure context (HTTPS). Ask for location when the rider taps the "Use my location" button or focuses the pickup box, not on page load: a permission prompt that appears before the rider has done anything is a well-known anti-pattern, and riders deny it more often. Before asking, navigator.permissions.query({ name: "geolocation" }) tells you if permission is already granted, denied, or still prompt, so you can skip the button when it is already granted and show help text when it is denied.
function locate(onFix: (c: GeolocationCoordinates) => void) {
const geo = navigator.geolocation;
// Accept a cached fix up to 60 s old: instant, maybe rough.
geo.getCurrentPosition((p) => onFix(p.coords), () => {}, {
maximumAge: 60_000, timeout: 3_000, enableHighAccuracy: false,
});
// Then ask for a precise one; it may take several seconds and more battery.
const id = geo.watchPosition((p) => {
onFix(p.coords);
if (p.coords.accuracy <= 30) geo.clearWatch(id); // good enough: stop
}, () => geo.clearWatch(id), { enableHighAccuracy: true, timeout: 15_000 });
setTimeout(() => geo.clearWatch(id), 20_000); // hard stop to save battery
}
Show the accuracy honestly. Draw a translucent circle with the reported radius around the blue dot. If the radius is above about 100 metres, say so in words ("Your location is approximate. Check the pin.") and open the map zoomed to the pin, so the rider is nudged to confirm rather than trust a guess.
Decision How does the rider set the exact pickup point?
What we pick: Combine them. Start from the GPS fix, show a draggable centre pin, and draw the suggested points as small dots near it. When the map stops with the pin within about 30 metres of a suggested point, snap to it and show its name. At airports and venues where only certain zones are allowed, show only the suggested points and no free pin.
Reverse geocoding without a request per frame
Dragging the map fires move events about once per frame, up to 60 a second. Reverse geocoding (turning coordinates into a street label) on each one would send hundreds of requests per drag and pay for each. Instead, listen for the map's idle or moveend event, which fires once the map stops, and reverse geocode then. While the map moves, show "Locating..." under the pin.
Round the coordinates to 5 decimal places (about 1 metre) and cache results in a small in-memory map, so dragging back to a spot does not refetch. Cancel the previous request with an AbortController when a new one starts, so a slow old answer cannot overwrite the new label: the request-races lesson covers that pattern.
AbortController, so it can never overwrite the label for B.Typed addresses and session tokens
Typing the destination is an autocomplete box: debounce keystrokes by 150 to 250 ms, cancel stale requests, and keep keyboard and screen reader support as the autocomplete guide describes. Two things are specific to places. First, bias results toward the pickup location and the rider's saved places ("Home", "Work"), which come from the rider's profile and should show before the first keystroke.
Second, place APIs bill autocomplete by session. In Google's Places API (New), for example, the client creates a session token (Google suggests a random UUID) when the rider starts typing, sends it with each Autocomplete (New) request and with the final Place Details (New) request, and then drops it. Those requests are billed together as one session. Without a token, or with a reused one, each autocomplete request is billed separately, which at ride-app scale is real money.
Check your understanding · Exercise 3
The rider drags the map to adjust the pickup pin. Which choices are sound? Select all that apply.
The map: useful, heavy, and never in the way
Everyone draws the map first, because the product looks like a map. On a cheap phone on a slow network, that instinct hurts. A full interactive maps SDK means a large script, a WebGL or canvas context, dozens of tile images, and fonts for labels. If the booking screen waits for all of that, a rider on a weak connection stares at a grey box for 10 seconds, and some give up before they can even type a destination.
Uber's own engineering post on m.uber describes the opposite stance: the essential ride request app was about 50 KB, and the app measured how long the page took to become interactive, using the window.performance API, then decided whether to load the interactive map at all. Riders could also turn the map off in settings. The map was treated as an enhancement, not as the product.
Decision Which map do we ship, and when?
What we pick: Static image first, interactive later. The map container has a fixed height so nothing shifts (CLS stays near 0). The static image is a plain img with width and height. The interactive map is loaded with a dynamic import() after the first interaction or on requestIdleCallback (Safari does not ship it by default, so fall back to a short setTimeout), and only when a quick device check passes. Whether that interactive map is a vendor SDK or a lighter renderer is a product decision: pick the SDK if you need vendor routing and traffic, the lighter renderer if you mostly draw a pin and a few cars.
Deciding whether this device gets the live map
Do not sniff the user agent: measure. Useful signals in today's browsers are how long the core took to become interactive (from Navigation Timing and your own mark when the booking panel is ready) and navigator.hardwareConcurrency (the number of logical cores). Chromium browsers also expose navigator.deviceMemory (rough RAM in gigabytes) and the Network Information API's navigator.connection.effectiveType and saveData; other browsers do not, so treat a missing value as unknown, not as fast.
A simple rule: if the core took more than 5 seconds, or the rider has turned on data saver, keep the static map and offer a "Show live map" button. The rider can always opt in.
When the map is static, it still updates: after each status change the client asks for a new static image with the current pickup, destination, and driver markers drawn in. That costs one image request every few seconds at most, instead of a renderer running at 60 frames per second.
Check your understanding · Exercise 4
A rider opens the app on a 2 GB phone over a congested 3G connection. Which choices protect the booking flow? Select all that apply.
Live driver locations: making a car glide
Two kinds of moving cars appear in the app. Before booking, a handful of nearby cars show that service is available; they are a hint, refreshed every 5 to 10 seconds, and they do not need to be exact (the server may also coarsen them, since they are other people's positions). After a match, the assigned driver is the car the rider watches closely, refreshed every few seconds. Most of this chapter is about the assigned driver.
The naive approach moves the marker to each new position when it arrives. With an update every 4 seconds and a car at 36 km/h (10 metres per second), the marker jumps 40 metres at a time, then sits still for 4 seconds. Network jitter makes it worse: two updates may arrive 300 ms apart after a gap of 7 seconds, so the car stutters, freezes, then leaps. It also turns corners by teleporting through buildings.
Decision How do we turn sparse position updates into smooth movement?
What we pick: Interpolate with a delay buffer for the assigned driver, and allow a short burst of dead reckoning (at most one interval) only when an update is late, then stop. A car drawn a few seconds late is a small, steady lag that riders rarely notice, because the ETA in text comes from the server; a car that drives through a building and slides back is noticed at once. If no update arrives for about 15 seconds, stop the car and show "Updating driver location" in text, so a frozen car is never mistaken for a parked one.
Try the lab with its defaults: a fix every 4 seconds and up to 1 second of jitter. Jumping to each fix gives 8 visible jumps and an average error of 29 metres. Interpolation removes every jump, but because it draws the car about 5.3 seconds in the past, its average error grows to 49 metres. Dead reckoning stays within a few metres on open road, then overshoots the red light by up to 28 metres and snaps back.
That is the trade in numbers: interpolation buys smoothness with a steady lag, and dead reckoning buys accuracy with visible corrections. A steady lag is far less noticeable than a car that slides backwards, which is why the verdict above picks interpolation.
How the interpolation works
Each fix from the server has a position, a heading in degrees, and the time the driver's phone measured it. The client keeps a short buffer. On every requestAnimationFrame callback it computes a render time slightly in the past, finds the two fixes on either side of it, and places the car between them in proportion to the time elapsed.
One catch: the driver's phone and the rider's phone have different clocks. So when the first fix arrives, the client records the difference between its own clock and the fix's timestamp, and converts every later fix onto its own timeline with that offset. The spacing between fixes, which is what interpolation needs, stays exact.
type Fix = { lat: number; lng: number; heading: number; t: number };
function carAt(buffer: Fix[], now: number, delayMs: number) {
const renderT = now - delayMs;
for (let i = buffer.length - 1; i > 0; i--) {
const a = buffer[i - 1], b = buffer[i];
if (a.t <= renderT && renderT <= b.t) {
const k = (renderT - a.t) / (b.t - a.t);
return {
lat: a.lat + (b.lat - a.lat) * k,
lng: a.lng + (b.lng - a.lng) * k,
heading: lerpAngle(a.heading, b.heading, k),
};
}
}
return buffer.at(-1); // render time is past the newest fix: hold (or dead-reckon briefly)
}
function lerpAngle(a: number, b: number, k: number) {
const d = ((b - a + 540) % 360) - 180; // shortest turn, between -180 and 180
return (a + d * k + 360) % 360;
}
Three details matter. First, lerpAngle turns the shortest way: from 350 degrees to 10 degrees is a 20-degree turn to the right, not a 340-degree spin. Second, the car icon is rotated with a CSS transform: rotate() or the SDK's marker rotation, never by swapping images.
Third, straight-line interpolation cuts corners. The server should send positions that are already snapped to the road (map matching, which uses the road network and the driver's recent path), and during a turn it can send the route polyline so the client moves the car along the road shape instead of a straight chord. Exactly how Uber does map matching is not public in detail, so in an interview describe it as a server job and do not invent the algorithm.
How often should positions arrive?
| Update every | Data per 10-minute wait (about 120 bytes each) | Delay with buffer (interval plus about 1 s margin) | How it looks |
|---|---|---|---|
| 1 s | about 72 KB | about 2 s | Smooth, but costly for data and the radio on both phones. |
| 4 s | about 18 KB | about 5 s | Smooth with interpolation; a good default for the assigned driver. |
| 10 s | about 7 KB | about 11 s | Fine for nearby cars; too laggy for the final minute before pickup. |
The rate can change with the state. A car 8 minutes away can update every 5 to 10 seconds; in the last minute before pickup, or when it is within a few hundred metres, every 2 seconds helps the rider spot it. The server chooses the rate; the client only renders what arrives and never assumes a fixed interval, which is why the interpolation uses the timestamps on the fixes.
Check your understanding · Exercise 5
The driver's updates arrive every 4 seconds, but the network sometimes delivers one 2 seconds late. Which approaches keep the car moving smoothly and truthfully? Select all that apply.
Real-time transport: hearing about changes
The client needs two streams from the server, and they behave differently. Keep them apart in your head and on the whiteboard.
| Stream | Rate | If one message is lost | Ordering rule |
|---|---|---|---|
| Trip events (status, driver details, fare changes) | About 5 to 15 per trip | Must be repaired: refetch the snapshot. | Versioned; apply only the next version. |
| Driver location (position, heading, ETA) | One every 2 to 10 seconds | Harmless: the next one replaces it. | Latest timestamp wins; older ones dropped. |
The choosing-realtime lesson compares the transports in general. Here is the comparison for this app. Note what Uber's 2017 post says: m.uber polled the ride status every few seconds and listed replacing polling with push as future work. Polling is not wrong; it is the simple baseline you improve on.
Decision How does the rider's browser hear about trip changes and driver positions?
What we pick: Server-Sent Events for the live trip, with polling as the fallback. Rider actions are rare and fit POST with idempotency keys. If the company already runs a WebSocket gateway (for rider and driver chat, say), use it instead: the reconcile rules from the state machine chapter stay exactly the same, because the transport only delivers messages and never decides state.
The connection has a state machine too
Treat the connection as its own small machine, separate from the trip. It is connecting, open, backing_off, polling (fallback), or paused (tab hidden). Every time it reaches open, the first thing it does is fetch a trip snapshot, because events may have been missed while it was down. That one rule makes reconnects correct.
- Backoff with jitter. After a failure, the base wait doubles from 1 s to 2 s, 4 s, and so on up to 30 s, and the client waits a random time between zero and that value (full jitter), so a million phones that lost a cell tower do not reconnect in the same second. The retries-and-backoff lesson explains full jitter.
- Fall back, then step up. After 3 failed stream attempts in a row, switch to polling every 5 seconds, and try the stream again every few minutes. Some carrier and office proxies break long-lived connections; the app must still work behind them.
- Heartbeats. The server sends a comment line (
: ping) every 15 to 20 seconds. If the client hears nothing for 45 seconds, it assumes the stream is dead even if the browser has not noticed, and reconnects. - Network hints. On the
onlineevent orvisibilitychangeto visible, skip the backoff timer and reconnect now.
When the tab is hidden
Riders lock the phone while they wait. Browsers throttle timers in hidden tabs heavily, and mobile browsers may freeze or discard a background page entirely, so you cannot rely on JavaScript running while hidden. Use the Page Visibility API on purpose: when document.visibilityState becomes hidden, stop the animation loop (requestAnimationFrame callbacks already pause in hidden tabs in most browsers), and after about 30 seconds hidden, close the stream to save battery and data.
When the page becomes visible again, reopen the stream, fetch the snapshot, and jump the car to its latest position without animating the gap.
So how does the rider learn that the driver has arrived while the phone is locked? With Web Push: a service worker registered by the app receives a push message from the server and shows a system notification ("Your driver is here: white Toyota, plate KA 01 AB 1234"), even when no tab is open. The push-notifications lesson covers the subscription flow.
Two limits to state in the interview. The rider must grant notification permission, so ask after they request a ride, when the value is obvious. And on iPhones and iPads, Web Push works only for web apps added to the Home Screen (iOS and iPadOS 16.4 and later); in a normal Safari tab it is not available. Without push, fall back to a clear in-page alert and an updated tab title the moment they return.
Check your understanding · Exercise 6
The stream has been down for 40 seconds and has just reconnected. What should the client do first? Select all that apply.
Upfront fares and requesting a ride safely
Once pickup and destination are set, the app shows each product with a price and a pickup ETA. The naive design computes nothing on the client and asks GET /fares?from=...&to=..., shows the numbers, and on Request sends the pickup, destination, and product.
Two problems hide in that. The price the rider saw is not attached to the request, so the server prices it again a minute later, maybe with a higher surge, and the rider pays more than they agreed to. And the Request button is a plain POST with no protection, so a slow network plus an impatient thumb creates two trips.
Quotes: a price with an id and an expiry
Make the price a server object: a quote. The server returns one quote per product, each with its own quoteId, the amount in minor units (cents, paise), the currency, any surge factor, and an expiresAt.
The request must carry the quoteId. If the quote is still valid, the server charges that price; if it expired, the server refuses and returns a fresh quote for the rider to confirm. The price on screen and the price charged are now the same object.
{
"quoteId": "q_7f3a91",
"product": { "id": "uberx", "name": "UberX", "seats": 4 },
"fare": { "amount": 24850, "currency": "INR", "display": "₹248.50" },
"surge": { "multiplier": 1.4, "reason": "High demand nearby" },
"pickupEtaSeconds": 240,
"expiresAt": "2026-10-20T11:05:00Z"
}
- Refresh before expiry. Keep a timer per quote set. About 15 seconds before
expiresAt, fetch new quotes in the background. Compare expiry with a server-corrected clock: a phone clock can be minutes off, so compute the offset from the responseDateheader once and use it. - Refresh on return. When the tab becomes visible and the quotes are expired, refetch before enabling the Request button. Show the old prices dimmed with "Updating prices" rather than a blank list.
- New places, new quotes. If the rider moves the pickup pin or changes the destination, abort the in-flight quote request and fetch again; never show prices for the old route next to the new pin.
- Surge in words. Show surge as text next to the price ("Fares are higher due to increased demand"), not only as a colour or a lightning icon. If the price rose since the rider last saw it, say so on the confirm step: "The price changed from ₹212 to ₹248".
A request that can never book twice
A request is idempotent when sending it twice has the same effect as sending it once. Booking a ride is not idempotent by nature, so the design has to make it so. The usual tool is an idempotency key: a unique id the client sends with the request, which the server uses to recognize a retry. Stripe's API is a widely copied example, and an IETF draft describes the same Idempotency-Key header.
Decision How do we make sure one tap creates at most one trip?
What we pick: All three, layered. The button goes to a requesting state on the first tap (disabled, with a spinner and an aria-busy region). The request carries an idempotency key that survives reloads. The server also enforces one active trip per rider. Each layer covers a hole in the others.
async function requestRide(quoteId: string, paymentId: string): Promise<Trip> {
const saved = JSON.parse(sessionStorage.getItem("pendingRequest") ?? "null");
const key = saved?.quoteId === quoteId ? saved.key : crypto.randomUUID();
sessionStorage.setItem("pendingRequest", JSON.stringify({ quoteId, key }));
for (let attempt = 0; attempt < 4; attempt++) {
try {
const res = await fetch("/api/trips", {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify({ quoteId, paymentId }),
signal: AbortSignal.timeout(10_000),
});
if (res.ok) { sessionStorage.removeItem("pendingRequest"); return res.json(); }
if (res.status < 500) throw await res.json(); // e.g. 409 quote expired: show new price
} catch (e) {
const transient = e instanceof TypeError || (e instanceof DOMException && e.name === "TimeoutError");
if (!transient) throw e; // a real answer from the server, not a lost one
}
await sleep(backoffWithJitter(attempt));
}
throw new Error("request_unknown"); // ask GET /trips/current before giving up
}
The last line matters. If every attempt times out, the client does not know whether the trip exists, and it must not show "Request failed, try again": the trip may exist and a driver may already be on the way.
Instead it asks GET /trips/current. If a trip is there, it goes straight to the searching screen; if not, it is safe to say the request did not go through. On page load, a leftover pendingRequest in sessionStorage triggers the same check.
Check your understanding · Exercise 7
The rider taps Request. The POST times out after 10 seconds with no response. Which next steps are safe? Select all that apply.
ETAs, countdowns, and payments
ETAs that count down without lying
The pickup ETA is the number riders look at most. The server computes it from the driver's position and the road network, and sends it with each location fix as etaSeconds plus the time it was computed. Two naive designs fail in opposite ways: showing only the server value makes "4 min" sit still for 30 seconds and then jump to "3 min", while counting down locally from the first value and ignoring updates drifts away from reality when the driver hits traffic.
- Count down locally between updates. Store the server ETA with the local time it arrived, using
performance.now()(a monotonic clock that does not jump when the phone's clock changes). Each second, showeta - elapsed. - Accept each new server value, gently. If the new ETA differs from the local countdown by less than about 30 seconds, blend toward it; if it differs more (traffic, a detour), switch to it at once. This stops the number flickering 4, 3, 4, 3.
- Round for people. Show whole minutes ("3 min"), rounding up, and switch to "Arriving now" under about 60 seconds rather than counting seconds down to zero: real arrivals are not that precise.
- Clock time during the trip. On the trip, riders care when they will arrive, not how many minutes remain. Show "Arrive by 6:42 PM" formatted with
Intl.DateTimeFormatin the rider's locale and the local time zone. - Waiting time. After
driver_arrived, some markets give a few free waiting minutes before a fee starts. Show that countdown from a server-provided deadline, never a client guess, because the fee is charged by the server.
Payments: stored methods, card checks, and cash
Riders rarely type a card during a ride request; they pick a saved method. The card was added earlier through the payment provider's hosted fields (iframes from Stripe, Adyen, Braintree and similar), so the raw card number never touches our servers: the amazon guide's checkout chapter explains why that keeps most of the card security burden with the provider. The trip request sends only a paymentMethodId. Cash, where it is allowed, is just another method with no online step; the receipt then says "Pay ₹248 in cash".
The hard case is 3-D Secure (3DS), the extra check where the card's bank asks the cardholder to confirm in their banking app or with a one-time code. In Europe, Strong Customer Authentication rules under PSD2 make it common, and banks elsewhere may ask too. With the current version, EMV 3-D Secure, the bank first judges the risk from data the provider sends: low-risk payments pass with no rider action (the frictionless flow), and the rest get a challenge, such as a code or an approval in the banking app.
Two moments can trigger a challenge: when the card is first saved, and when a charge is made. If the bank asks at charge time, the rider might be standing at the kerb.
Decision Where and how does the rider pass a 3-D Secure check?
What we pick: Authenticate when the card is saved, and handle any charge-time challenge in-page with the provider's SDK, loaded only when the server says a challenge is needed. Support the redirect as a fallback. In every case, when the rider comes back, the client does not trust URL parameters like ?status=success; it reloads the payment and trip state from the server and follows the trip state machine from there.
GET /trips/current; the SDK result alone is not the truth.Payment failure after the ride is a trip state, not a toast. The trip moves to payment_failed and the next app open shows "Your last trip's payment did not go through" with the amount and a way to pick another method. The retry POST carries its own idempotency key, so a double tap cannot charge twice. A new ride request is blocked until the balance is settled, and the reason is stated in words.
Check your understanding · Exercise 8
A rider comes back from a 3-D Secure redirect to /trip/abc?auth=ok. What should the app do? Select all that apply.
Architecture: the pieces and their boundaries
With the hard decisions made, the architecture almost draws itself. The rule behind every boundary: the booking flow must work with only the core bundle, the server owns the trip, and anything that runs 60 times a second stays out of the UI framework.
| Piece | Responsibility | Why it is separate |
|---|---|---|
| Server-rendered shell | First HTML with the rider's state inlined: saved places, active trip if any, a static map URL. | Content on screen before any JavaScript runs; Uber's post describes server rendering with inlined state for this reason. |
| Core app (about 50 KB) | Booking panel, product list, trip status card, routes. Rendered with a small UI library. | This is the part every rider needs, so it gets the tight budget. |
| Trip store | Holds the trip mirror and its version; runs reconcile on every message; exposes the current status to screens. | One place decides the trip state, so no screen can disagree with another. |
| Live connection manager | Opens SSE, falls back to polling, backs off, pauses when hidden, fetches a snapshot after each reconnect. | Transport can change without touching trip logic. |
| Quote and request service | Fetches quotes, refreshes before expiry, sends the request with an idempotency key, checks /trips/current after unknown outcomes. | Money and side effects get their own small, well-tested module. |
| Places client | Autocomplete with session tokens, reverse geocoding with caching, pickup point suggestions. | Vendor-specific and billed per request; isolate it so it can be swapped. |
| Map adapter (lazy) | Static image mode and live map mode behind one interface: setPickup, setRoute, moveCar. | Lets the app choose the map per device and swap vendors without touching screens. |
| Car animator | Buffer of fixes and a requestAnimationFrame loop that writes the car's position and rotation directly. | Runs every frame, so it must not trigger framework renders. |
| Service worker | Caches the shell and core bundles; receives Web Push and shows notifications. | Reloads work on a flaky network, and the driver-arrived alert works with the tab closed. |
Read the data flow figure below from top to bottom. The first visit gets HTML plus inlined state from a BFF (backend for frontend: a thin server layer shaped around this one client), and the trip store is filled from that state or from GET /trips/current. After that, the live stream feeds two consumers: versioned trip events go to the trip store, and location fixes go straight to the car animator.
Writes (request, cancel, change destination, pay) go out as POST calls with idempotency keys, and their results come back as trip events like any other change. That last point is worth saying in an interview: the UI never sets a status because of a tap. If the POST response carries the trip, it goes through the same reconcile as any snapshot, so the screen changes only when the trip changes, wherever the change came from.
Check your understanding · Exercise 9
Which responsibilities are in the right place? Select all that apply.
Data model: what the client holds, and where
Sort the client's data by who owns it and how long it must live. The table is the answer; the reasons are in the last column.
| Entity | Owner | Lives in | Why there |
|---|---|---|---|
| Draft (pickup, destination, chosen product) | Client | Memory, mirrored to sessionStorage and the URL query | Survives a reload or a 3DS redirect; a shared link can prefill the destination. |
| Quotes | Server | Memory, keyed by route | Short-lived (minutes); worthless after expiry, so never persisted. |
| Trip | Server | Trip store; last copy in localStorage for an instant repaint | Must be correct, so the server snapshot always wins over the stored copy. |
| Driver fixes | Server | A buffer of the last 3 to 5 fixes in the car animator | Only the recent path matters for interpolation. |
| Pending request | Client | sessionStorage: quote id and idempotency key | Lets a reload resend with the same key or check the outcome. |
| Rider profile (saved places, payment methods, locale) | Server | Server-state cache, refreshed on app open | Changes rarely; inlined in the first HTML so the booking panel is ready. |
| Connection status | Client | Memory in the connection manager | Drives the "Reconnecting" banner; meaningless after reload. |
type TripStatus =
| "requested" | "driver_assigned" | "driver_arriving" | "driver_arrived"
| "on_trip" | "completed" | "cancelled" | "no_drivers" | "payment_failed";
type Place = { lat: number; lng: number; label: string; pickupPointId?: string };
type Money = { amount: number; currency: string }; // 24850 + "INR" = ₹248.50
type Trip = {
id: string;
version: number; // +1 on every server-side change
status: TripStatus;
pickup: Place;
destination: Place;
productId: string;
quoteId: string;
fare: Money; // the upfront price the rider accepted
driver?: { name: string; photoUrl: string; rating: number;
car: { make: string; model: string; colour: string; plate: string } };
pickupEtaSeconds?: number;
cancelledBy?: "rider" | "driver" | "system";
cancellationFee?: Money;
updatedAt: string;
};
Two choices in that type deserve a sentence each. The driver's live position is not in the Trip: it changes every few seconds, carries no version, and would make every location fix look like a trip change. And fare is the accepted upfront price, copied from the quote, so the receipt can show "Upfront price ₹248.50" even if the quote object is long gone.
Think first
Why put the draft in sessionStorage and the last trip in localStorage, rather than both in the same place?
A draft belongs to one tab's booking attempt. sessionStorage is per tab and is cleared when the tab closes, which is the right lifetime: a second tab should start its own draft, and a forgotten draft from last week should not reappear.
The last trip copy is a cache of server state that any tab may want to paint instantly on open, so localStorage (shared by all tabs of the origin, kept across sessions) fits. It is always replaced by the server snapshot within one round trip, so a stale copy does little harm.
Neither holds anything secret: no card data, no tokens. Both are readable by any script on the origin, which is one more reason to keep third-party scripts off the page.
Interface: the API the client talks to
The API is small. Most of the design lives in the rules around it: which calls are idempotent, which errors the client must handle, and what the stream sends.
| Call | Purpose | Notes |
|---|---|---|
GET /riders/me | Profile, saved places, payment methods, locale | Inlined in the first HTML; cached. |
GET /places/autocomplete?q=&session=&near= | Address suggestions | Debounced; the session token groups billing. |
GET /places/reverse?lat=&lng= | Label for the pin and nearby pickup points | Called on map stop; cached by rounded position. |
GET /cars/nearby?lat=&lng= | Nearby cars before booking | Polled every 5 to 10 s while the booking screen is visible. |
POST /quotes | Upfront fares for a route, one per product | Returns quote ids and expiresAt. |
POST /trips | Request a ride with a quote id and payment method | Requires Idempotency-Key. 409 if the quote expired, with a new quote. |
GET /trips/current | The active trip snapshot, or 204 | Called on load, reconnect, return to tab, and after unknown outcomes. |
POST /trips/:id/cancel | Rider cancels | Idempotent by trip; returns any fee before confirming if asked with ?preview=1. |
PATCH /trips/:id/destination | Change destination mid-trip | Returns a new fare quote the rider must accept. |
GET /trips/:id/stream | SSE: trip events and driver fixes | Last-Event-ID on reconnect; heartbeats every 15 s. |
POST /trips/:id/rating | Rating and tip | Idempotency key; tip is charged separately. |
event: trip
id: 42
data: {"tripId":"t_9c2","version":7,"status":"driver_assigned","driver":{...}}
event: location
data: {"tripId":"t_9c2","lat":12.97161,"lng":77.59456,"heading":84,
data: "speed":9.2,"t":"2026-10-20T11:02:13.400Z","etaSeconds":240}
: ping
{
"error": {
"code": "QUOTE_EXPIRED",
"message": "Prices have changed.",
"retryable": false,
"details": { "newQuote": { "quoteId": "q_81bd02", "fare": { "amount": 26900, "currency": "INR" } } }
}
}
The UI maps each code to a screen or a message: QUOTE_EXPIRED to the price-changed confirm, PAYMENT_METHOD_DECLINED to the payment picker, ACTIVE_TRIP_EXISTS to the current trip, OUTSIDE_SERVICE_AREA to a clear explanation on the pickup step. The message is a fallback, already translated by the server for the rider's locale. A retryable flag tells the request service whether backing off and retrying can help.
Check your understanding · Exercise 10
Which API rules protect the rider from paying for something they did not agree to? Select all that apply.
Fast on a cheap phone: bundle, battery, data, and flaky networks
Performance here is not a polish step. For a rider on a slow network with a low-end phone, a 2 MB app is an app that does not exist. The performance-budgets lesson explains how to set and enforce a budget; this chapter sets one for this app and shows where each kilobyte goes.
What Uber reported for m.uber
Uber's engineering post on rebuilding m.uber is worth knowing by name, because it is a real example of this exact product. The points it reports, in short:
- The core ride request app was 50 KB minified and gzipped, aimed at about 3 seconds to interactive on what the post calls a typical 2G network with 300 ms latency.
- They chose Preact (about 3 KB) over React (about 45 KB at the time) for size, and used webpack code splitting so screens like settings and payment options load only when opened.
- The server rendered the first view with Preact and inlined the state, so content showed before the bundles arrived.
- Heavy data formatting moved to the server so large libraries (the post names Moment) never reached the client. Icons became tuned inline SVG (the logo went from 7.4 KB as PNG to 500 bytes), and custom fonts were dropped.
- A service worker cached the HTML and bundles for instant reloads and intermittent network loss, and the latest ride status was kept in
localStorageto repaint before the API answered. - The interactive map was loaded or hidden based on measured time to interactive, with a setting to turn it off.
That post is from 2017, and the tools have moved on, but every idea still holds. In 2025 you would say the same things with today's names: server rendering or streaming with hydration, route-level import(), a small UI runtime, and a budget checked in CI.
A budget for the booking flow
| Asset | Budget (compressed) | How it stays there |
|---|---|---|
| HTML with inlined state | about 15 KB | Only the rider's profile, saved places, and active trip; no catalogue of strings for other screens. |
| Core JavaScript | 50 KB | Booking panel, trip card, trip store, connection manager. CI fails the build if it grows. |
| CSS | about 10 KB, inlined | Atomic or component-scoped styles; no unused framework CSS. |
| Static map image | 20 to 60 KB | WebP at the device's pixel ratio, fixed width and height. |
| Fonts | 0 KB | System font stack, as m.uber did. |
| Later, on demand | Hundreds of KB | Interactive map, payment provider SDK, settings, receipt history, rating screen. |
Check it against Core Web Vitals in the field, at the 75th percentile, split by device class: LCP (Largest Contentful Paint) at or under 2.5 s, INP at or under 200 ms, CLS at or under 0.1. On this app the LCP element is usually the static map image, so give it fetchpriority="high" and make sure its URL is in the HTML, not built by JavaScript.
Battery and data
- GPS only when choosing. Stop
watchPositiononce the pickup is confirmed. The driver needs the pin, not a live feed of the rider's phone (unless the product shares rider location on purpose, which needs its own consent). - Server-chosen update rates. Nearby cars every 5 to 10 s, the assigned driver every 2 to 10 s depending on how close it is, nothing while the tab is hidden.
- Draw only when something moved. The animation loop skips the DOM write when the car moved less than one pixel, and stops entirely when the car is parked or off screen.
- Respect data saver. With
saveDataon, keep the static map, lower the nearby-cars rate, and skip driver photos until tapped. - Close what you do not use. Stop polling nearby cars after the request; close the stream after the trip completes.
Offline and flaky connections: what can and cannot work
The offline-first lesson describes apps that work fully offline. A ride app cannot: a driver cannot be matched without the network. Be honest about the line.
| Works offline | Needs the network |
|---|---|
| Opening the app from the service worker cache | Quotes and prices |
| The last trip state, marked "Last updated 2 min ago" | Requesting a ride |
| Driver name, car, plate, and photo already loaded | Live driver position and ETA |
| Saved places and the draft | Cancelling or changing the destination |
The emergency number and a tel: link to call it | Sharing the live trip with a contact |
Do not queue a ride request or a cancellation to send later. A request queued in a tunnel and sent 4 minutes later books a car the rider may no longer want, at a price that has expired; a cancellation sent late may hit a trip already under way. Show a clear banner ("You're offline. We'll update your trip when you're back."), keep the last known state visible, and let reconnect plus snapshot do the rest.
Check your understanding · Exercise 11
Which changes keep the core booking flow small and fast on a low-end phone? Select all that apply.
Accessibility, safety, and many markets
A map-centric UI that does not depend on the map
A blind rider, a rider using a switch device, and a rider in bright sunlight all have the same need: the trip state in words. The rule for this app is that the map is never the only way to see anything. Everything the map shows (where the driver is, how far, which car) is also in the trip status card as text.
- Status card as the source for assistive tech. A heading with the status ("Driver on the way"), then name, car colour and model, plate, and "3 minutes away". The static map image gets alternative text such as "Map: your driver is 3 minutes away on MG Road", updated only when the status or the minute changes. The live map sits in a labelled region (for example
aria-label="Map"), and its many markers are hidden from the accessibility tree, because the status card already says what they show. - Announce changes, not ticks. A polite live region (
aria-live="polite") speaks status changes: "Driver assigned: Ravi, white Toyota Etios, KA 01 AB 1234", "Your driver has arrived". Do not announce the countdown every second or every fix; announce the ETA only when it crosses a threshold such as "1 minute away". The screen-readers lesson covers live region politeness and why too many announcements are as bad as none. - Alternatives to dragging. Dragging a pin is hard or impossible for many users. Offer the typed address and the list of suggested pickup points as real buttons, and a "Use my current location" button. WCAG 2.2 calls this out directly: anything done by dragging needs a single-pointer alternative (success criterion 2.5.7).
- Focus on screen changes. When the status moves to a new screen (searching to driver assigned), move focus to the new heading only if the rider is not typing; otherwise the live region is enough.
- Reduced motion. With
prefers-reduced-motion: reduce, drop the pulsing search animation and move the car in small steps without gliding. The data still updates; only the decoration goes. - Big targets and contrast. The Request and Cancel buttons are full width and well above WCAG 2.2's minimum target size of 24 by 24 CSS pixels; the plate number is large and high contrast because riders read it at night.
Safety features in the UI
Ride apps put safety tools on the trip screen. Uber's apps, for example, offer a safety toolkit with trip sharing and an emergency call option, and the details vary by country, so describe the patterns rather than exact features.
- Share trip status. A Share button creates a link to a limited, read-only trip page (driver first name, car, plate, live position, ETA) that expires when the trip ends. Use the Web Share API (
navigator.share) where available to open the phone's share sheet, and fall back to copying the link. - Emergency button. Always visible during
on_tripand reachable within two taps. It shows the current address, car, and plate in large text so the rider can read them to the dispatcher, and offers atel:link to the local emergency number. This screen must work from cached data when the network is gone. - Verify the car. Show the plate first and largest before pickup, because getting into the wrong car is a real risk. Some markets add a PIN the rider tells the driver; if so, show it on the arrived screen.
Currencies, right-to-left, and units
- Money. The server sends integer minor units plus an ISO 4217 currency code. Format with
Intl.NumberFormat(locale, { style: "currency", currency }), which knows that the yen has no minor unit and the Kuwaiti dinar has three. Never format money with string concatenation. - Distance and time. Kilometres or miles depend on the market, not the language, so the server says which. Format with
Intl.NumberFormatandstyle: "unit". Times useIntl.DateTimeFormatin the rider's locale, so "6:42 PM" becomes "18:42" where that is the norm. - Right-to-left. For Arabic or Hebrew, set
dir="rtl"on the root and use logical CSS properties (margin-inline-start, notmargin-left), so the panel, arrows, and progress steps mirror. The map itself does not mirror: north is still up, and street names follow the map's own rendering. - Text that grows. German and Finnish product names and status strings can be 30 to 40 percent longer than English. The status card must wrap, not truncate the plate or the ETA.
Check your understanding · Exercise 12
A screen reader user is waiting for their driver. Which behaviours help them? Select all that apply.
Running it in the interview
Nobody covers all of this in 60 minutes. The interviewer is looking for three things: that you find the trip state machine and the server's ownership of it early, that you go deep on two hard parts with real mechanics, and that you keep the low-end device in mind the whole time. Here is a plan for 60 minutes; for 45, shorten the deep dives.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 6 | Requirements. Ask about devices and networks, multiple devices, upfront pricing, payment methods, and markets. | Write four numbers: 50 KB core, about 3 s to usable on a slow network, a driver fix every 2 to 10 s (4 s by default), INP 200 ms. |
| 6 to 9 | The hard parts in one breath. | "Server-owned trip, client mirror with versions; smooth car from sparse fixes; idempotent request; map loaded last." |
| 9 to 20 | The trip state machine and the reconcile rules. | The states diagram, then snapshot plus versioned events: duplicate, late, and gap cases. |
| 20 to 28 | Architecture and data flow. | Shell, core, trip store, connection manager, car animator, map adapter, service worker. |
| 28 to 40 | Deep dive: request safely, or the moving car. | The request sequence with timeout and idempotency key, or the interpolation timeline with the delay buffer. |
| 40 to 50 | Transport and hidden tabs. | SSE with polling fallback, backoff with jitter, snapshot after reconnect, Web Push for driver arrived. |
| 50 to 60 | Performance, accessibility, safety, then questions. | The budget table, the static map first, the status card as text, the live region, share and emergency. |
Follow-up questions and strong answers
Interviewers use the last third to push on an edge. Try each question before opening the answer. A strong reply names the failure first, then the mechanism that handles it, then the cost.
Think first
The driver cancels while the rider is watching the car approach. What does the rider see, step by step?
The server moves the trip to cancelled with cancelledBy: "driver", or, in many designs, straight back to requested while it finds another driver, and bumps the version. The event arrives on the stream and the trip store applies it.
The screen changes to "Your driver cancelled. Finding you another driver" with no fee, the old car disappears from the map, and the live region announces the change. If the server gives up, the trip moves to no_drivers with a one-tap retry that fetches fresh quotes.
Location fixes for the old driver that arrive late are dropped, because fixes carry the trip id and driver id, and the animator only accepts fixes for the currently assigned driver.
Think first
The rider has the app open in two tabs on the same laptop. Do you open two streams?
It works with two, because each tab reconciles independently against server versions, so correctness is safe. The cost is double the connections and data.
To save it, one tab can hold the stream and share messages with the others over a BroadcastChannel, with a simple leader choice using the Web Locks API (navigator.locks.request). When the leader tab closes, its lock is released and another tab takes over.
For a rider app this is an optimization, not a requirement. Mention it, and say you would add it only if field data shows many multi-tab sessions.
Think first
The rider's phone clock is 3 minutes fast. What breaks, and how do you guard against it?
Quote expiry checks think quotes expired 3 minutes early, so the app refreshes prices constantly or blocks the Request button. Countdowns based on server deadlines (waiting time) are off by 3 minutes.
The guard: compute a clock offset from the server's Date header (or a serverTime field) on the first response, and compare every server timestamp against Date.now() + offset. For elapsed time on the page, use performance.now(), which is monotonic and not affected by clock changes.
The server stays the judge: even if the client thinks a quote is valid, the server answers QUOTE_EXPIRED and the client shows the new price.
Think first
On New Year's Eve, a million riders have the booking screen open. How does the client help the backend survive?
Nearby cars are a hint, so the client polls them slowly (every 10 s), only while the tab is visible, and accepts a Retry-After header that tells it to slow down further. The server can also return the same cached answer for everyone in a map cell.
Quote requests are made only when both places are set and are aborted when they change, so typing does not create quote load. Autocomplete is debounced and uses session tokens.
Reconnect storms are spread out with jittered backoff, and the stream accepts a server hint to fall back to slower polling. The client is a good citizen by design, not by luck.
Think first
Why build a web rider app at all when Uber has native apps?
Reach. Riders with low storage, older phones the native app no longer supports, or a one-off need (a tourist, a shared laptop) can book from a link with no install. Uber's post on m.uber gives exactly this reason: requesting a ride quickly regardless of location, network speed, and device.
The cost is fewer capabilities: no reliable background location, push notifications that depend on the browser and platform, and less battery headroom. The design accepts those limits and plans around them.
Check your understanding · Exercise 13
You have 10 minutes left and the interviewer asks what you would monitor in production. Which signals are most useful for this app? Select all that apply.
Summary
- The trip is a state machine owned by the server. The client mirrors it, asks for changes with
POST, and never moves the status on its own. - Reconcile with snapshots plus versioned events: ignore old or duplicate versions, fetch a snapshot on any gap, and fetch one after every reload, reconnect, and return to the tab. The same path handles a second device.
- Keep trip events and driver locations as separate streams: trip events are versioned and must be repaired if lost; location fixes are disposable, and the newest wins.
- Make the car glide by interpolating between timestamped fixes with a small render delay, rotating by the shortest angle, and dead reckoning only briefly; positions come snapped to roads by the server.
- Prices are quotes with an id and an expiry, and the request carries the quote id. Requests carry an idempotency key that survives retries and reloads, and an unknown outcome is resolved by asking the server, never by guessing.
- Book first, map later: a small server-rendered core, a static map image as the first paint, and the interactive map only when the device and network can afford it.
- Hidden tabs get no animation and, after a short while, no stream; Web Push tells the rider the driver has arrived.
- The map is never the only way to see the trip: a text status card, polite announcements of status changes, alternatives to dragging, and safety tools that work from cached data.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Building m.uber: Engineering a High-Performance Web App for the Global Market (Uber Engineering) Uber's own account of the 50 KB core, Preact, server rendering, service workers, and the optional map.
- Geolocation API (MDN) Positions, accuracy, options, and the secure-context requirement.
- Using server-sent events (MDN) The event stream format,
EventSource, reconnection, andLast-Event-ID. - Page Visibility API (MDN) Detecting hidden tabs to pause work and save battery.
- Push API (MDN) Push messages to a service worker when no tab is open.
- Idempotent requests (Stripe API reference) A widely used design for
Idempotency-Keyheaders and safe retries. - Session tokens for Autocomplete (New) (Google Maps Platform) How Autocomplete (New) requests and the final Place Details (New) request are grouped into one billed session.
- Understanding Success Criterion 2.5.7: Dragging Movements (W3C) Why a draggable pin needs a single-pointer alternative.
- Web Vitals (web.dev) LCP, INP, and CLS thresholds and how to measure them in the field.
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.
- Choosing a real-time techniqueHow to pick between short polling, long polling, server-sent events, WebSockets, and WebRTC for each feature, starting from who talks, how often, and how fresh the data must be, then checking scale, proxies, battery, and the fallback you will ship.
- 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.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Performance budgets and monitoringAgreed limits on what a page may cost (kilobytes of JavaScript, LCP, INP) that CI checks on every pull request and real-user monitoring watches in production, so speed stops getting worse one small change at a time.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.