androidinterview.com

Android System Design Interview Questions

Design the Uber app.

Tier: CommonDifficulty: Hard

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, sub-second 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) {
}

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;
    }
}

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;
    }
}

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;
    }
}

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();
    }
}

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() }
}

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
    }
}

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 mid-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 re-fetched on launch rather than restored from memory, because either app being killed mid trip is routine, not an edge case.

Watch