system design

Design a ride-hailing system without drowning in GPS pings

The number that shapes this whole design shows up before you draw a single box. A mature ride-hailing network runs on the order of five million drivers online at peak, each phone reporting GPS roughly every four seconds. That works out to about 1.25 million location writes per second, and almost none of them change anything a rider will ever query. Get that ingestion path wrong and none of the clever matching logic downstream gets a chance to run.

Interviewers at Uber, Lyft, and the delivery companies that copied their marketplace hand you some flavor of “design Uber” as a 45-minute round. What they’re checking is whether you can pull apart two problems that look like one: a firehose of writes with weak durability needs, and a small volume of reads that are extremely sensitive to latency and correctness. If you treat “find the nearest available driver” as a SELECT ... ORDER BY distance LIMIT 1, you’ve already lost the round. It’s a geospatial indexing problem.

Scope it before the whiteboard fills up

Say the happy path out loud so you and the interviewer agree on the target. A rider opens the app and sees cars nearby. They request a ride, get matched to one driver within a couple of seconds, watch that driver approach on a live map, take the trip, and pay at the end. Ratings, driver payouts, fraud checks, and promo codes are real, and you should name them as deliberately out of scope so you don’t spend twenty minutes on the wrong thing.

The non-functional targets constrain the design more than the features do. Matching should feel immediate, so give yourself a budget of two to three seconds from request to a driver’s phone buzzing. Map freshness beats durability: a dropped GPS ping is invisible to everyone, but a trip that gets billed twice is a support ticket and a chargeback. That asymmetry between what has to be correct and what can be lossy is the lever you’ll pull for the rest of the hour.

Run the numbers so the components size themselves

Five million active drivers pinging every four seconds gives about 1.25 million writes per second on the location path. Rider requests are orders of magnitude rarer, call it a few tens of thousands per second at peak across a global footprint, and completed trips land around twenty-five million a day. So the location stream is roughly 100x the request stream and needs its own home. A single Redis node handles low hundreds of thousands of operations per second, so you’re already committed to sharding location state across many nodes, partitioned by geography rather than by driver ID.

The location stream lives in memory, not in Postgres

Writing 1.25 million rows per second to a durable relational store is possible and expensive, and it buys you nothing, because a driver’s position from eight seconds ago has no value. Keep the current position of every online driver in an in-memory store, sharded by region, keyed by driver, overwritten on each ping. Redis with its geospatial commands, or an equivalent in-house service, is the usual answer. The location layer becomes fast and disposable; if a shard dies, drivers repopulate it within one ping cycle.

Only two things need to escape memory. Trip location history gets sampled every few seconds and streamed to durable storage for receipts, disputes, and mapmaking. And the aggregate supply per area feeds the pricing pipeline. Everything else stays hot and ephemeral.

“Nearest driver” is a geospatial index question

Computing distance from a rider to five million drivers on every request is a non-starter. You want to look only at drivers in the rider’s immediate vicinity, which means partitioning the map into cells and indexing drivers by cell. When a request comes in, you resolve the rider’s cell, gather that cell plus its neighbors, and rank the handful of candidates inside. Four schemes show up in these discussions, and knowing why you’d pick one signals real experience.

Geospatial index How it partitions the map Neighbor / range lookup When to reach for it here
Geohash Recursive rectangular cells encoded as a base-32 string, where a shared prefix means physical proximity Prefix match plus the eight adjacent cells to cover boundaries Default answer in an interview: trivial to store as a string key and explain in one sentence
Google S2 Projects the sphere onto cube faces and orders cells along a Hilbert curve into hierarchical squares Range scan over cell IDs along the curve Global coverage with no polar distortion; what Uber’s DISCO dispatch layer actually uses
QuadTree Recursively splits a region into four quadrants, subdividing only where driver density is high Tree descent to the leaf, then walk sibling leaves Wildly uneven density (packed downtown, empty suburbs) where fixed-size cells waste space
H3 Hexagonal hierarchical grid where every cell has six equidistant neighbors Ring query expanding outward from a center hexagon Supply-and-demand aggregation for surge, where equal-distance neighbors avoid diagonal bias

Uber’s own dispatch service, DISCO, shards the map with Google’s S2 cells and routes each cell to a worker through consistent hashing, so every driver in one area is handled by one node. They later built H3, a hexagonal grid, because hexagons have six equidistant neighbors and that makes the supply-and-demand math for pricing far cleaner than square cells, where diagonal neighbors sit farther away than edge neighbors. For an interview, geohash is the fastest to explain and defend. Reach for S2 or H3 when the interviewer pushes on aggregation or on uneven cell sizes near the poles.

Matching, and the two-drivers-one-request race

Once you have a short candidate list, matching is a small optimization: rank by ETA rather than straight-line distance, since a driver across a river is far in minutes and close in meters, and factor in the driver’s heading and how long they’ve waited for a fare. The hard part is concurrency. Two nearby riders can request at the same instant and both see the same idle driver.

Solve it with a compare-and-swap on driver state. A driver sits at available; matching flips them to matching only if the current value is still available. Whoever wins the CAS owns the offer, and the loser drops back to their candidate list. Production systems often go further with parallel dispatch: offer the trip to the top few candidates at once and give it to the first who accepts, which cuts the wait when drivers are slow to tap accept, at the cost of occasionally offering a trip that someone accepts a beat too late and has to be told never mind.

The trip is a state machine, and that’s where correctness lives

A trip walks through requested, matched, driver en route, in progress, and completed, plus the sad branches of cancelled and no-driver-found. This is the part that must not lose or duplicate data, so it belongs in a durable, transactional store: one row per trip, with each transition guarded so you can’t move from completed back to in progress. Make the payment step idempotent with a per-trip key, because the driver’s phone will retry the “trip complete” call on a flaky connection and you cannot charge the rider twice for one retry.

Live location during the trip rides a separate channel. The driver’s pings flow through the location service and get pushed to the rider’s app over a websocket or similar persistent connection, so the moving car on the rider’s map is the same data stream you already built, filtered to a single trip.

Surge pricing is a streaming aggregation

Surge isn’t a number you store and read back. You recompute it continuously per area. Ride requests and driver-availability events stream through Kafka, get aggregated per geo cell over a rolling five-minute window, and the ratio of demand to supply sets a multiplier that gets recalculated every minute or two. To avoid an edge artifact where a driver one meter outside a cell is invisible to it, aggregate over a ring of cells around the target, which is exactly what hexagons make tidy. Smooth the multiplier over time and rate-limit how fast it can move, so the price doesn’t oscillate between 1.0 and 2.5 every minute and train riders to just wait sixty seconds.

Where the interviewer takes it next

Once the skeleton is standing, the follow-ups probe the corners. Expect some of these, phrased about this bluntly:

  • “A driver loses signal in a dead zone for thirty seconds mid-approach. What does the rider see, and what happens to the match?”
  • “You’re launching in a brand-new city with almost no drivers. How does matching behave, and does surge help or hurt?”
  • “How do you stop one region’s traffic spike from taking down dispatch for the rest of the country?”
  • “A delayed flight lands and empties an airport at once. Walk me through what your geo cells and surge pipeline do over the next five minutes.”

The real answer to most of them traces back to the same two moves you made early: geo-shard everything so a hotspot stays contained inside its own nodes, and keep the lossy real-time layer strictly walled off from the durable trip-and-payment layer. A candidate who can point at the airport surge and follow it through the same ring aggregation they drew twenty minutes earlier reads as someone who has actually run one of these, not someone reciting a diagram.

newsletter

What's actually being asked right now

Interview patterns & comp trends, straight to your inbox.

No spam. Unsubscribe anytime.

newsletter

What's actually being asked right now

Interview patterns & comp trends, straight to your inbox.

No spam. Unsubscribe anytime.

1972 Soviet postage stamp commemorating the Mars 2 probe

worth a read

Mars For The Rest of Us — a weekly-or-more deep dive on the technical side of Mars exploration: rocket propulsion, microbiology, mission architecture, and everything in between. Written by Maciej Ceglowski.

Read it on Substack →
Scroll to Top