Design a Ride-Hailing Rider App
Requirements
You design the rider side of a ride-hailing app, like Uber or Lyft. The rider picks a destination, requests a car, and watches the driver come on a map.
Functional
- Set pickup (default: current location) and destination.
- See nearby cars and a price estimate before requesting.
- Request a ride. Cancel a ride.
- See the assigned driver moving live on the map, with an ETA (estimated time of arrival).
- Trip states: searching, driver assigned, driver arrived, on trip, completed.
- Notifications for key moments ("Your driver has arrived"), also on the Lock Screen.
- Rate the driver at the end. (Payment details are out of scope.)
Non-functional
- The car moves smoothly on the map, not in jumps.
- The trip state is always correct, even after the app is killed and opened again.
- Never book two rides by mistake on a bad network.
- Battery friendly: use GPS only when needed.
- Privacy: "When In Use" location permission only.
Out of scope
- The driver app, the matching (dispatch) algorithm, payment processing, map tiles.
Questions to ask the interviewer
- How often does the server send the driver's location? Every 2 seconds? 5?
- Does the driver need the rider's live location for pickup?
- Scheduled rides, multiple stops, shared rides?
- MapKit or a third-party map SDK?
- Which countries? (Some need cash payment or special pickup rules.)
High-level architecture
+----------------+ +--------------------+ +----------------------+
| MapScreen |<--->| TripViewModel |<--->| TripStateMachine |
| MKMapView + | | @Observable | | pure Swift, tested |
| bottom sheet | +----+----------+----+ +----------------------+
+-------+--------+ | |
^ | v
| | +----------------------+
+-------+--------+ | | TripAPI (REST) | estimate, request,
| DriverMarker | | | | cancel, active trip
| Animator | | +----------------------+
+-------+--------+ v
^ +--------------------+
+-----------| LiveTripChannel | WebSocket: trip updates +
| | driver locations
+--------------------+
+--------------------+ +----------------------+
| LocationService | pickup location | APNs + Live Activity|
| CLLocationUpdate |---------------------->| (app in background) |
+--------------------+ +----------------------+Key rule: the server is the source of truth for the trip. The app shows a copy of the server's state. It never decides on its own that a trip is "on trip" or "completed".
| Part | Job |
|---|---|
TripViewModel | Glues everything. Turns trip state into what the screen shows. |
TripStateMachine | Applies server updates in order, drops stale ones, checks transitions. |
LiveTripChannel | WebSocket for trip events and driver positions while a trip is active. |
DriverMarkerAnimator | Moves the car icon smoothly between position updates. |
LocationService | Gets the rider's location with the lowest accuracy that is good enough. |
TripAPI | REST calls: estimate, request (with idempotency key), cancel, get active trip. |
Data model
struct Coordinate: Codable, Sendable, Equatable {
let lat: Double
let lng: Double
}
struct DriverLocation: Codable, Sendable {
let coordinate: Coordinate
let heading: Double // degrees, 0 = north
let timestamp: Date
let seq: Int64 // grows with each update: drop old ones
}
struct Driver: Codable, Sendable {
let id, name, carModel, plate: String
let rating: Double
let photoURL: URL
}
struct Money: Codable, Sendable {
let amountMinor: Int // cents; never use Double for money
let currency: String // "USD"
}
enum TripState: String, Codable, Sendable {
case searching, driverAssigned, driverArrived, onTrip
case completed, cancelled, noDriversFound
}
struct Trip: Codable, Sendable, Identifiable {
let id: String
var state: TripState
let pickup: Coordinate
let dropoff: Coordinate
var driver: Driver?
var etaSeconds: Int?
var fare: Money?
var version: Int // the server adds 1 on every change
}
struct TripRequest: Codable, Sendable {
let pickup: Coordinate
let dropoff: Coordinate
let productID: String // "economy", "xl", ...
}version lets the app ignore old or repeated updates. seq does the same for driver positions.
API / interfaces
Server endpoints
POST /v1/estimates { pickup, dropoff } -> [{ productID, fare, etaSeconds }]
GET /v1/drivers/nearby?lat=..&lng=.. -> [Coordinate] (before a trip)
POST /v1/trips Idempotency-Key: <uuid> -> Trip
GET /v1/trips/active -> Trip or 204
POST /v1/trips/{id}/cancel -> Trip
wss://rt.example.com/v1/trips/{id} -> events: tripUpdated, driverMovedSwift protocols
enum TripEvent: Sendable {
case tripUpdated(Trip)
case driverMoved(DriverLocation)
}
protocol TripAPI: Sendable {
func estimate(pickup: Coordinate, dropoff: Coordinate) async throws -> [Estimate]
func requestTrip(_ request: TripRequest, idempotencyKey: UUID) async throws -> Trip
func activeTrip() async throws -> Trip?
func cancel(tripID: String) async throws -> Trip
}
protocol LiveTripChannel: Sendable {
func events(tripID: String) -> AsyncThrowingStream<TripEvent, Error>
}
protocol LocationProviding: Sendable {
func currentLocation(accuracy: CLLocationAccuracy) async throws -> Coordinate
}Deep dives
1. The trip state machine
All trip logic lives in one small, pure type. It has no UI and no network, so it is easy to test.
+------------+ no car +-----------------+
request -->| searching |------------>| noDriversFound |
+-----+------+ +-----------------+
| driver accepts
v
+----------------+ driver cancels (search again)
| driverAssigned |---------------------------> searching
+-----+----------+
| driver at pickup
v
+----------------+
| driverArrived |
+-----+----------+
| trip starts
v
+----------------+ trip ends +-------------+
| onTrip |---------------->| completed |
+----------------+ +-------------+
searching / driverAssigned / driverArrived --(rider or driver cancels)--> cancelledimport os
struct TripStateMachine {
private(set) var trip: Trip?
private let logger = Logger(subsystem: "app.rides", category: "trip")
static let allowed: [TripState: Set<TripState>] = [
.searching: [.driverAssigned, .noDriversFound, .cancelled],
.driverAssigned: [.driverArrived, .searching, .cancelled],
.driverArrived: [.onTrip, .cancelled],
.onTrip: [.completed],
.completed: [], .cancelled: [], .noDriversFound: []
]
mutating func apply(_ update: Trip) {
if let current = trip {
guard update.version > current.version else { return } // old or duplicate
let ok = update.state == current.state
|| Self.allowed[current.state, default: []].contains(update.state)
if !ok {
// We missed events, for example while offline. The server is the truth,
// so we accept the update. But we log it: it can also point to a bug.
logger.warning("Jump \(current.state.rawValue) -> \(update.state.rawValue)")
}
}
trip = update
}
}The screen is a function of the state. One switch decides what to show:
| State | Bottom sheet | Rider GPS | Live data |
|---|---|---|---|
| no trip | pickup pin, products, prices | one fix, ~100 m | poll nearby cars every 10 s |
searching | "Finding your driver" | off | WebSocket (trip events) |
driverAssigned | driver card, car on map, ETA | off (or low, if sharing pickup) | WebSocket, car every 2–4 s |
driverArrived | "Your driver is here" | optional, for pickup help | WebSocket |
onTrip | route, ETA to destination | off: the driver's phone tracks the trip | WebSocket |
completed | fare, rating | off | close the socket |
2. Live driver location on the map
The server sends a new driver position every few seconds. If you just set the new coordinate, the car jumps. Instead, animate it to the new point over about the same time as the update interval. This is called interpolation (filling in the points between two updates).
import MapKit
@MainActor
final class DriverMarkerAnimator {
let annotation = MKPointAnnotation()
weak var annotationView: MKAnnotationView?
private var lastSeq: Int64 = -1
private var lastUpdate = Date.distantPast
func handle(_ location: DriverLocation) {
guard location.seq > lastSeq else { return } // old update: ignore
lastSeq = location.seq
let target = CLLocationCoordinate2D(latitude: location.coordinate.lat,
longitude: location.coordinate.lng)
let rotation = CGAffineTransform(rotationAngle: location.heading * .pi / 180)
let gap = Date().timeIntervalSince(lastUpdate)
lastUpdate = Date()
if gap > 10 {
// We were in the background or offline. Do not animate across the city.
annotation.coordinate = target
annotationView?.transform = rotation
return
}
UIView.animate(withDuration: 2.5, delay: 0,
options: [.curveLinear, .beginFromCurrentState]) {
self.annotation.coordinate = target
self.annotationView?.transform = rotation
}
}
}More details:
- Drop out-of-order updates with
seq. Networks can deliver an old position late. - Snap to road on the server, not on the phone. Raw GPS can put the car inside a building.
- One marker, many updates. Do not remove and add annotations. Move the existing one.
- Throttle. If updates come faster than 1 per second, skip some. The eye cannot see the difference.
- ETA comes from the server. Do not compute routes on the phone every few seconds.
3. Battery-aware location
GPS is one of the biggest battery costs. The rider app needs it much less than people think.
- Pickup: one location fix, about 100 m accuracy, then stop.
- Waiting and on trip: usually off. The driver's phone sends the car position.
- No background location. Ask only for "When In Use". This is also better for App Review and for user trust.
With the iOS 17+ API, you take the first good fix and leave the loop. Leaving the loop stops updates.
import CoreLocation
enum LocationError: Error { case unavailable }
struct LocationService: LocationProviding {
func currentLocation(accuracy: CLLocationAccuracy) async throws -> Coordinate {
for try await update in CLLocationUpdate.liveUpdates() {
if let location = update.location, location.horizontalAccuracy <= accuracy {
return Coordinate(lat: location.coordinate.latitude,
lng: location.coordinate.longitude)
}
}
throw LocationError.unavailable
}
}Wrap it in a timeout (for example 5 seconds). If it fails, use the last known location and let the user move the pin. If the user denied permission, show a search field for the pickup address.
Other battery rules:
- Close the WebSocket when there is no active trip, and when the app goes to the background.
- Poll "nearby cars" only while the home map is on screen.
- In Low Power Mode, ask the server for fewer driver updates (for example every 5 s, not 2 s).
4. Reliability: app kills, bad networks, double booking
Double booking. The user taps "Request". The request reaches the server, but the response is lost. The app retries. Without protection, the server creates two trips.
Fix: an idempotency key. The app makes one UUID per tap and sends it on every retry. The server returns the same trip for the same key.
extension TripViewModel {
func requestRide(_ request: TripRequest) async {
let key = UUID() // one key for this tap, reused on retries
for attempt in 1...3 {
do {
let trip = try await api.requestTrip(request, idempotencyKey: key)
machine.apply(trip)
connectLive(tripID: trip.id)
return
} catch {
if attempt == 3 {
showError("Could not request a ride. Try again.")
return
}
try? await Task.sleep(for: .seconds(Double(attempt))) // 1 s, then 2 s
}
}
}
}App killed during a trip. On every launch, ask GET /v1/trips/active. If a trip exists, open
the trip screen in the right state and reconnect the socket. Do not trust only a local copy: the
trip may have moved on while the app was dead.
Socket drops. Reconnect with backoff and jitter. After every reconnect, fetch the trip again with
REST, because events sent during the gap are lost. If the socket fails many times, fall back to
polling GET /v1/trips/{id} every 5 seconds.
func liveLoop(tripID: String) async {
var attempt = 0
while !Task.isCancelled, machine.trip?.state.isActive == true {
do {
if let fresh = try await api.activeTrip() { machine.apply(fresh) } // resync first
for try await event in channel.events(tripID: tripID) {
attempt = 0
switch event {
case .tripUpdated(let trip): machine.apply(trip)
case .driverMoved(let location): marker.handle(location)
}
}
} catch {
attempt += 1
try? await Task.sleep(for: .seconds(min(30, pow(2, Double(attempt)))))
}
}
}isActive is a small helper on TripState that is true for searching, driverAssigned,
driverArrived and onTrip.
5. When the app is in the background
The rider often locks the phone while waiting. The socket is closed, but the rider still wants news.
- Push notifications for key moments: driver assigned, driver arrived, trip completed.
- Live Activity (ActivityKit) on the Lock Screen and Dynamic Island: driver name, car, plate and ETA. The server updates it with ActivityKit push notifications, so the app does not need to run.
- When the app comes back to the foreground: fetch the active trip, then reconnect the socket.
Trade-offs
| Decision | Option A | Option B | Usual pick |
|---|---|---|---|
| Live channel | Polling: simple, robust | WebSocket: low latency, less data | WebSocket + polling fallback |
| Car movement | Show raw points | Interpolate between points | Interpolate, jump on big gaps |
| Rider location | Continuous GPS | One fix at pickup | One fix (battery, privacy) |
| Trip truth | App decides state | Server decides, app mirrors | Server |
| Request retry | Retry blindly | Idempotency key | Idempotency key |
| Map | MapKit: free, native | Third-party SDK: more features | Depends on routing needs |
| Background | Background location + socket | Push + Live Activity | Push + Live Activity |
What interviewers look for
- A clear trip state machine, kept pure and unit-tested, with the server as the truth.
- Version numbers to drop stale trip updates,
seqto drop old positions. - Smooth car animation, with a plan for big gaps and out-of-order data.
- Battery awareness: one-shot location, "When In Use" only, socket only during a trip.
- Idempotency keys so a retry never books two rides.
- Recovery after app kill and socket drops: fetch the active trip, then reconnect.
- Push and Live Activities for the background, not background location.
- Follow-ups: surge pricing changes during request, driver cancels, pickup pin accuracy in big airports, accessibility (VoiceOver reading the ETA), low-end devices and poor networks.