Why Your Ride-Sharing Database Fails at 250,000 Writes a Second
Quick Overview
This resource explains how to design a scalable nearest-driver matching system for ride-sharing and marketplace-style applications. It covers why simple latitude and longitude database queries fail at high write volume, how spatial indexes like geohash, quadtree, Redis GEO, and H3 help find nearby candidates efficiently, and when each approach makes sense. It also breaks down the parts many system design answers miss: splitting hot real-time location state from cold historical storage, handling hundreds of thousands of driver location updates per second, preventing double-booked rides with atomic conditional writes, and using cell-based supply-and-demand counts for surge pricing. This guide is built for software engineers, backend engineers, site reliability engineers, and system design interview candidates who want to move beyond buzzwords and explain the correctness, tradeoffs, and failure modes behind a production-grade matching service
The matching bug nobody tests for, and knowing when a spatial index is overkill.

One pin, many cells. The whole design is deciding which few tiles to look at instead of scanning the entire floor.
The nearest-driver query is a self-inflicted DoS
That friendly query, find me the nearest driver, is the one that kills you. It reads like a SELECT. At scale it behaves like a denial-of-service attack you wrote yourself.
Every driver app pings its location every few seconds. In a busy city that lands at roughly 250,000 writes a second, all of them updates, all of them hot. A single relational database will not take that write load and answer proximity queries in under a second. It does one or the other, badly, and it tends to pick the evening rush to fall over.
So "nearest available driver in under a second" is really two jobs, not one. Find the candidates fast. Then give the ride to exactly one of them. Almost every write-up nails the first job and hand-waves the second. The second is where the money leaks, and it is the more interesting half.
This is also, not by accident, the design a good interviewer reaches for when they want to see how you actually think.
A B-tree indexes a line, but the Earth is a plane
You might think: put lat and lng in Postgres, add an index, run a bounding-box query. It seems reasonable. It keeps seeming reasonable right up until the evening rush. Then it falls over.
A B-tree indexes one ordered axis. Think of it as sorting books by title, alphabetically. Brilliant for finding one exact title. Useless for "which shelf is closest to this spot on the floor." You can sort your drivers by latitude, or by longitude, but nearness lives in both dimensions at once, and no single-axis index gives you that. So the database does the only thing it can. It walks every candidate row, measures the distance, and sorts. An O(n) scan (the work grows with every extra driver) wearing the costume of a query.
A B-tree indexes a line. The Earth is a plane. That mismatch is your whole problem.
Every location update hits that table. Every rider request scans it. Two O(n) firehoses aimed at one box, which is the wall everyone eventually smacks into.
Four questions before you touch a spatial index
Before you reach for a fancier structure, answer four questions, because the answers decide which structure you actually want.
- What is your write volume, and can your durable store swallow it? If it can't, you are splitting your writes into two paths whether you like it or not, and we will get to that.
- How lumpy is your density? Evenly spread points are happy on a fixed grid. Wildly uneven ones, packed downtown and empty at the edges, want something that adapts.
- Do you need every neighbour the same distance away, for smoothing or surge maths? If yes, that pushes you towards hexagons.
- Are you querying the neighbouring cells, or only the one your point lands in? If it is only the one, you already have a bug. You just haven't watched it bite yet.
Geohash, quadtree, H3: pick by density, not by habit
The only way out of that scan is a spatial index. Think of it as a filing system for the map: nearby things live in the same drawer, so a proximity query opens two or three drawers instead of reading every file in the building. Three of these come up over and over, and the real skill is knowing which one to grab.
Start with geohash, reach for a quadtree when density is uneven, and choose H3 when neighbour distances need to be uniform. Here's why.
Geohash: the simplest start, and Redis gives it to you free. Begin here. Just don't assume you have to stop here. A geohash squashes a lat/long pair into a single value where a shared prefix means things sit physically close. GEOADD drops each driver into a sorted set scored by a 52-bit geohash, and GEOSEARCH pulls back everyone inside a radius in logarithmic time. No table scan anywhere in sight.
GEOADD drivers -73.98 40.75 "driver:42" # 52-bit geohash as the sorted-set score
GEOSEARCH drivers FROMLONLAT -73.98 40.75 BYRADIUS 2 km ASC COUNT 10
proximity in log time, not a full scan
There's an off-ramp here. If your write volume is a few hundred matches a minute, stop reading and run PostGIS on a single box. It is the right tool at that size, and everything below is machinery you will resent maintaining. The rest of this is for people whose write volume genuinely breaks a relational database.
This one bit me. My first version hand-rolled geohash prefixes and only looked inside the cell the rider landed in. It was clean and fast. It was also quietly broken. The nearest driver was parked one street over, in the next cell along, and my code never once saw him. Grid lines do not care where people actually stand.
Geohash isn't the whole answer. It's the honest starting point.
One rule fixes this: never query only the cell your rider falls in. Always pull the eight cells around it too. Redis GEOSEARCH BYRADIUS does that neighbour maths for you under the hood, which is the real reason to lean on it instead of hand-rolling prefixes the way I did.
The rider sits in the middle cell, but the nearest drivers fall just over the edge. Query the eight neighbours or you never see them.
Quadtree: for when your density is uneven. A geohash grid is stubborn. Same cell size in a dead suburb and a heaving downtown, no matter what's actually there. A quadtree only splits a cell when points pile up inside it, so the busy squares get carved fine and the empty ones stay coarse. You pay for that with more code, an in-memory structure, and the odd rebalance, but it bends to the map instead of fighting it. H3: for uniform neighbours. H3 is Uber's public answer to all this: hexagons instead of squares. Hexagons hand you one thing squares never will. Every neighbour's centre sits the same distance away, six of them, evenly spaced. Squares give you eight, and the diagonal four are further off than the straight four, which quietly warps anything you average across cells. Surge included. Reach for H3 when you genuinely need that evenness and you're at a scale that earns the extra moving parts.
Split the write path: one hot, one cold
For everyone still reading, the write storm itself needs a proper solution. 250,000 updates a second is cheap to serve and expensive to keep, so you split the path in two.
On the hot path, there is only one question that matters: where are the drivers right now? Pings land in the in-memory geo index and overwrite the last position. Reads come back in under a millisecond, which is exactly what the matcher needs. And the hot path is ephemeral on purpose. A location that is stale in three seconds does not need durability. If Redis fails over and loses a few seconds of positions, drivers re-ping and the index quietly repairs itself.
The cold path answers slower questions. Where was this driver an hour ago? What route did they take? Who do we bill when a rider disputes a fare? The same pings fan out to Kafka, and a consumer writes them into a time-series or columnar store. Durable, cheap per write at volume, and nobody is waiting on it in real time.
Current state and history are different products. Stop making one database do both.
I keep seeing teams fire the whole write storm straight at their durable store, because "we might need the data." That's the mistake. You will need the data. You just don't need it on the hot path, and forcing it there is how you end up with a database that's permanently on fire. Split it. Fast throwaway state on one side, slow permanent history on the other, and suddenly each half is something you can actually reason about.
Two drivers tapped Accept in the same millisecond
Matching is where juniors stop and seniors start. On paper the flow is easy. A rider requests, the matcher pulls the N nearest available drivers from the geo index, fires an offer at several of them at once, the first to accept wins the ride, and the rest get "gone."
The whole thing hangs on that one word: first.
Two drivers tap Accept on the same block in the same millisecond. Both requests read the ride, both see driver_id sitting empty, both write themselves in. Now one rider has two cars on the way and your support inbox has a fresh ticket.
ride = get_ride(ride_id)
if ride.driver_id is None: # both requests read None at the same ms
ride.driver_id = this_driver # both write. one rider, two drivers.
save(ride)
Read something, then write it, across two machines, and you have a race every single time. The fix is to stop reading first. Let the write itself pick the winner, in one atomic step:
first writer wins. everyone else gets 0 rows and a clean rejection.
rows = db.execute(
"UPDATE rides SET driver_id = :d WHERE id = :id AND driver_id IS NULL")
if rows == 0:
return "TAKEN" # the race closed itself
That AND driver_id IS NULL is the whole trick. The first write finds the condition true and claims the driver. Every write behind it matches zero rows and walks away with a clean "taken." No lock to hold, nothing to leak. Prefer Redis on the hot path? One command does it: SET ride:{id} driver:{d} NX. The NX flag just means "only set this key if it doesn't already exist," so the first SET wins and everyone else bounces off.
The same guard protects the other side of the match. A driver can only hold one ride at a time, so before you hand them the trip, you claim the driver row the same way, inside the same transaction:
same transaction: claim the driver only if they're still free
rows = db.execute(
"UPDATE drivers SET status = 'BUSY' WHERE id = :d AND status = 'FREE'")
if rows == 0:
return "DRIVER_GONE" # they accepted a different ride first

A real assignment needs both writes to win: the ride still unclaimed and the driver still free. If either conditional touches zero rows, one of them was already taken, so you roll the transaction back and offer the trip to the next candidate. Skip this half and you get two cars for one rider again, just from the other direction.
The double-booking bug never shows up in your tests. It shows up in your support queue.
Surge is a counting problem you already solved
If you're preparing for systems-design interviews, the follow-up question here is almost always surge pricing. And the geo index you just built hands you the answer. Count demand against supply per cell over a short window. When the ratio crosses a line in a given geohash or H3 cell, that cell gets a multiplier. For example, 12 requests against 3 free drivers in cell dr5ru is a ratio of 4, and if your threshold sits at 3, that cell tips into a 1.2x multiplier. Per cell, never city-wide, because the airport can be starving while downtown is drowning.
A couple more that come up every time:
Why not just use PostGIS? It's brilliant at rich spatial queries and durable storage, and it's the wrong tool for soaking up 250,000 throwaway writes a second on the hot path. Put it on the cold side, where it earns its keep.
Geohash or H3 in an interview? Open with geohash and Redis GEO, because it is the fastest correct answer. Mention quadtree for uneven density. Then say H3 is what Uber open-sourced. Naming all three, and when each fits, is the signal they are listening for.
The bill for doing it properly
A proper solution comes with proper costs. Here's what you're actually paying for.
You are running two data paths now instead of one. That is two things to monitor, two sets of alerts, two failure modes to reason about at 6pm on a Friday. When the numbers in your dashboards disagree, and they will, someone has to know whether the hot path or the cold path is telling the truth. That is a real tax on whoever is on call, and it shows up on no architecture diagram.
The in-memory index is ephemeral by design. That is a feature, right up until a failover drops live positions for a few seconds while drivers re-ping. Usually that is fine. But "usually fine" is a call you make on purpose, with product in the room, not a default you sleepwalk into.
The idempotent write (meaning you can safely repeat it without changing the outcome) buys you correctness, and it costs you a round-trip on the hottest path in the whole system. At this volume, round-trips aren't free. You'll spend real hours making sure that conditional update is indexed and quick.
And somebody owns surge thresholds per cell, forever. That is not a one-off config. It is a standing tuning job that drifts with the city, the season, and the weather, and it never truly finishes.
The hard part was never scale
Step back and the whole design is quietly pointing at one thing. The geospatial half is the famous half, the part that gets the diagrams and the whiteboard questions. But the index is not what saves you. Any of the three structures will find the nearest driver. What decides whether the system actually works is an older, quieter problem: who wrote first.
Two drivers, one ride, a millisecond between them, and the entire outcome turns on the order the writes landed. That is the shape of most hard problems in distributed systems once you strip the buzzwords off. Scale you can buy your way out of, you add machines and move on. Sequence you have to design, deliberately, up front, because no bigger box will fix a race you did not close.
That gap, between name-checking Redis and being able to explain why the eighth neighbour matters, or why one conditional write settles a dead heat between two drivers, is exactly what a senior interview is probing for. It's the difference between someone who has read about Uber and someone who could rebuild a slice of it. Exactly the kind of thinking we sharpen at PracHub.
If you're preparing for a system design interview, or just trying to survive your next production outage, followPracHubfor more engineering deep dives.
Related Articles
Design WhatsApp: the presence and receipt problems most candidates ignore
Design WhatsApp-style chat with WebSockets, offline inboxes, Kafka partitions, presence TTLs, receipts, and reliable delivery.
I Pinned Our Autoscaler for a Month to See What Would Break. Nothing Did.
Learn when Kubernetes autoscaling helps, when CPU-based HPA wastes money, and how capacity planning can cut cloud costs safely.
One Line of Code Halved Our Storage. Another Stopped Us Re-Encoding Two-Hour Films.
Learn how to scale video transcoding with segment-level retries, CMAF packaging, CDN caching, and VOD system design tradeoffs.
"Just Make It Idempotent" Is Half an Answer in a System Design Interview
Learn idempotency for system design interviews: payment retries, duplicate requests, database constraints, outbox patterns, and exactly-once myths.
Comments (0)