← System design

Marketplace & Booking

Design Uber / Ride Sharing

A ride-hailing backend has to know where a few million moving cars are, to within a few metres, and pair each new request with the right one in a couple of seconds. The difficulty is not any single lookup. It is that the data changes every few seconds for every driver, two riders must never get the same car, and demand can triple in one neighbourhood the moment a concert ends.
Core: hexagon index + atomic driver claim18M trips a day500K location writes a second at peakCity-scoped cells

01

The problem

01

The problem

A rider opens the app, drops a pin and taps request. Within about three seconds the app should show a named driver, a car and a pickup time. From then until drop-off, both phones show each other moving on a map, and when the trip ends the fare is final and charged once.

Behind that flow sit four separate systems that interviews tend to blur together: a location index that absorbs a constant stream of GPS points, a dispatcher that picks and locks a driver, a live tracking channel that pushes positions to riders, and a pricing and ETA layer that decides what the trip costs and how long it takes. Each has a different consistency need, and treating them as one service is the most common mistake candidates make.

The central question

How do you find the closest available drivers to a point, when every driver moves every few seconds, and then give exactly one of them the job even though several dispatchers may be trying at once?

What makes it tractable

Rides are local. A request in Lagos never needs a driver in Lima. Partition everything by city and the global problem becomes several hundred small ones, the largest of which fits in the memory of a few machines.

02

What the platform must do

02

What the platform must do

Functional
  • A rider sets pickup and drop-off, sees an upfront price and ETA, and requests a ride.
  • The system offers the request to a nearby available driver, who accepts or declines within a short window, and retries with the next driver if needed.
  • Online drivers report location every 5 seconds when idle and every 2 seconds on a trip.
  • After a match, both apps see each other's live position, the pickup ETA and status changes.
  • Trips move through a fixed lifecycle: requested, offered, accepted, arrived, in progress, completed or cancelled.
  • Fares combine base, distance, time and a surge multiplier, and the rider is charged once at the end.
Non-Functional
  • From request to an offer on a driver's phone in under 2 seconds at p95.
  • A driver can be assigned to at most one active trip, with no exceptions under retries or partitions.
  • Position updates reach the rider within about 1 second of the driver sending them.
  • Requesting and in-progress trips survive the loss of any single machine or availability zone.
  • One city's overload or outage must not affect other cities.
Out of scope unless asked

Payments internals, driver onboarding and documents, ratings, shared rides and food delivery. Mention them in one sentence each and move on. Ask whether the interviewer wants pooled rides, because it turns matching from a nearest-neighbour search into a routing problem.

03

Sizing one busy city and the world

03

Sizing one busy city and the world

Assume 18 million trips a day across about 700 cities, 2.5 million drivers online at the global peak, and a largest city with 120,000 drivers online on a Friday night. About a third of online drivers are on a trip at any moment.

QuantityWorkingResult
Trips per dayassumption18M
Average request rate18M / 86,400 sabout 210 / s
Peak request rate5x average, Friday evening across time zonesabout 1,050 / s
Biggest city, peak requestsevent exits can reach 10x its normal rateabout 90 / s normal, 900 / s burst
Location writes, global1.7M idle / 5 s + 0.8M on trip / 2 sabout 740,000 / s
Location writes, biggest city120K drivers, same mixabout 35,000 / s
Live index size2.5M drivers x 120 B (id, cell, lat, lng, heading, status, ts)about 300 MB
Riders watching a car0.8M trips, plus about 0.3M pickups in progressabout 1.1M open streams
Push messages to riders1.1M streams x one update / 2 sabout 550,000 / s
GPS breadcrumbs stored0.8M on-trip drivers / 2 s x 48 Babout 19 MB / s, 1.7 TB / day raw
Trip records18M x about 4 KB with fare lines and eventsabout 72 GB / day
Read the table carefully

Requests are tiny: about a thousand a second worldwide. Location writes are 700 times larger and nearly all of them are overwritten a few seconds later. That says the location index must live in memory and must not be a durable database. The durable history of where a car went belongs to a separate, append-only log.

One city fits on a few machines

35,000 writes per second and 15 MB of live state for the busiest city is well within a single sharded in-memory store. That is why splitting by city, rather than by some global hash, is the natural cut.

04

API design

04

API design

POST/v1/tripsRider requests a ride
// Idempotency-Key: 9b2e77c1, a retry after a dropped connection returns the same trip { "pickup": { "lat": 19.0760, "lng": 72.8777 }, "dropoff": { "lat": 19.0896, "lng": 72.8656 }, "product": "standard", "quote_id": "q_51ad0e" // locks the upfront price shown on the confirm screen } // 202 Accepted { "trip_id": "t_7c03f9", "state": "requested", "stream": "wss://rt.example/trips/t_7c03f9" }
POST/v1/drivers/me/locationsDriver location batch
// Usually sent over the driver's open socket; this is the HTTP fallback { "points": [ { "lat": 19.0712, "lng": 72.8801, "acc_m": 6, "speed": 8.4, "bearing": 212, "ts": 1790851203 } ], "status": "available" } // 204 No Content, the server never waits on durable storage for this call
POST/v1/offers/{offer_id}/acceptDriver accepts an offer
// 200 with trip details if the claim succeeds // 409 offer_expired if the window closed or the rider cancelled first { "trip_id": "t_7c03f9", "rider_first_name": "Asha", "pickup_eta_s": 240 }
GET/v1/trips/{trip_id}Current trip state for app resume
{ "state": "accepted", "version": 4, // clients drop pushed events older than this "driver": { "lat": 19.0731, "lng": 72.8790, "eta_s": 190 }, "fare_estimate": { "amount": 214, "currency": "INR", "surge": 1.3 } }
Why a resume endpoint matters

Phones lose signal in lifts and tunnels constantly. The socket carries live updates, but the source of truth is always the trip record. On reconnect the app fetches the trip, compares versions and only then trusts pushed events again.

05

Architecture overview

05

Architecture overview

One city cell, request path and location pathDiagram
flowchart LR
	R["Rider app"] --> GW["API gateway"]
	D["Driver app"] --> WS["Socket gateway"]
	WS --> LI["Location ingest"]
	LI --> IDX[("Hex index in memory")]
	LI -. "breadcrumbs" .-> LOG[["Kafka: locations"]]
	GW --> TS["Trip service"]
	TS --> TDB[("Trip store")]
	TS --> DP["Dispatcher"]
	DP --> IDX
	DP --> ETA["ETA service"]
	DP --> DS[("Driver state, CAS")]
	TS --> PS[["Trip channel pub/sub"]]
	PS --> WS
	GW --> PR["Pricing and surge"]

The system is built as cells. A cell is a full copy of the real-time stack serving a group of nearby cities: its own socket gateways, location index, dispatcher, trip store and pub/sub. A routing layer maps each city to one cell, and a large city may get a cell to itself. Shared global services, such as accounts, payments and maps data, sit outside the cells and are called asynchronously wherever possible.

Two paths run through each cell. The location path is high volume and lossy by design: a driver's phone sends a point over its socket, ingest snaps it to a road, updates the in-memory hex index and appends the point to Kafka for history. Nothing on this path waits for a disk. The trip path is low volume and strict: creating a trip, offering it, accepting it and finishing it are durable state transitions with versioning.

They meet at the dispatcher. It reads candidates from the index, asks the ETA service for real drive times, and then claims a driver through an atomic compare-and-set on the driver state store. Every trip state change is published on a per-trip channel, which the socket gateway forwards to whichever phones are subscribed.

Socket gateway

Holds about 60,000 persistent connections per node. Stateless apart from the sockets themselves, so a node loss means clients reconnect to another node, not lost data.

Hex index

Driver positions bucketed by H3 cell, sharded by cell within the city. A ring query around the pickup returns candidates in well under a millisecond.

Dispatcher

Builds the candidate list, ranks by true pickup ETA, sends one offer at a time and handles timeouts. Stateless; offer timers live in a durable delay queue.

Trip store

A replicated database such as a sharded Postgres or Spanner-style store, keyed by trip ID, with a version column on every row. This is the only place trip truth lives.

Pricing and surge

Reads demand and supply counts per hex every 30 seconds and writes multipliers that quotes read. Quotes are signed so the price cannot change after confirmation.

Kafka: locations

Feeds trip distance for fares, ETA model training, fraud checks and the replay tool support uses for disputes.

06

Deep dive: finding nearby drivers

06

Deep dive: finding nearby drivers

The question the index answers thousands of times a second is: which available drivers are within a few minutes of this point? A database query on latitude and longitude ranges is too slow once each driver writes every few seconds. You want a scheme that turns a position into a short key, so that "nearby" becomes "these few keys".

SchemeHow it worksVerdict for dispatch
GeohashInterleaves latitude and longitude bits into a base-32 string. Prefixes form rectangles that shrink as the string grows.Simple and supported by Redis. Cells change size with latitude, and two points across a cell boundary can have unrelated prefixes, so you always query the 8 neighbours.
QuadtreeSplits a region into four children whenever it holds too many points, so dense downtowns get small cells.Adapts to density well. Every move can force splits and merges, which is awkward when 35,000 points change per second.
S2Projects the sphere onto a cube and orders cells along a Hilbert curve, giving 64-bit IDs with good locality.Excellent for range scans and region covering. Cells are squares of varying shape, so distance to neighbours is uneven.
H3Covers the earth in hexagons at 16 fixed resolutions. Each cell has a 64-bit ID and exactly six neighbours at equal distance.Our choice. Ring queries are uniform, which suits both nearest-driver search and surge zones. Uber open-sourced it for exactly this use.

How the H3 index is laid out

We index drivers at resolution 9, where a hexagon is about 0.1 km2, roughly 175 metres across. For each cell the index holds a small set of driver IDs with their last point. A driver update does three things: compute the new cell from the snapped point, remove the driver from the old cell if it changed, and overwrite its entry in a per-driver hash with a TTL of 30 seconds. A driver who stops reporting therefore disappears from search without any cleanup job.

To search, take the pickup's cell and expand rings with gridDisk(k). Ring 1 is 7 cells, ring 3 is 37, ring 6 is 127. Stop as soon as you have about 15 available candidates or hit a maximum radius around 3 km. In a dense core that usually happens at ring 2; on the edge of a city it takes more rings, and the dispatcher widens the radius rather than giving up.

Straight-line distance is only a first filter. A driver 400 metres away on the other side of a river or a highway can be 12 minutes out. The dispatcher sends the candidate list to the ETA service and ranks by real pickup time, which is the number the rider actually experiences.

# candidate search for one request def candidates(pickup, want=15, max_k=10): origin = h3.latlng_to_cell(pickup.lat, pickup.lng, 9) found = [] for k in range(0, max_k + 1): for cell in h3.grid_ring(origin, k): found += [d for d in index.drivers_in(cell) if d.status == "available" and d.fresh()] if len(found) >= want: break return eta.rank(pickup, found)[:5]
Shard by cell, not by driver

Sharding the index by driver ID spreads load evenly but forces every search to ask every shard. Sharding by a coarser parent cell (resolution 5 or 6) keeps a search on one or two shards. The cost is that a stadium or airport can heat one shard, which the surge section deals with.

07

Deep dive: dispatch and the driver claim

07

Deep dive: dispatch and the driver claim

Trip states and who moves themDiagram
flowchart LR
	REQ["requested"] -->|dispatcher sends offer| OFF["offered"]
	OFF -->|driver accepts, CAS wins| ACC["accepted"]
	OFF -->|decline or 10 s timeout| REQ
	REQ -->|no driver after 90 s| NOD["no_drivers"]
	ACC -->|driver at pickup| ARR["arrived"]
	ARR -->|rider on board| PROG["in_progress"]
	PROG -->|drop-off| DONE["completed"]
	ACC -->|either side cancels| CAN["cancelled"]
	ARR -->|either side cancels| CAN

Every trip and every driver has a small state machine, and every transition is a conditional write that names the version it expects. If the version has moved on, the write fails and the caller re-reads. This one rule handles nearly every race in the system: a rider cancelling while a driver accepts, two dispatchers working the same driver, a retry arriving after the first attempt succeeded.

Claiming a driver exactly once

A driver near a busy junction may be the best candidate for three requests at the same instant. If three dispatchers each send an offer, the driver gets three pings and two riders are disappointed. So before sending an offer, the dispatcher claims the driver:

-- one atomic statement on the driver state store UPDATE driver_state SET status = 'offered', offer_trip = :trip_id, version = version + 1, offer_expires = now() + interval '10 seconds' WHERE driver_id = :driver_id AND status = 'available' AND version = :seen_version; -- 1 row: this dispatcher owns the driver for 10 s. 0 rows: move to the next candidate.

Only the winner sends an offer. When the driver accepts, a second conditional write moves the driver to on_trip and the trip to accepted, both guarded by version. Because the two rows live in different stores, the trip service writes the trip first and the driver second, and a reconciler repairs any pair left half-done after a crash by checking offer expiry times.

In Redis the same claim is a short Lua script that compares status and version, then sets the new values. Either way, the lock is the state itself, so there is no separate lock service to fail or leak.

Choosing the offer window

Offers expire after 10 seconds. Shorter windows cause drivers who are mid-turn or glancing at the road to miss offers they would have taken; longer windows mean a rider waits through several silent drivers. With a typical acceptance rate around 80% and three candidates ready, the expected wait to an accepted driver stays under 15 seconds even with one decline.

In dense areas we also run batched matching: collect all requests and available drivers in a zone over a 2-second window and solve the assignment as a small bipartite matching problem that minimises total pickup time. Greedy nearest-driver assignment often gives one rider a great match and the next a terrible one; batching smooths that out at the cost of a 1 to 2 second delay.

The late accept

A driver taps accept at 9.9 seconds, the request crosses a slow network and arrives at 10.4. The offer has expired and the dispatcher may already be claiming someone else. The accept call must fail with offer_expired, and the driver app must show "this request was reassigned". Never let a late accept overwrite a newer claim; the version check makes that impossible.

08

Deep dive: the live map

08

Deep dive: the live map

Once a trip is accepted, the rider watches the car approach. Polling would be the simplest design, but 1.1 million watchers polling every 2 seconds is another 550,000 HTTP requests per second, each paying for TLS, auth and routing to return a few bytes. Persistent sockets cost one connection per phone and a small frame per update.

A driver point reaching the riderDiagram
flowchart LR
	DA["Driver phone"] -->|"point every 2 s"| G1["Gateway node A"]
	G1 --> IN["Ingest: snap and filter"]
	IN --> IDX[("Hex index")]
	IN --> CH[["Channel trip:t_7c03f9"]]
	CH --> G2["Gateway node B"]
	G2 --> RA["Rider phone"]
	RA -->|"on reconnect"| API["GET trip state"]
Gateways hold sockets, nothing else

Driver and rider phones connect to whichever gateway node a load balancer picks. Each node subscribes to the channels of the phones it holds, so the two phones on one trip rarely share a node, and they do not need to.

A channel per trip

Ingest publishes each snapped point to trip:{id}. Redis pub/sub or NATS fans it out to subscribed gateways. Channels are created on accept and removed on completion.

Cleaning the signal

Raw GPS jumps between parallel streets in tall downtowns. Ingest runs a small Kalman filter and snaps points to the road graph, dropping fixes with accuracy worse than 50 metres.

Smooth on the phone

With a point every 2 seconds, a car icon would hop. The rider app animates between points using speed and bearing, and corrects gently when the next point arrives.

When connections drop

Rider drops: the app reconnects with backoff, calls the trip endpoint, and resubscribes. Missed points are not replayed; only the latest position matters. Driver drops: the phone buffers points locally and uploads the batch on reconnect, so trip distance for the fare is still complete, while the rider sees a "connection lost" hint after 15 seconds without updates. Gateway node dies: its 60,000 phones reconnect to other nodes over the next few seconds. Jitter the reconnect delay on the client, or the surviving nodes take a thundering herd of TLS handshakes at once.

09

Deep dive: demand spikes and surge

09

Deep dive: demand spikes and surge

A stadium empties at 22:40. In five minutes, 9,000 people within 800 metres open the app. Three things go wrong together: demand outruns supply in a few hexagons, the shard holding those hexagons runs hot, and drivers already nearby are claimed instantly, leaving later requests with ETAs of 25 minutes.

Surge pricing as a control loop

Every 30 seconds, the pricing service counts open requests and available drivers per resolution-7 hexagon (about 5 km2) and smooths the ratio over the last few minutes. A multiplier comes from a capped curve of that ratio: 1.0 when supply covers demand, rising in steps of 0.1 up to a regulated maximum. Smoothing across neighbouring hexes stops prices jumping at a street corner.

The multiplier does two jobs at once. Some riders wait or choose a cheaper product, which trims demand. Drivers see a heat map and move towards the zone, which adds supply over 5 to 10 minutes. The quote a rider confirms is signed and honoured for a few minutes, so the price never changes after the rider says yes.

TimeWhat is happeningSystem response
22:38Normal: 140 drivers, 60 requests in the stadium hexesMultiplier 1.0
22:41Requests jump to 1,100, idle drivers drop to 40Multiplier 1.6 after one smoothing cycle
22:44Drivers from neighbouring zones head in, some riders waitIndex shard for the area at 3x normal writes
22:50Supply reaches 300, open requests fall to 350Multiplier eases to 1.2
23:05Queue clearedMultiplier back to 1.0

Keeping the hot shard alive

Ring searches from a thousand requests in one area all hit the same index shard. Three measures keep it healthy. First, coalesce searches: requests in the same resolution-9 hex within a 500 ms window share one candidate list. Second, give the shard read replicas, since a few hundred milliseconds of staleness is harmless for candidate search when the claim step re-checks status anyway. Third, for known venues and airports, run a virtual queue: riders join a line and drivers are matched in order at a pickup zone, which turns a geographic search into a FIFO.

Planned spikes are easier

New Year's Eve, festivals and big matches are on a calendar. Scale the city's cell up an hour before, warm caches, and push driver incentives in advance so supply arrives before demand does.

10

Deep dive: computing arrival times

10

Deep dive: computing arrival times

ETA shows up three times in one trip: ranking candidate drivers, quoting the price, and the countdown on the rider's screen. Calling an external maps API for each would mean several million paid calls an hour with 100 ms or more of latency each. Ranking alone calls for a dozen ETAs per request. So the ETA service is in-house and layered from cheap to precise.

ETA layers, cheapest firstDiagram
flowchart TD
	Q["ETA request: origin, destination, time"] --> L1["Layer 1: straight line x city detour factor"]
	L1 --> L2["Layer 2: zone to zone travel time table"]
	L2 --> L3["Layer 3: road graph route with live speeds"]
	L3 --> L4["Layer 4: learned correction"]
	L4 --> OUT["ETA in seconds"]

Layer 1 multiplies the haversine distance by a per-city detour factor, usually around 1.3 to 1.5, and divides by a typical speed for that hour. It is free and used only to prune obviously far candidates. Layer 2 is a table of median travel times between resolution-7 hexes for each 15-minute slot of the week, built offline from trip history. It answers in microseconds and is good enough for rough price quotes.

Layer 3 runs a real shortest-path query on the road graph. Plain Dijkstra on a city graph with a few million edges takes far too long, so the graph is preprocessed with contraction hierarchies: nodes are ranked by importance and shortcut edges are added that skip over unimportant ones. A query then searches upward from both ends and meets in the middle, touching only a few thousand nodes, in about a millisecond. Live traffic is applied by updating edge weights from recent probe speeds; customisable variants of the technique let you refresh weights every few minutes without redoing the full preprocessing.

Layer 4 is a gradient-boosted model that predicts the error of layer 3 from features such as time of day, weather, pickup type (airport, mall, side street) and the driver's recent speed. Every completed trip is a labelled example, so the model improves as the platform grows, which is a real advantage over a new competitor.

Two awkward cases

In tunnels GPS disappears and the car seems to stop; ingest dead-reckons along the known road at the last speed for up to 60 seconds. When a driver leaves the planned route, ETA is recomputed from the new position on the next point rather than snapping the car back to the old path.

11

Decisions and their costs

11

Decisions and their costs

Chosen
Drivers push location on a socket

One persistent connection per driver, small frames every 2 to 5 seconds, and the server always has a fresh view without asking. Battery and data cost on the phone stays low.

Fresh and cheap
Rejected
Server polls drivers or drivers send HTTP posts

Polling 2.5 million phones is impossible across mobile networks, and separate HTTPS posts repeat handshake and auth work hundreds of thousands of times a second.

Too much overhead
Chosen
In-memory index with TTL, durable log on the side

Searches and updates take microseconds, stale drivers expire by themselves, and history is kept in Kafka for anyone who needs it. Losing the index loses at most a few seconds of positions, which the next updates refill.

Matches the data
Rejected
Geospatial queries on the primary database

Durable and familiar, but 35,000 writes a second per city into an indexed table with geometry columns is heavy, and every write is obsolete within seconds.

Wrong storage for churn
Chosen
One offer at a time, with batching in dense zones

Drivers see clear, exclusive offers, acceptance is easy to reason about, and batching keeps matches fair when demand is high.

Clean and fair
Rejected
Broadcast to several drivers, first to accept wins

Faster to fill in quiet areas, but most drivers lose the race and learn to ignore offers. It also creates the very conflicts the claim step is there to prevent.

Only for scarce supply
Chosen
Strong, versioned writes for trip and driver state

A driver can never hold two trips, a cancel and an accept cannot both win, and retries are safe. These writes run at about a thousand a second, so the cost is negligible.

Non-negotiable
Rejected
Eventually consistent state with later repair

Higher availability on paper, but a double booking reaches real people at a real kerb before any repair runs. Save eventual consistency for locations and analytics.

Visible to users

12

Failure modes

12

Failure modes

📡
GPS jumps between streets

In dense downtowns, reflections off glass towers move a reported point 100 metres onto a parallel road. Riders see the car teleport, and ETA swings by minutes.

Filter with a Kalman model, snap to the road graph using heading and speed, and discard fixes whose reported accuracy is worse than 50 metres. Smooth the icon on the client.
👻
Ghost drivers in search results

A driver's phone dies or loses signal, but their last position stays in the index. The dispatcher keeps offering them trips that time out after 10 seconds each.

Give every index entry a 30-second TTL refreshed by each update, and skip candidates whose last point is older than 15 seconds. Penalise drivers with repeated silent timeouts in ranking.
🔥
One index shard overheats

A stadium, an airport or a festival sends most of a city's searches and updates to the shard that owns that area. Latency on that shard climbs and requests elsewhere in the city slow down with it.

Coalesce searches per hex, add read replicas for hot shards, split a hot parent cell into finer shards when write rates cross a threshold, and route venue pickups through a queue.
👯
Two riders get the same driver

Two dispatchers pick the same driver within a few milliseconds, or an accept retry lands after the first attempt succeeded. Without a guard, two riders both see the same car arriving.

Claim through a versioned compare-and-set before any offer is sent, make accept idempotent on offer ID, and run a reconciler that alerts on any driver with two non-final trips.
✂️
The cell loses its trip store mid-trip

A zone outage takes the primary of the trip store offline while thousands of trips are in progress. New writes fail and drivers cannot mark trips complete.

Replicate synchronously across zones with automatic failover. Driver apps queue state changes locally with timestamps and replay them once the store is back, and fares are computed from the location log, so they remain correct.
🔌
A gateway node crashes

Sixty thousand phones lose their sockets at once and reconnect in the same second, overloading the remaining nodes with handshakes.

Clients reconnect with randomised backoff, the load balancer limits new connections per node per second, and phones resume from the trip endpoint so no state is lost.

13

Designs to avoid

⚠

Designs to avoid

🚫
Using one global geospatial index for every city

Every update and search crosses the whole fleet, one city's spike slows the world, and any outage is global.

Instead: run one index per city cell, sized for that city's drivers.
🚫
Saving every location update to the main database

The database spends its life writing rows that are obsolete a few seconds later, and real transactional work queues behind them.

Instead: keep live positions in memory with a TTL and append history to a log.
🚫
Ranking drivers by straight-line distance alone

Rivers, one-way streets and highways make the nearest dot on the map a slow pickup, so riders wait longer than they would with the second-closest car.

Instead: filter by distance, then rank the short list by road ETA.
🚫
Locking drivers with a separate distributed lock service

Locks expire, holders crash, and you now have two sources of truth about who owns a driver.

Instead: make the driver's status row the lock, using a conditional versioned write.

14

How to run the interview

13

How to run the interview

1

Start with one rider and one driver. Walk the happy path end to end, then add concurrency. It gives the interviewer a map before you go deep.

2

Let the numbers pick the storage. Show that location writes outnumber trip requests by several hundred to one. That single ratio justifies an in-memory index and a separate durable path.

3

Name your spatial index and defend it. Geohash, S2 and H3 all work; say which, why, and how a ring search finds candidates. Vague "use a geo index" answers lose points.

4

Spend real time on double assignment. This is where depth shows. Explain the conditional write, the offer timeout and what happens to a late accept.

5

Partition by geography from the start. City cells contain failures and keep each problem small. Mention the awkward cases: cities near borders and airport trips across zones.

6

Separate the lossy path from the strict one. Locations can drop a point; trips and fares cannot. Interviewers like candidates who choose consistency per data type.

7

Bring up spikes before you are asked. Stadiums and rain storms are where these systems break. Surge as a feedback loop, coalesced searches and venue queues show operational sense.

15

Growing the design in stages

15

Growing the design in stages

Stage 1: one city, a few thousand drivers
A single service and Postgres with PostGIS

Drivers post locations over HTTP, a spatial query finds nearby cars, and a transaction assigns them. Good enough to prove the market.

Stage 2: tens of cities
In-memory index and sockets

Live positions move to Redis with geospatial keys, drivers connect over sockets, and the trip store gets versioned writes. History moves to Kafka.

Stage 3: hundreds of cities
City cells and H3

The stack is cloned into cells so one city's spike cannot hurt another. The index switches to H3 hexes, sharded by parent cell, and surge pricing reads the same hexes.

Stage 4: planet scale
Regional resilience and smarter matching

Cells span availability zones, trip stores replicate across regions for disaster recovery, matching batches requests in dense zones, and ETA gains its learned correction layer. The original PostGIS query survives only in the internal admin tool.

Watch and read

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.