cf.completefrontend
My progress
SYSTEM DESIGN · #97

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.

QuestionAnswer we assumeWhat 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.

Wireframe of the booking screen on a phone. At the top, a box reads Pickup: Gate 3, and below it a box reads Where to. Behind them, the map shows a pickup pin in the centre and three small car squares nearby. At the bottom, a sheet lists three products: UberX for 248 rupees, 4 minutes away, selected; Comfort for 312 rupees, 6 minutes; XL for 401 rupees, 8 minutes. Under them, a full-width Request UberX button. Notes on the right name each part: the address boxes with saved places first, the map as a static image with the live map loading later, the products with upfront fares and pickup ETAs, and the Request button guarded by an idempotency key.m.uber.comPickup: Gate 3Where to?PUberX ₹248 4 minComfort ₹312 6 minXL ₹401 8 minRequest UberXAddress boxes,saved places firstMap: static image,live map loads laterProducts with upfrontfare and pickup ETARequest: one tap,one idempotency key
Everything the rider needs to book is outside the map: the boxes, the product list, and the button work before the map has loaded at all.

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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?

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 isSearching is false, driver is 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.

State machine of a trip. A dashed box, quoting, is owned by the client. An arrow leads to requested, then assigned, then arriving, then arrived, then on_trip, then completed. From completed an arrow leads to payment_failed. From requested an arrow leads down to no_drivers. Arrows from requested, assigned, arriving, and arrived lead into cancelled. A curved arrow from assigned back to requested is labelled driver cancels: find another. Solid boxes are owned by the server.quotingclient onlyrequestedfinding driverassigneddriver acceptedarrivingcar movingno_driversgave upcancelledrider or driverarrivedat pickuppayment_failedfix methodcompletedrate, receipton_tripridingdriver cancels: find anotherdashed box:client onlyserver ownssolid boxes
Only the dashed box belongs to the browser. Every solid box is a status the server sets; the client asks for changes and then mirrors whatever the server says. Ended states are drawn green or red.
StatusWho moves it hereWhat the rider sees
draft (client only)The rider, by picking placesPickup and destination boxes, the map, nearby cars.
quoting (client only)The client, once both places are setProduct list with fares and ETAs, a Request button.
requestedThe server, after accepting the request"Finding your driver" with a pulsing marker and a Cancel button.
driver_assignedThe server, when a driver acceptsDriver name, photo, car, plate, rating, and a pickup ETA.
driver_arrivingThe server, as the driver movesThe car moving on the map, the ETA counting down.
driver_arrivedThe server, when the driver taps Arrived"Your driver is here", maybe a waiting-time notice.
on_tripThe server, when the driver starts the tripRoute to the destination, arrival time, share and safety buttons.
completedThe server, when the driver ends the tripFare, receipt, rating, and tip.
cancelledThe server, after a rider or driver cancelsWho cancelled, any fee, and a one-tap "Request again".
no_driversThe server, when matching gives up"No drivers available", with try again or another product.
payment_failedThe server, when the charge failsFix 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?

Client-driven transitions

The client moves its own state machine on button taps and on socket messages.

Server messages are treated as commands: "go to driver_assigned".

Pros

  • Instant feedback.
  • Simple to start with.

Cons

  • Late or repeated messages push the machine into wrong states.
  • Nothing survives a reload; nothing syncs a second device.

Poll the full trip

Every 3 to 5 seconds, GET /trips/current and replace the local copy.

Pros

  • Always converges to the truth.
  • Reload and second device just work.

Cons

  • Changes show up seconds late.
  • Hundreds of wasted requests per trip, bad for data and battery.

Snapshot plus versioned events

On load, on reconnect, and on returning to the tab, fetch the full trip snapshot with its version.

Between snapshots, apply pushed events only if their version is newer than the one held.

If an event skips a version, fetch a snapshot instead of guessing.

Pros

  • Fast like events, correct like polling.
  • Duplicate and out-of-order events are harmless.
  • Reload, second device, and offline gaps all use the same path.

Cons

  • The server must stamp every trip change with a version number.
  • Two code paths to test: snapshot and event.

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.

ts
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 };
}
One function decides what to do with anything the server sends about the trip.

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.

Client rule
Trip state explorer. The server's trip has eight versions and is at version 1. The client holds version 1, requested. The client matches the server.Server versionsv1v2v3v4v5v6v7v8server: v1 requestedClient mirrorv1: requestedv1 requested: applied
Server at v1: requested. Client holds v1: requested. The client matches the server.

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.

Sequence diagram with four columns: Phone tab, Trip store, Trip API, and Laptop. The phone tab loads and the trip store asks GET /trips/current. The API replies with version 8, driver_arriving. The store renders version 8 and opens the event stream. The rider taps cancel on the laptop, which posts cancel to the API; the API replies version 9, cancelled. The API pushes version 9, cancelled, to the phone's trip store over the stream. A late version 7 event then arrives and is ignored. The phone tab shows cancelled.Phone tabTrip storeTrip APILaptoppage loadGET /trips/currentv8 driver_arrivingrender v8, open SSEPOST cancelv9 cancelledSSE: v9 cancelledlate v7: ignoredshow cancelled
The phone never saw the tap on the laptop, and still ends up right. A reload and a second device use the same path: snapshot first, then only newer versions.

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 accuracy field of GeolocationCoordinates tells 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.

ts
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
}
A fast rough fix first, then a precise one. Each call has a timeout so the UI never waits forever.

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?

Raw GPS fix

Send the device coordinates as the pickup.

Pros

  • Zero effort for the rider.

Cons

  • Off by tens or hundreds of metres indoors.
  • May land in the middle of a building or a road the car cannot stop on.

Draggable pin

A pin fixed at the centre of the map; the rider moves the map under it.

When the map stops moving, reverse geocode the centre into a label.

Pros

  • Precise when the rider knows the area.
  • Works with any starting position, even IP-based.

Cons

  • Fiddly on a small screen.
  • The rider can still choose an illegal or unreachable spot.

Suggested pickup points

The server returns a few known good spots near the position: named entrances, kerbs, ride-share zones at airports.

The pin snaps to the nearest one when the map stops near it.

Pros

  • Spots that drivers can actually reach.
  • Clear names ("Gate 3, North entrance") that both sides understand.

Cons

  • Needs server-side data about each venue.
  • Snapping must be gentle or riders feel the pin fight them.

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.

Sequence diagram with four columns: Rider, Map, Pickup logic, and Places API. The rider drags the map. The map fires moveend with centre A, and the pickup logic asks the Places API to reverse geocode A. The rider drags again; the map fires moveend with centre B. The pickup logic aborts the request for A and asks for B. The API replies that B is near Gate 3. The pickup logic snaps the pin to Gate 3 on the map, and the rider sees the label Pickup: Gate 3.RiderMapPickup logicPlaces APIdrag mapmoveend: centre Areverse Adrag againmoveend: centre Babort A, reverse BB: near Gate 3snap pin to Gate 3Pickup: Gate 3
One lookup per stop, not per frame. The stale lookup for A is aborted with an AbortController, so it can never overwrite the label for B.

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?

Full maps SDK on load

Load Google Maps JavaScript API or Mapbox GL JS in the head, draw the map, then the booking panel on top.

Pros

  • Rich map: smooth zoom, vector tiles, traffic, 3D buildings.
  • Routing, geocoding, and places from one vendor.

Cons

  • Hundreds of kilobytes of script plus tiles before first use.
  • WebGL on low-end GPUs drains battery and can stutter.
  • Booking is blocked on the slowest part of the page.

Light raster tile renderer

A small library such as Leaflet (its README says about 40 KB of gzipped JavaScript) or a custom renderer that places 256-pixel tile images in a grid.

Pros

  • Much less code and no WebGL.
  • Enough for a pin, a few cars, and a route line.

Cons

  • Fewer features; rotation and smooth zoom are limited.
  • You still need a tile provider and attribution.

Static image first, interactive later

The server renders the first HTML with a static map image of the rider's area (a single PNG or WebP from a static maps API, about 20 to 60 KB).

After the booking panel is interactive, load the interactive map in the background and swap it in when ready, only if the device and network can afford it.

Pros

  • Something real on screen at first paint, with no script.
  • Booking never waits for the map.
  • Low-end devices can stay on static images for the whole session.

Cons

  • Two map modes to build and test.
  • The swap must not shift the layout or lose the pin position.

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.

Two loading timelines on a slow network, from 0 to 10 seconds. Map first: HTML until 1.2 seconds, then the maps SDK and tiles until 5.6 seconds, then the app JavaScript until 7.8 seconds; the rider can book at about 8 seconds. Static first: HTML until 1 second with a static map image from 0.6 to 2.2 seconds, the core JavaScript from 1 to 2.6 seconds, and the rider can book at about 3 seconds. The live map loads afterwards, from 3.5 to 8 seconds, only if the device allows.Map first: SDK in the headHTMLmaps SDK + tilesapp JSbook: 8 sStatic first: booking, then mapHTMLcore JSbook: 3 smap imglive map, later0 s5 s10 s
Illustrative timings for a slow connection. The same bytes arrive in both rows; the order decides when the rider can book.

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?

Jump to each update

Set the marker position when a message arrives.

Pros

  • Trivial, always shows the latest known position.

Cons

  • Teleporting car, frozen between updates.
  • Looks broken, especially at low update rates.

Interpolate with a delay buffer

Keep the last two or three fixes with their timestamps.

Render the car slightly in the past, at "now minus one update interval plus a margin for network delay", moving along the line between the two fixes that surround that time, on every animation frame.

Pros

  • Smooth and always on a path the car really drove.
  • A fix that arrives late, within the margin, is absorbed by the buffer.

Cons

  • The car on screen is a little more than one interval behind reality (about 5 seconds at one fix every 4 seconds).
  • If an update is later than the margin, the car stops at the last fix.

Dead reckoning

From the last fix, its speed, and its heading, predict where the car is now and keep moving it.

When a new fix arrives, blend from the predicted spot to the corrected path over a few hundred ms.

Pros

  • No built-in delay: the car looks current.
  • Keeps moving through late updates.

Cons

  • Overshoots when the car stops at a light or turns.
  • Corrections can look like the car sliding backwards.

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.

Draw by
Fix everyJitter
A route runs east along the bottom and turns north at a corner. There is a red light at 168 metres where the car waits from 14 to 20 seconds. 4 position fixes have arrived. jump to each fix, a fix every 4 s, jitter up to 1 s. At 16.0 s the drawn car is 24 m from the real one. Over the whole run: worst error 64 m, average 29 m, 8 visible jumps of more than 8 m in one tenth of a second.red lightTCT: real carC: drawn cargap: 24 mworst: 64 maverage: 29 mjumps: 8
jump to each fix, a fix every 4 s, jitter up to 1 s. At 16.0 s the drawn car is 24 m from the real one. Over the whole run: worst error 64 m, average 29 m, 8 visible jumps of more than 8 m in one tenth of a second.

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.

ts
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;
}
Called on every animation frame. The buffer holds fixes sorted by time.

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.

Timeline from 0 to 15 seconds. The driver's phone measures fixes F1 to F4 at 0, 4, 8, and 12 seconds. The browser receives them up to about a second later, at 0.3, 4.9, 8.4, and 12.8 seconds. The delay is 5 seconds: the 4-second interval plus a 1-second margin. Now is 14.5 seconds and the car is drawn at 9.5 seconds, which is between F3 and F4, so the car sits three eighths of the way from F3 to F4.phone measuresF1F2F3F4browser getsF1F2F3F4delay: 5 s0 s4 s8 s12 sdrawn: 9.5 snow: 14.5 scar = F3 + 3/8 of the way to F4
The render clock runs one interval plus a margin behind real time, so a fix is needed only one second after it was measured. Late arrivals within the margin, like F2 at 4.9 s, cause no stop and no jump.

How often should positions arrive?

Update everyData per 10-minute wait (about 120 bytes each)Delay with buffer (interval plus about 1 s margin)How it looks
1 sabout 72 KBabout 2 sSmooth, but costly for data and the radio on both phones.
4 sabout 18 KBabout 5 sSmooth with interpolation; a good default for the assigned driver.
10 sabout 7 KBabout 11 sFine 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.

StreamRateIf one message is lostOrdering rule
Trip events (status, driver details, fare changes)About 5 to 15 per tripMust be repaired: refetch the snapshot.Versioned; apply only the next version.
Driver location (position, heading, ETA)One every 2 to 10 secondsHarmless: 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?

Short polling

GET /trips/current every 3 to 5 seconds while a trip is active.

Pros

  • Works through every proxy and network.
  • Each response is a full snapshot, so it self-heals.

Cons

  • A change shows up 0 to 5 seconds late.
  • About 120 to 200 requests per 10 minutes, most saying nothing new; each one wakes the radio.

Server-Sent Events

One long GET returning text/event-stream; the server writes trip events and location fixes as they happen.

Rider actions (cancel, change destination) go as normal POST requests.

Pros

  • Server to client is exactly the direction we need.
  • Plain HTTP; multiplexed over HTTP/2.
  • EventSource reconnects by itself and sends Last-Event-ID.

Cons

  • Some proxies buffer the stream; needs heartbeats.
  • Text only (fine for small JSON).

WebSocket

One socket per tab for both streams, plus rider actions and in-app chat with the driver.

Pros

  • Two-way and low overhead per message.
  • Natural if the product already has chat.

Cons

  • Reconnect, resume, and heartbeats are all yours to build.
  • Needs a socket gateway that scales separately from HTTP servers.

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.

State machine of the live connection. Connecting leads to open when the stream opens. Open leads to paused when the tab is hidden for 30 seconds, and paused leads back to connecting when the tab is visible again or the device comes online. Open leads to backing off on an error or 45 seconds of silence. Backing off leads back to connecting when its timer fires, and to polling after 3 failures. Polling tries the stream again every few minutes. Each time the connection opens, the client fetches a trip snapshot.connectingEventSourceopenSSE + heartbeatpausedtab hiddenpollingGET every 5 sbacking_off1 to 30 s, jitteropenshidden30 svisible again, or onlineerror or 45 s silenttimer fires3 failsretryeach time it opens:fetch the snapshot
Every path into open goes through one rule: fetch the snapshot first. That makes reconnects correct no matter how long the connection was down or which events were missed.
  • 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 online event or visibilitychange to 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.

json
{
  "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"
}
One product in the quote response. Money is an integer in minor units, never a float.
  • 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 response Date header 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".
Timeline of fare quotes over six minutes. Quote q1 at 248 rupees is valid from 0 to 2 minutes. At 1 minute 45 seconds, 15 seconds before it expires, the app fetches q2 at 248 rupees, valid until 3 minutes 45. At 2 minutes 10 the rider leaves the tab. At 4 minutes 30 the rider returns; q2 has expired, so the app dims the prices and fetches q3 at 269 rupees. At 5 minutes the rider requests, and the request carries q3.q1 ₹248q2 ₹248q3 ₹269tab hidden123402 min4 min6 min1 refresh 15 s before expiry2 rider leaves the tab3 back: q2 expired, refetch4 request carries q3
The price on screen is always a live quote. When the price changed while the rider was away, the confirm step says so before the request is sent.

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?

Disable the button

Set the button to disabled on the first tap until the response arrives.

Pros

  • Stops double taps on one page.

Cons

  • Does nothing for a retry after a timeout, a reload mid-request, or a second device.

Server dedupe by rider

The server refuses a new trip if the rider already has an active one.

Pros

  • A good business rule anyway.

Cons

  • Cannot tell a retry (return the existing trip) from a real second request (show an error).
  • Breaks if the first trip was just cancelled.

Idempotency key per intent

When the rider taps Request, the client creates a random key (crypto.randomUUID()) and stores it with the quote id in sessionStorage.

Every send of that request carries the same Idempotency-Key header. The server stores the key with the result and, on a repeat, returns the same trip.

Pros

  • Retries after timeouts are safe by design.
  • A reload can resend with the stored key.

Cons

  • The server must keep keys for a while (for example 24 hours).
  • The client must reuse the key on retry and create a new one only for a new intent.

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.

ts
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 key is created once per intent and reused by every retry, even after a reload.

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.

Sequence diagram with four columns: Rider, App, Request service, and Trips API. The rider taps Request. The app passes key k1 and quote q3 to the request service, which posts to the Trips API with key k1. The response is lost to a timeout. The rider taps again and the app ignores it because it is already requesting. The request service retries with the same key k1. The API returns the stored trip t9. The request service hands trip t9, status requested, to the app, which shows Finding your driver.RiderAppRequest svcTrips APItap Requestkey k1, quote q3POST, key k1timeout: losttap again: ignoredretry, same k1stored trip t9t9: requestedFinding your driver
The failure path: the first response never arrives, but the trip was created. Because the retry reuses key k1, the server returns the same trip instead of booking a second car.

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, show eta - 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.DateTimeFormat in 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?

Full-page redirect at request time

The server answers the trip request with "authentication required" and a bank URL; the page navigates there and the bank sends the rider back to a return URL.

Pros

  • Works with every bank page.

Cons

  • The app unloads mid-request; all state must survive the round trip.
  • Slow on a weak network; riders lose context.

In-page challenge

The provider's SDK shows the bank page in a modal iframe, or the rider confirms in the bank app, and resolves a promise when done.

Pros

  • The app stays loaded; the request continues.
  • Shorter and clearer for the rider.

Cons

  • Loads the provider's SDK (lazy load it only when needed).
  • Some flows still fall back to a redirect.

Authenticate when the card is saved

Run the check once when the card is added, so the provider can reuse that authentication for later charges where the rules allow it.

Pros

  • Most rides need no challenge at all.

Cons

  • Banks may still ask again at charge time, so the other paths are still needed.

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.

Sequence diagram with four columns: App, Trips API, Provider SDK, and Bank. The app posts the trip request with key k2. The API replies that 3-D Secure is needed and returns a client secret. The app loads the provider SDK and asks it to confirm. The SDK shows the bank's challenge; the bank replies approved. The SDK tells the app it is done. The app then asks the Trips API for the current trip and gets version 1, requested.AppTrips APIProvider SDKBankPOST /trips, key k2needs 3DS: secretload SDK, confirmchallengeapproveddoneGET /trips/currentv1 requested
The provider SDK loads only when the server asks for a challenge. After it says done, the app still asks the server with 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.

Architecture of the rider web app. In the browser, four rows of parts. Row one: Core app, the screens in about 50 KB, sends requests to the Request service, which fetches quotes and posts with an idempotency key to the Trips API. Row two: the Trip store holds the mirror and its version and feeds the Core app; the Connection manager receives events and fixes from the SSE stream and passes trip events to the Trip store. Row three: the Connection passes fixes to the Car animator, which runs a requestAnimationFrame loop and calls moveCar on the Map adapter; the Map adapter gets tiles from the maps vendor. Row four: the Places client uses session tokens, and the Service worker caches the shell and receives pushes from the push service.BrowserCore appscreens, 50 KBRequest svcquotes, keyTrip storemirror + versionConnectionSSE, poll, pauseCar animatorrAF, no rendersMap adapterstatic or livePlaces clientsession tokensService workercache, pushrequesteventsmoveCarServersTrips APIRESTSSE streamevents, fixesMaps vendortilesPush serviceWeb PushPOSTSSEtilespush
Each boundary has a reason: the trip store is the only place that decides state, the animator keeps 60 frames a second out of the UI framework, and the map adapter hides whether the map is a static image or a live map.
PieceResponsibilityWhy it is separate
Server-rendered shellFirst 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 storeHolds 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 managerOpens 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 serviceFetches 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 clientAutocomplete 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 animatorBuffer 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 workerCaches 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.

Data flow for the trip screen, in four layers. At the top, the trip and location services. The BFF (backend for frontend) sends a trip snapshot as HTML with inlined state to the Trip store, which gives the status to the Status card. The SSE gateway sends events and fixes as an event stream to the Connection, which passes trip events to the Trip store and fixes to the Car animator. On the write side, a tap on the Request button goes to the Request service, which sends POST with an idempotency key to the Trips API, which creates the trip in the services. The new trip comes back down as an event.Trip and location servicesBFFSSR + snapshotSSE gatewayone streamTrips APIRESTTrip storereconcileConnectionSSE or pollRequest svckey, retriesStatus cardCar animatorRequest buttontrip snapshotevents + fixescreateHTML + statestreamPOST + keyeventsstatusfixestap
Reads flow down, writes flow up, and nothing loops back on its own: the result of a write reaches the screen the same way as any other change, as a trip event through the stream.

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.

EntityOwnerLives inWhy there
Draft (pickup, destination, chosen product)ClientMemory, mirrored to sessionStorage and the URL querySurvives a reload or a 3DS redirect; a shared link can prefill the destination.
QuotesServerMemory, keyed by routeShort-lived (minutes); worthless after expiry, so never persisted.
TripServerTrip store; last copy in localStorage for an instant repaintMust be correct, so the server snapshot always wins over the stored copy.
Driver fixesServerA buffer of the last 3 to 5 fixes in the car animatorOnly the recent path matters for interpolation.
Pending requestClientsessionStorage: quote id and idempotency keyLets a reload resend with the same key or check the outcome.
Rider profile (saved places, payment methods, locale)ServerServer-state cache, refreshed on app openChanges rarely; inlined in the first HTML so the booking panel is ready.
Connection statusClientMemory in the connection managerDrives the "Reconnecting" banner; meaningless after reload.
ts
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;
};
The trip mirror. Money is in minor units; times are ISO strings from the server.
Data model sketch in two zones. Server-owned data the client mirrors: Rider profile in a server cache, inlined in the HTML; Quote in memory, with an expiry; Trip in the trip store with a copy in localStorage; Driver fixes in the animator's buffer of 3 to 5. Client-owned data: Draft in sessionStorage and the URL; Pending request in sessionStorage with its idempotency key; Connection status in memory only. Arrows: the draft's places produce quotes; a quote's id and fare are copied into the trip; the pending request's key resolves to a trip.Server-owned, mirroredRider profileserver cache, inlinedQuotememory, expiresTriptrip store + localStorageDriver fixesanimator buffer, 3 to 5Client-ownedDraftsessionStorage + URLPending requestsessionStorage: keyConnectionmemory onlyplaces to quotequoteId, farekey
Driver fixes sit apart from the trip on purpose: they change every few seconds and carry no version, so they never count as a trip change.

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?

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.

CallPurposeNotes
GET /riders/meProfile, saved places, payment methods, localeInlined in the first HTML; cached.
GET /places/autocomplete?q=&session=&near=Address suggestionsDebounced; the session token groups billing.
GET /places/reverse?lat=&lng=Label for the pin and nearby pickup pointsCalled on map stop; cached by rounded position.
GET /cars/nearby?lat=&lng=Nearby cars before bookingPolled every 5 to 10 s while the booking screen is visible.
POST /quotesUpfront fares for a route, one per productReturns quote ids and expiresAt.
POST /tripsRequest a ride with a quote id and payment methodRequires Idempotency-Key. 409 if the quote expired, with a new quote.
GET /trips/currentThe active trip snapshot, or 204Called on load, reconnect, return to tab, and after unknown outcomes.
POST /trips/:id/cancelRider cancelsIdempotent by trip; returns any fee before confirming if asked with ?preview=1.
PATCH /trips/:id/destinationChange destination mid-tripReturns a new fare quote the rider must accept.
GET /trips/:id/streamSSE: trip events and driver fixesLast-Event-ID on reconnect; heartbeats every 15 s.
POST /trips/:id/ratingRating and tipIdempotency key; tip is charged separately.
text
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
What the stream sends. Trip events carry a version; location fixes carry the driver's measured time.
json
{
  "error": {
    "code": "QUOTE_EXPIRED",
    "message": "Prices have changed.",
    "retryable": false,
    "details": { "newQuote": { "quoteId": "q_81bd02", "fare": { "amount": 26900, "currency": "INR" } } }
  }
}
One error shape for every call, with a machine-readable code the UI switches on.

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 localStorage to 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

AssetBudget (compressed)How it stays there
HTML with inlined stateabout 15 KBOnly the rider's profile, saved places, and active trip; no catalogue of strings for other screens.
Core JavaScript50 KBBooking panel, trip card, trip store, connection manager. CI fails the build if it grows.
CSSabout 10 KB, inlinedAtomic or component-scoped styles; no unused framework CSS.
Static map image20 to 60 KBWebP at the device's pixel ratio, fixed width and height.
Fonts0 KBSystem font stack, as m.uber did.
Later, on demandHundreds of KBInteractive 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 watchPosition once 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 saveData on, 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 offlineNeeds the network
Opening the app from the service worker cacheQuotes and prices
The last trip state, marked "Last updated 2 min ago"Requesting a ride
Driver name, car, plate, and photo already loadedLive driver position and ETA
Saved places and the draftCancelling or changing the destination
The emergency number and a tel: link to call itSharing 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_trip and 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 a tel: 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.NumberFormat and style: "unit". Times use Intl.DateTimeFormat in 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, not margin-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.

MinutesWhat to doWhat to draw or say
0 to 6Requirements. 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 9The hard parts in one breath."Server-owned trip, client mirror with versions; smooth car from sparse fixes; idempotent request; map loaded last."
9 to 20The trip state machine and the reconcile rules.The states diagram, then snapshot plus versioned events: duplicate, late, and gap cases.
20 to 28Architecture and data flow.Shell, core, trip store, connection manager, car animator, map adapter, service worker.
28 to 40Deep 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 50Transport and hidden tabs.SSE with polling fallback, backoff with jitter, snapshot after reconnect, Web Push for driver arrived.
50 to 60Performance, 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?

Think first

The rider has the app open in two tabs on the same laptop. Do you open two streams?

Think first

The rider's phone clock is 3 minutes fast. What breaks, and how do you guard against it?

Think first

On New Year's Eve, a million riders have the booking screen open. How does the client help the backend survive?

Think first

Why build a web rider app at all when Uber has native apps?

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.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next