iosNudge

Design a Ride-Hailing Rider App

HardSystem designArchitectureNetworkingPerformanceApp lifecycle 60 min read

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".

PartJob
TripViewModelGlues everything. Turns trip state into what the screen shows.
TripStateMachineApplies server updates in order, drops stale ones, checks transitions.
LiveTripChannelWebSocket for trip events and driver positions while a trip is active.
DriverMarkerAnimatorMoves the car icon smoothly between position updates.
LocationServiceGets the rider's location with the lowest accuracy that is good enough.
TripAPIREST 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, driverMoved

Swift 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)--> cancelled
import 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:

StateBottom sheetRider GPSLive data
no trippickup pin, products, pricesone fix, ~100 mpoll nearby cars every 10 s
searching"Finding your driver"offWebSocket (trip events)
driverAssigneddriver card, car on map, ETAoff (or low, if sharing pickup)WebSocket, car every 2–4 s
driverArrived"Your driver is here"optional, for pickup helpWebSocket
onTriproute, ETA to destinationoff: the driver's phone tracks the tripWebSocket
completedfare, ratingoffclose 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

DecisionOption AOption BUsual pick
Live channelPolling: simple, robustWebSocket: low latency, less dataWebSocket + polling fallback
Car movementShow raw pointsInterpolate between pointsInterpolate, jump on big gaps
Rider locationContinuous GPSOne fix at pickupOne fix (battery, privacy)
Trip truthApp decides stateServer decides, app mirrorsServer
Request retryRetry blindlyIdempotency keyIdempotency key
MapMapKit: free, nativeThird-party SDK: more featuresDepends on routing needs
BackgroundBackground location + socketPush + Live ActivityPush + 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, seq to 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.