androidinterview.com

Android System Design Interview Questions

Design the Uber app.

Tier: CommonDifficulty: Hard

Design the mobile experience of a ride hailing service. A rider chooses a pickup and destination, requests a trip and follows the assigned driver's arrival. The driver receives an offer, accepts it and updates the trip as it progresses.

The problem

Cover rider and driver app state, server APIs and location updates. Both apps need to show the same trip status and recover after losing their connection. Agree on how fresh positions and arrival estimates must be.

For example, Ana requests a ride and Dev accepts it. Ana sees Dev approaching her pickup point. If Ana's app closes and reopens, it should restore the active trip rather than offer to create a new one. If Dev's location stops updating, the displayed position should be marked stale.

Focus on Android architecture, trip synchronization and maps integration. Driver matching and routing are server services at this level. Detailed class design for assigning a driver is covered in the separate LLD walkthrough.

Starting the design

The riding app is really two apps sharing a backend, a rider client and a driver client, connected by one hard real time requirement, both sides need to see a moving car's position update smoothly, and everything else is built around getting that right.

Who this system servesThe system boundary Ride hailing app holds the use cases Request a trip, Accept the trip, Follow the car, Publish location, Rate and pay, Compute the ETA. Rider takes part in Request a trip, Follow the car, Rate and pay. Driver takes part in Accept the trip, Publish location. Maps service takes part in Compute the ETA.
Who this system serves, a use case diagram for Ride hailing app
The sketch worth drawing before any class exists, because the rider and the driver are two different actors running two different apps, and routing is a service this system calls rather than something it works out itself.

What I'd clarify first

  • Which side am I focused on, rider, driver, or both, since matching and dispatch logic mostly lives on the driver side and backend.
  • Does route and ETA computation happen server side, using a maps and routing service, or is the client expected to compute anything itself.
  • What's the acceptable staleness for a driver's shown position, is genuinely live, subsecond tracking required, or is a few seconds of lag acceptable.

Core components

  • A WebSocket connection, held by both rider and driver apps while a trip is active, since this is exactly the kind of continuous, bidirectional, low latency requirement WebSocket is built for, not something to poll for.
  • A driver location publisher, the driver app pushes its GPS position at a short, fixed interval over the socket while online or on a trip, and the backend fans that update out to the one rider who's currently matched with that driver. A fixed interval is right here and wrong for near by friends, because a driver on a trip is moving by definition.
  • A foreground service on the driver side, typed for location, with the ongoing notification and the background location grant that go with it. A backgrounded process stops getting fixes, and a driver's screen is off for most of an eight hour shift.
  • A matching service, on ride request, queries nearby available drivers using a geospatial index, the same shape of problem as any nearby search feature, and dispatches the request to the closest eligible one.
  • A trip state machine, requested, accepted, driver en route, in progress, completed, cancelled, that both apps render UI against and that the backend is the single source of truth for, neither client should locally infer a state transition that the server hasn't confirmed.
Trip lifecycleStates Requested, Accepted, EnRoute, InProgress, Completed, Cancelled, starting at Requested. driver accepts moves Requested to Accepted. heading to pickup moves Accepted to EnRoute. rider on board moves EnRoute to InProgress. trip ends moves InProgress to Completed. cancel moves Requested to Cancelled. cancel moves Accepted to Cancelled. cancel moves EnRoute to Cancelled. Completed is a terminal state. Cancelled is a terminal state.
Trip lifecycle, a state machine over Requested, Accepted, EnRoute, InProgress, Completed, Cancelled
What either app can ask for. The rider can cancel until the car is carrying them and not after, and neither app is allowed to guess at a transition the server has not sent. What a client accepts back is looser, any forward move, because a socket that dropped may have missed a step.

How a ride flows

The rider requests a trip with a pickup and destination. The matching service finds and offers it to the nearest available driver, who accepts or the offer times out and moves to the next candidate. Once accepted, both apps open a WebSocket scoped to that trip. The driver's app streams position updates, and the rider's app animates the car's marker smoothly between them rather than snapping it. The backend pushes trip state changes, driver arrived, trip started, to both sides the moment they happen. When the trip ends, the socket closes and the interaction falls back to normal REST calls, receipt, rating, history.

The code

Three ideas carry the design. The trip is a closed set of states with a sequence number on every server event, so a socket that reconnects and replays cannot walk the rider's screen backwards. The client refuses exactly two things, a replay and a move backwards. A jump over a state it never saw is accepted, because the server is authoritative and a socket that dropped during "finding a driver" may come back to a car already on its way. A client that insisted on every intermediate step would be stuck on the wrong screen for the rest of the trip.

The interpolator is what makes a car published every few seconds look smooth at sixty frames, and it refuses to extrapolate past the newest fix, because guessing where a car went after the updates stopped is how a marker drives through a building. Bearings interpolate the short way round the circle, which is a one line detail that looks badly wrong when it is missing.

The fix buffer is the driver side of a tunnel, bounded, oldest dropped, flushed in one batch on reconnect. Matching and dispatch live on the backend and are not in this tree, and the geospatial side of finding a nearby driver is the same mechanism as the near by friends design.

Java

com.androidinterview.rideapp.location.Fix.java

package com.androidinterview.rideapp.location;

// One GPS reading. The bearing is carried because a marker that slides without
// turning looks wrong, and interpolating it is a different sum from
// interpolating a coordinate.
public record Fix(double lat, double lon, float bearing, long atMillis) {
}
package com.androidinterview.rideapp.location;

public record Fix(double lat, double lon, float bearing, long atMillis) {
}

com.androidinterview.rideapp.location.FixBuffer.java

package com.androidinterview.rideapp.location;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;

// The driver side of a dropped connection. Fixes keep being recorded into a
// bounded queue while the socket is down and are flushed in order once it
// comes back, so a tunnel costs the rider a stalled marker rather than a
// missing mile of the trip record.
public final class FixBuffer {

    private final Deque<Fix> pending = new ArrayDeque<>();
    private final int capacity;

    public FixBuffer(int capacity) {
        if (capacity <= 0) throw new IllegalArgumentException("capacity must be positive");
        this.capacity = capacity;
    }

    public void record(Fix fix) {
        // Bounded, and it drops the oldest. A long dead zone should cost the
        // middle of the gap, not the memory of the whole shift, and the newest
        // fixes are the ones the trip actually needs on reconnect.
        if (pending.size() == capacity) pending.removeFirst();
        pending.addLast(fix);
    }

    // Flushed in order and in one batch, because a hundred separate sends the
    // instant a connection returns is how you drop it again.
    public List<Fix> drain() {
        List<Fix> batch = new ArrayList<>(pending);
        pending.clear();
        return batch;
    }
}
package com.androidinterview.rideapp.location;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;

public final class FixBuffer {

    private final Deque<Fix> pending = new ArrayDeque<>();
    private final int capacity;

    public FixBuffer(int capacity) {
        if (capacity <= 0) throw new IllegalArgumentException("capacity must be positive");
        this.capacity = capacity;
    }

    public void record(Fix fix) {
        if (pending.size() == capacity) pending.removeFirst();
        pending.addLast(fix);
    }

    public List<Fix> drain() {
        List<Fix> batch = new ArrayList<>(pending);
        pending.clear();
        return batch;
    }
}

com.androidinterview.rideapp.location.MarkerInterpolator.java

package com.androidinterview.rideapp.location;

// Why the car looks smooth at one fix every few seconds rather than sixty a
// second.
//
// The driver app publishes every few seconds, because publishing every second
// over an eight hour shift is a real battery and data cost. The rider app
// draws every frame in between by walking along the line between the last two
// fixes. The shown position is therefore always slightly behind the truth, and
// that is the trade, visual smoothness bought with a couple of seconds of lag.
public final class MarkerInterpolator {

    private MarkerInterpolator() {
    }

    public static Fix at(Fix from, Fix to, long nowMillis) {
        long span = to.atMillis() - from.atMillis();
        if (span <= 0) return to;
        double t = (nowMillis - from.atMillis()) / (double) span;
        if (t <= 0) return from;

        // Never extrapolate past the newest fix. Guessing where a car went
        // after the updates stopped is how a marker drives through a building
        // while the driver is sitting at a light.
        if (t >= 1) return to;

        return new Fix(
                from.lat() + (to.lat() - from.lat()) * t,
                from.lon() + (to.lon() - from.lon()) * t,
                bearingAt(from.bearing(), to.bearing(), t),
                nowMillis);
    }

    // Shortest way round the circle. Straight interpolation from 350 degrees
    // to 10 spins the marker the long way, three hundred and forty degrees
    // backwards, for a turn of twenty.
    private static float bearingAt(float from, float to, double t) {
        float delta = ((to - from + 540f) % 360f) - 180f;
        return (float) ((from + delta * t + 360) % 360);
    }

    // Past this the rider is shown a reconnecting state rather than a frozen
    // marker. Freezing the car silently is the failure mode to design against,
    // because it looks exactly like a car that has stopped moving.
    public static boolean stale(Fix latest, long nowMillis, long maxAgeMillis) {
        return nowMillis - latest.atMillis() > maxAgeMillis;
    }
}
package com.androidinterview.rideapp.location;

public final class MarkerInterpolator {

    private MarkerInterpolator() {
    }

    public static Fix at(Fix from, Fix to, long nowMillis) {
        long span = to.atMillis() - from.atMillis();
        if (span <= 0) return to;
        double t = (nowMillis - from.atMillis()) / (double) span;
        if (t <= 0) return from;

        if (t >= 1) return to;

        return new Fix(
                from.lat() + (to.lat() - from.lat()) * t,
                from.lon() + (to.lon() - from.lon()) * t,
                bearingAt(from.bearing(), to.bearing(), t),
                nowMillis);
    }

    private static float bearingAt(float from, float to, double t) {
        float delta = ((to - from + 540f) % 360f) - 180f;
        return (float) ((from + delta * t + 360) % 360);
    }

    public static boolean stale(Fix latest, long nowMillis, long maxAgeMillis) {
        return nowMillis - latest.atMillis() > maxAgeMillis;
    }
}

com.androidinterview.rideapp.trip.Trip.java

package com.androidinterview.rideapp.trip;

// Server authoritative, and this class is where that is actually enforced.
//
// The rider's app never decides that a trip started because the car looks
// close, and the driver's app never decides it ended because the tap happened.
// A tap sends an intent to the server, and the state changes when the event
// comes back. That costs a beat of latency and buys the two apps never
// disagreeing about what is happening, which on a paid trip is the right trade.
public final class Trip {

    // Every server event carries a sequence. A socket that reconnects mid trip
    // replays, and without this an old en route event lands after in progress
    // and puts the rider's screen back a step.
    public record StateEvent(TripState state, long seq) {
    }

    private TripState state = TripState.REQUESTED;
    private long appliedSeq;

    public TripState state() {
        return state;
    }

    // Two refusals, and only two. A sequence already applied is a replay. A
    // state earlier than the current one is a move backwards. Anything else
    // the server says is adopted, including a jump over an event this client
    // never received.
    public boolean apply(StateEvent event) {
        if (event.seq() <= appliedSeq) return false;         // stale replay
        if (!state.canMoveTo(event.state())) return false;   // backwards, or already over
        state = event.state();
        appliedSeq = event.seq();
        return true;
    }
}
package com.androidinterview.rideapp.trip;

public final class Trip {

    public record StateEvent(TripState state, long seq) {
    }

    private TripState state = TripState.REQUESTED;
    private long appliedSeq;

    public TripState state() {
        return state;
    }

    public boolean apply(StateEvent event) {
        if (event.seq() <= appliedSeq) return false;
        if (!state.canMoveTo(event.state())) return false;
        state = event.state();
        appliedSeq = event.seq();
        return true;
    }
}

com.androidinterview.rideapp.trip.TripState.java

package com.androidinterview.rideapp.trip;

import java.util.EnumSet;
import java.util.Set;

// The trip, as a closed set of states in the order a trip passes through them.
// The backend is the single source of truth for which one a trip is in, and
// both apps only render what it says.
public enum TripState {

    REQUESTED, ACCEPTED, EN_ROUTE, IN_PROGRESS, COMPLETED, CANCELLED;

    private static final Set<TripState> TERMINAL = EnumSet.of(COMPLETED, CANCELLED);

    // Forward only. The server is authoritative, so a jump over a missed step,
    // requested straight to en route because a dropped socket swallowed the
    // accepted event, is taken at its word. Only a move backwards, or out of a
    // terminal state, is refused. A client that insisted on every intermediate
    // step would sit on "finding a driver" for the rest of the trip.
    public boolean canMoveTo(TripState next) {
        if (TERMINAL.contains(this)) return false;
        return next.ordinal() > this.ordinal();
    }
}
package com.androidinterview.rideapp.trip;

import java.util.EnumSet;
import java.util.Set;

public enum TripState {

    REQUESTED, ACCEPTED, EN_ROUTE, IN_PROGRESS, COMPLETED, CANCELLED;

    private static final Set<TripState> TERMINAL = EnumSet.of(COMPLETED, CANCELLED);

    public boolean canMoveTo(TripState next) {
        if (TERMINAL.contains(this)) return false;
        return next.ordinal() > this.ordinal();
    }
}

Kotlin

com.androidinterview.rideapp.location.Tracking.kt

package com.androidinterview.rideapp.location

// One GPS reading. The bearing is carried because a marker that slides without
// turning looks wrong, and interpolating an angle is a different sum from
// interpolating a coordinate.
data class Fix(val lat: Double, val lon: Double, val bearing: Float, val atMillis: Long)

// Why the car looks smooth at one fix every few seconds rather than sixty.
//
// The driver app publishes on a slow cadence, because publishing every second
// over an eight hour shift is a real battery and data cost. The rider app
// draws every frame in between by walking the line between the last two fixes.
// The shown position is therefore always slightly behind the truth, and that
// is the trade, smoothness bought with a couple of seconds of lag.
fun interpolate(from: Fix, to: Fix, nowMillis: Long): Fix {
    val span = to.atMillis - from.atMillis
    if (span <= 0) return to
    val t = (nowMillis - from.atMillis) / span.toDouble()
    return when {
        t <= 0 -> from
        // Never extrapolate past the newest fix. Guessing where a car went
        // after the updates stopped is how a marker drives through a building
        // while the driver sits at a light.
        t >= 1 -> to
        else -> Fix(
            lat = from.lat + (to.lat - from.lat) * t,
            lon = from.lon + (to.lon - from.lon) * t,
            bearing = bearingAt(from.bearing, to.bearing, t),
            atMillis = nowMillis,
        )
    }
}

// Shortest way round the circle. Straight interpolation from 350 degrees to 10
// spins the marker the long way, three hundred and forty degrees backwards,
// for a turn of twenty.
private fun bearingAt(from: Float, to: Float, t: Double): Float {
    val delta = ((to - from + 540f) % 360f) - 180f
    return ((from + delta * t + 360) % 360).toFloat()
}

// Past this the rider is shown a reconnecting state rather than a frozen
// marker. Freezing the car silently is the failure mode to design against,
// because it looks exactly like a car that has stopped moving.
fun Fix.stale(nowMillis: Long, maxAgeMillis: Long) = nowMillis - atMillis > maxAgeMillis

// The driver side of a dropped connection. Fixes keep being recorded while the
// socket is down and are flushed in order once it returns, so a tunnel costs
// the rider a stalled marker rather than a missing mile of the trip record.
// Bounded, dropping the oldest, because a long dead zone should cost the middle
// of the gap and not the memory of the whole shift.
class FixBuffer(private val capacity: Int) {

    init {
        require(capacity > 0) { "capacity must be positive" }
    }

    private val pending = ArrayDeque<Fix>()

    fun record(fix: Fix) {
        if (pending.size == capacity) pending.removeFirst()
        pending.addLast(fix)
    }

    // Drained in one batch, because a hundred separate sends the instant a
    // connection returns is how you drop it again.
    fun drain(): List<Fix> = pending.toList().also { pending.clear() }
}
package com.androidinterview.rideapp.location

data class Fix(val lat: Double, val lon: Double, val bearing: Float, val atMillis: Long)

fun interpolate(from: Fix, to: Fix, nowMillis: Long): Fix {
    val span = to.atMillis - from.atMillis
    if (span <= 0) return to
    val t = (nowMillis - from.atMillis) / span.toDouble()
    return when {
        t <= 0 -> from
        t >= 1 -> to
        else -> Fix(
            lat = from.lat + (to.lat - from.lat) * t,
            lon = from.lon + (to.lon - from.lon) * t,
            bearing = bearingAt(from.bearing, to.bearing, t),
            atMillis = nowMillis,
        )
    }
}

private fun bearingAt(from: Float, to: Float, t: Double): Float {
    val delta = ((to - from + 540f) % 360f) - 180f
    return ((from + delta * t + 360) % 360).toFloat()
}

fun Fix.stale(nowMillis: Long, maxAgeMillis: Long) = nowMillis - atMillis > maxAgeMillis

class FixBuffer(private val capacity: Int) {

    init {
        require(capacity > 0) { "capacity must be positive" }
    }

    private val pending = ArrayDeque<Fix>()

    fun record(fix: Fix) {
        if (pending.size == capacity) pending.removeFirst()
        pending.addLast(fix)
    }

    fun drain(): List<Fix> = pending.toList().also { pending.clear() }
}

com.androidinterview.rideapp.trip.Trip.kt

package com.androidinterview.rideapp.trip

// The trip, as a closed set of states in the order a trip passes through them.
// The backend is the single source of truth for which one a trip is in, and
// both apps only render what it says.
enum class TripState {
    REQUESTED, ACCEPTED, EN_ROUTE, IN_PROGRESS, COMPLETED, CANCELLED;

    val terminal: Boolean get() = this == COMPLETED || this == CANCELLED

    // Forward only. The server is authoritative, so a jump over a missed step,
    // requested straight to en route because a dropped socket swallowed the
    // accepted event, is taken at its word. Only a move backwards, or out of a
    // terminal state, is refused. A client that insisted on every intermediate
    // step would sit on "finding a driver" for the rest of the trip.
    fun canMoveTo(next: TripState): Boolean = !terminal && next > this
}

// Every server event carries a sequence. A socket that reconnects mid trip
// replays, and without this an old en route event lands after in progress and
// puts the rider's screen back a step.
data class StateEvent(val state: TripState, val seq: Long)

// Server authoritative, and this class is where that is enforced.
//
// The rider's app never decides a trip started because the car looks close,
// and the driver's app never decides it ended because the tap happened. A tap
// sends an intent and the state changes when the event comes back. That costs
// a beat of latency and buys the two apps never disagreeing, which on a paid
// trip is the right trade.
class Trip {

    var state = TripState.REQUESTED
        private set

    private var appliedSeq = 0L

    // Two refusals, and only two. A sequence already applied is a replay, and
    // a state earlier than the current one is a move backwards. Anything else
    // the server says is adopted, including a jump over an event this client
    // never received.
    fun apply(event: StateEvent): Boolean {
        if (event.seq <= appliedSeq || !state.canMoveTo(event.state)) return false
        state = event.state
        appliedSeq = event.seq
        return true
    }
}
package com.androidinterview.rideapp.trip

enum class TripState {
    REQUESTED, ACCEPTED, EN_ROUTE, IN_PROGRESS, COMPLETED, CANCELLED;

    val terminal: Boolean get() = this == COMPLETED || this == CANCELLED

    fun canMoveTo(next: TripState): Boolean = !terminal && next > this
}

data class StateEvent(val state: TripState, val seq: Long)

class Trip {

    var state = TripState.REQUESTED
        private set

    private var appliedSeq = 0L

    fun apply(event: StateEvent): Boolean {
        if (event.seq <= appliedSeq || !state.canMoveTo(event.state)) return false
        state = event.state
        appliedSeq = event.seq
        return true
    }
}

How I'd build this on Android

Ride app Android componentsRider and driver routes to RideViewModel, state and commands. RideViewModel to RideRepository, observe and request. RideRepository to Trip DAO and platform adapters, save and check updates.state and commandsobserve and requestsave and check updatesRider and driver routesMap, quote and trip controlsRideViewModelTrip state and request actionsRideRepositoryTrip versions and requestsTrip DAO and platform adaptersREST, socket, location service
Ride app Android components
Each arrow shows a call from one component to another.

I'd use RideViewModel for pickup, quote and trip state. RideRepository calls REST endpoints, saves the current trip and applies live updates. The driver has a separate location service and upload queue that follow the active shift or trip. Closing the map should not stop driver tracking.

interface RideRepository {
    fun observeTrip(tripId: String): Flow<TripSnapshot>
    // Persists the attempt ID before POST and reconciles ambiguous responses.
    suspend fun requestRide(pickup: Coordinate, destination: Coordinate): String
    suspend fun cancelRide(tripId: String, operationId: String)
}

data class Coordinate(val latitude: Double, val longitude: Double)
data class TripSnapshot(val id: String, val version: Long, val status: String)

class RideViewModel(
    saved: SavedStateHandle,
    repository: RideRepository,
) : ViewModel() {
    private val tripId: String = checkNotNull(saved["tripId"])
    val trip = repository.observeTrip(tripId)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
}

The repository saves the newest trip version in Room and ignores older or duplicate events. A gap in the event sequence triggers a request for the current trip. Before requesting a ride, it saves a request ID. Retrying that ID prevents a lost response from creating two rides. Cancel stays pending until the server confirms it, because a trip may have started before cancellation arrived.

The Compose route collects the trip state with lifecycle awareness. Camera position and marker animation stay in the UI. The map can animate between timestamped points, but it must show when the location is old. Fare and ETA come from the server. One driver point should not force the whole screen to rebuild.

I'd inject a location source interface, API, DAO and signed in session. Platform adapters use application context where appropriate. Driver tracking also needs the correct foreground service type, permissions and an allowed start path. Background location permission is not automatically required for every foreground service, and it does not bypass restrictions on starting one. See foreground service types.

I'd check these cases.

  • A driver accepting while the rider cancels.
  • A repeated ride request.
  • Process restart.
  • Approximate location.
  • Old events after reconnect.

I'd measure location age, event delay, battery use and how long a ride request stays unresolved. The delivery tracking answer shows the location callback and service wiring.

Tradeoffs I'd call out

  • Update frequency vs battery and bandwidth. Publishing driver location every second gives the smoothest tracking but costs meaningfully more battery and data over a multi hour driving shift. Slowing the interval saves both at the cost of the rider's map looking less fluid, most systems land somewhere around every 3 to 5 seconds and smooth the visual motion client side between updates rather than relying on raw update frequency for smoothness.
  • Client side animation vs raw position snapping. Interpolating the car's marker between two received positions makes movement look continuous even at a modest update rate, but it means the shown position is always slightly behind or extrapolated from the last real fix, a deliberate, worthwhile tradeoff of visual smoothness over pixel perfect accuracy.
  • Server authoritative trip state vs optimistic client updates. Waiting for the server to confirm a state transition, like "trip started," guarantees rider and driver never disagree about what's happening, but it adds a beat of latency between an action and it reflecting on screen. An optimistic local update feels faster but risks a rider's app showing "trip started" for a trip the driver's app never actually confirmed starting.

What breaks at scale, offline, and on a poor connection

At scale, the matching service's geospatial query is the part that has to stay fast as both rider demand and the driver fleet grow, the same geohash or spatial indexing approach as any nearby search problem, a naive distance scan against every online driver falls over long before a real city's driver count does. Offline or on a dropped connection during a trip, this is the one place graceful degradation really matters, the driver app should keep recording position locally and flush the backlog once reconnected rather than losing that gap entirely, and the rider's app should show a clear "reconnecting" state rather than silently freezing the car's marker in place as if nothing were wrong. On a poor connection generally, the WebSocket needs automatic reconnection with backoff, and both apps should fall back to a slower REST poll for trip state if the socket can't reestablish quickly, a stale but updating trip screen beats one that's stuck. Trip state is fetched again on launch rather than restored from memory, because either app being killed mid trip is routine, not an edge case.

Watch