Prep · System Design

Ride-Hailing Service

Two systems wearing one name: a location firehose allowed to lose a message, and a trip that can never be assigned twice.

The Prompt

Design a ride-hailing service.

Ask First

Nothing gets drawn until these are answered. Each one moves a box on the board or removes it.

  1. 01One city or the whole map? City-scale is one shard of the global answer, and I would rather design the shard and then say how many.
  2. 02Do drivers publish location continuously while on duty, or only when we ask? That single number is the largest load in the system and everything else is rounding.
  3. 03What is matching optimising — rider wait, driver utilisation, or platform take? Those three want different matchers and the answer is a business decision, not a technical one.
  4. 04Are pooled rides in scope? Pooling turns matching from one-to-one into a routing problem with a solver in it, and I would rather draw the one-to-one system first.
  5. 05What happens when nobody accepts? Widen the radius, raise the price, queue the rider, or tell them no — the design has to pick one and say so.
  6. 06Who pays when a ride is cancelled, and from which state? There is a driver's time on the other side of that schedule, so I want to be told the policy rather than invent one and find out at launch.
  7. 07Is the driver app allowed to be offline mid-trip, and for how long before we call it a problem?
  8. 08Who owns the map — do we buy a road graph and ETAs, or build them?
  9. 09Who holds the money — do we settle to drivers ourselves, or does the rail pay them straight out of each charge? That answer is the difference between a marketplace with a ledger and a merchant with a payout problem, and it is very hard to change later.
  10. 10What has to reconcile at the end of the day, and against whose report?

Arrow keys pan the board, plus and minus zoom it, zero frames it on this step again.

  • client
  • store
  • service
  • edge
  • queue
  • worker
  • cache
  • third party
  • async
  1. 01

    Pin the trip's states before drawing a box

    Before I draw anything I want the state machine on the board, because every decision after this one is about defending a single row.

    A trip is one row and one state machine: requested, matching, offered, accepted, arriving, arrived, in progress, completed. Arrived is its own state because the money changes there: the car is parked, a clock is running, and five minutes later the bill starts moving. There are four other ways it ends and I would rather name them now than discover them in the money step — unmatched, when the search ran out of drivers; cancelled by the rider; cancelled by the driver; cancelled by us. Fault is a field on the row, not something support infers from a timeline later, and cancellation is legal only from the states before in progress: a trip that stops after pickup ends early, which is a completion with a shorter route and a different bill, not a cancellation. Unmatched exists because the alternative is a request ageing in a queue forever, which is a spinner pretending to be a state. Every transition names who is allowed to make it: the rider cancels for free before anyone commits, the pickup geofence moves arriving to arrived and starts that clock, the driver moves arrived to in progress, and nobody outside the trip service writes the row at all. The request carries rider, pickup, dropoff, product, and an idempotency key; it does not carry a driver, because a driver is a result and not an input. One entry point, one writer, one row that is the truth — everything else on this board is a cache, a copy, or a stream.

  2. 02

    Send the location firehose somewhere else entirely

    The pings never touch the trip database. This is the decision the whole design turns on.

    A quarter of a million writes a second against a relational store that also owns money is not a scaling problem, it is a category error, and no amount of read replicas fixes it. So the ping path gets its own fleet, its own load balancer, and its own contract — accept, append, return, with no read, no join, and no transaction anywhere on it. The stream is partitioned by cell and retained in hours rather than years, because a position is worthless the moment a newer one lands, and that is what licenses the cheapest durability in the system. The real cost here is not the 25 MB/s of payload, it is holding a million TLS sessions open, which is why this fleet is sized on connections and the trip fleet is sized on queries.

  3. 03

    Index space, not distance

    Nearby has to be a key lookup. The moment it becomes a distance computation over a table, this design is finished.

    Every position gets a cell id — H3, S2, geohash, the family matters less than the property, which is a fixed hierarchical grid where a point maps to a key by arithmetic and neighbours are enumerable without asking anyone. The writer folds each ping into that cell's bucket, keyed by driver and last write wins, so the live map of the fleet is a few hundred megabytes of memory instead of a spatial index absorbing a quarter of a million writes a second. Resolution is the tuning decision: too coarse and one lookup returns thousands of candidates, too fine and a single request has to read a ring of dozens of cells. Distance is still computed — on the handful of candidates the lookup returned, never as the means of finding them.

  4. 04

    Offer with a lease, do not assign

    The matcher never assigns a driver. It makes a short-lived offer with a lease and a countdown on it.

    One driver at a time holds an exclusive lease on this trip for fifteen seconds, pushed down a socket the driver's app already holds open, because a poll cannot ring a phone and a phone that has to be rung is the whole product. If the timer expires the lease dies on its own and the next candidate is offered — expiry is the mechanism, not a cleanup job that has to run for correctness. A driver holds at most one live offer, so two matchers working two different trips do not normally ring the same phone; that is the lease doing its job, and the accept two steps from here is what makes it a guarantee rather than a habit. The search is bounded, and I will say the bound rather than wave at it: three candidates a round at a 15-second lease each, then the radius widens one ring and the price multiplier is re-read, and three rounds is all there is — a hard ceiling of 135 seconds. At the ceiling the trip moves to unmatched, the request leaves the queue instead of ageing in it, and the rider is told no with the pickup still filled in so re-requesting is one tap. Telling someone no in two minutes is a better product than a spinner that never resolves, and it is the only version of this where queue depth still means something. The queue in front of the matcher is what lets a city's demand exceed a city's matchers for a minute without dropping a request on the floor.

  5. 05

    Make the accept linearizable, and only the accept

    Everything else on this board is allowed to be eventually consistent. The accept is not.

    The accept is one conditional write over two rows in a single transaction: set the driver and move offered to accepted only if the trip's state is still offered and the offer id still matches, and only if that driver's current trip is null — then stamp the trip onto the driver's row. Both directions have to be in the predicate, because they are different races. The trip clause stops two drivers taking one ride. The driver clause stops one driver taking two, which is what happens when two matchers working two different trips both reach the same phone. Exactly one attempt reports rows changed; every loser gets a screen that says taken — a real screen a person understands, not an error anyone has to debug. The lease is an optimisation over the driver clause, not a substitute for it: it keeps us from ringing two phones and it makes the race rare. With the lease store down the accept is still correct and merely louder — more phones ring, more drivers are told taken, and the decline rate on the dashboard goes up for a reason we already know. Once the write commits the lease is cleared and the transition goes on the log; the driver becomes offerable again when the trip closes, not here.

  6. 06

    Price and ETA read the index the matcher already reads

    Surge is not a second system. It is the same cell index, counted a different way.

    Supply is idle drivers per cell, which the geo-index already holds; demand is open requests per cell, which the match state already counts while it hands out leases. A multiplier per cell recomputed on the order of a minute, smoothed and capped, is enough — a price that moves faster than a rider can walk two blocks is a support ticket, not a market. The ETA is a road-graph question and I buy it rather than build it: a third-party routing call, cached hard by cell pair, because straight-line distance happily ranks the driver across the river as the closest one. The quote before matching is a coarse estimate; the ETA the rider watches afterwards is the matched driver's actual route, and the two are allowed to differ as long as we say which is which.

  7. 07

    Shard by city, because a city is the blast radius

    Geography is already the shard key here — I would rather choose it deliberately than discover it during an incident.

    A ride does not span two cities, so every hot structure partitions cleanly by city: the cell index, the match queue, the matchers, the trip rows, the pricing loop. The gateway resolves a cell to a city stack at the edge and pins the call there, which means there is no cross-shard transaction anywhere on the critical path and a city can be failed over, drained, or upgraded on its own. Hashing on trip id would spread load more evenly and would also turn every nearby query into a scatter-gather across every shard on earth, which is the wrong trade twice over. The cost is honest: cities are wildly uneven, so the largest ones get their own capacity and their own on-call, and a rider crossing a boundary needs a rule that says the trip belongs to the city it started in.

  8. 08

    Two durability classes, drawn apart

    A lost ping is a stale dot on a map. A lost trip row is a ride nobody can prove happened.

    The location stream is allowed to lose messages, run thin on replicas, and expire in hours; it is downsampled into cold storage only because disputes and fraud reviews need a trace of where the car actually went. The trip log is the opposite in every respect: each state change appended in order and committed with the row it describes through an outbox, so a crash between the two is a delayed publish rather than a trip that vanished. Closed trips age out of the hot table into an archive that history reads happily and that matching never touches, which keeps the working set the size of the last few days. The rule I would write on the wall: anything that becomes money is durable, anything that becomes a dot on a map is not.

  9. 09

    Now break it on purpose

    Leases outstanding, phones asleep, and a stadium emptying — this is where a matcher earns its keep.

    A matcher that restarts mid-offer holds nothing anybody needs, because the leases live in shared state with TTLs: its outstanding offers expire on their own and the trips flow back onto the queue to be offered again, which is also the recovery path for a matcher that is merely slow. A driver app that goes quiet stops refreshing its cell entry and falls out of the candidate set within a couple of ping intervals — that is the correct behaviour, and it is why the entry carries a TTL rather than waiting for a delete that will never arrive. A rider cancelling mid-match races the same conditional write the driver would have taken, so first writer takes the row and a cancel that lands first simply makes the accept fail. A cell that goes hot because a venue emptied gets split at a finer resolution while the queue absorbs the burst, which turns a stampede into a wait, and a wait is a thing a screen can explain.

  10. 10

    Charge for the value destroyed, not for the act

    A cancellation fee is not a fine. It pays a driver for a drive that already happened, so it is sized to their deadhead and most of it has to reach them.

    Three inputs decide whether a fee is owed and how large: who cancelled, what state the trip was in, and how much irreversible work already exists — the deadhead minutes and kilometres nobody is otherwise paying for. The schedule that falls out of those three is in the numbers above, and the property that matters is that it is total: every state a trip can be cancelled from has a row, because a gap in the table is a rider or a driver finding out at the worst possible moment which one of them we forgot. This box decides the fault and the tier; it does not price them. The fare service downstream is the only thing on this board that turns a decision into an amount, which keeps the idempotency argument in one place instead of two. Platform fault is a real value in that field, not an afterthought — a bad match, a surge glitch, a driver app that crashed, all free — and it is a field rather than a support agent's reconstruction a week later. The fee is shown before the rider confirms, because a surprise charge becomes a dispute and a dispute costs several times the fee. And a driver cancelling is not an ending: the trip goes back to matching with the search budget reset, and the rider is told the car cancelled rather than left watching an ETA that stopped falling.

  11. 11

    Price the driver's side in reputation, then detect what that invites

    Cash does not stop a driver cancelling, it changes the shape of it. So this side is priced in reputation, and that forces a detector.

    A driver charged cash for cancelling stalls at the kerb, drives slowly, or rings the rider and asks them to cancel instead, which converts a driver penalty into a rider fee and makes us the party profiting from our own bad match. So driver cancellations cost dispatch priority and, past a threshold, a suspension conversation, and never money. That asymmetry is exactly what makes induced-cancellation detection load-bearing rather than a nicety: stationary far from the pickup, heading away from it, or still arriving well past a recomputed ETA waives the rider's fee and re-attributes the event to the driver. Without it the schedule is gameable by the only party who can see it coming. The inputs have to be durable, so the trip row carries a small approach record written on the same path as the state transitions — accept time, accept position, cumulative distance, last movement, geofence arrival — and that is what arrives with the cancellation. The live index is corroboration only: one point per driver, no history, and losable by design, so a decision that becomes money never rests on it. The other half of this is whose evidence it is. The accused supplies it, so the driver's fix is read against the rider's, against the geofence event, and against whether the route is physically plausible — a parked spoofed pin should not win the first argument, because the first one is the one that pays.

  12. 12

    The ledger is the source of truth

    Every trip that closes, however it closed, becomes double-entry rows in our ledger before a cent moves anywhere.

    Completion and cancellation are both state transitions, so the fare service consumes closed trips off the log keyed by trip id — a redelivered event recomputes the same number instead of billing twice — and prices whichever thing happened: a fare from base, distance and time, or a fee from the tier and fault already stamped on the cancellation record. It writes the entries before any money moves: rider debit, driver credit, processing cost, platform revenue, tax, five rows that sum to zero for that trip. The processing row is the one people leave out, and leaving it out is what turns a daily reconciliation into a daily break — the rail settles a $5 fee as $4.555 net of its $0.445, so a ledger with no line for the rail's cut disagrees with the rail on every trip it has ever booked. The ordering is the point. The ledger is ours and it is the answer to what everyone is owed; the rail's balance transactions are reconciled against it daily, and when the two disagree the rail is what is wrong until somebody proves otherwise. A break is a named trip id and an amount, found the next morning, not a quarterly surprise. Seven years of it, at 17.6 GB a day, and the retention is not our decision.

  13. 13

    Authorize before dispatch, capture at the end

    The hold goes on the card before anyone is dispatched, so a bad card is a screen rather than a debt.

    The rail here is Stripe and I am going to use its nouns, because the nouns are the constraints. A manual-capture PaymentIntent for the estimate plus a buffer is created at request time, before a single phone is rung. That is the real answer to a declined card: you learn the instrument is bad while nobody has driven anywhere, which turns a debt into a screen asking for another card. The authorization is then a ceiling. As a rule a capture cannot exceed it, so a trip that outruns the hold takes an incremental authorization mid-trip or a second intent at completion. There is an escape hatch and it is narrower than people want it to be: the networks allow overcapture for a short list of merchant categories, and taxi and limousine is on it at about twenty percent on Visa, Amex and Discover — but only on interchange-plus pricing, not for Visa in the EEA, and where strong customer authentication applies you generally have to authenticate at least what you intend to capture, which takes most of it back again. Twenty percent covers a tip. It does not cover a detour, so the design still assumes the ceiling is real and asks the bank a second time. Every mutation on this path carries an idempotency key on trip plus operation, because retries here are guaranteed and the failure mode is somebody's rent.

  14. 14

    Charge on our account, move the driver's share separately

    The driver's money leaves as a second movement, after the charge — the choice that survives tips, pooled rides, and late adjustments.

    The simple option is one charge that splits at the rail — a destination charge with an application fee — and it breaks the moment reality arrives: tips land after the trip closed, a pooled ride pays two drivers out of one payment, and adjustments happen post hoc, none of which survive a split decided at charge time. So we charge on the platform account and issue a Transfer to the driver's connected account afterwards, and the honest cost is that we are now holding other people's money. The cancellation fee gets its own intent instead of a partial capture of the hold, and the reason is not the split — under separate charges and transfers the split is decided at transfer time anyway — it is that a rider disputing a five-dollar fee should be disputing a charge with its own descriptor and its own evidence, not a trip that never happened. The estimate's hold is voided in the same breath, so nobody is carrying two authorizations for one ride they did not take. Payouts are gated on Connect onboarding and KYC, so trips completed before verification finishes accrue to a held balance the driver can see and cannot withdraw, which is a product problem long before it is a compliance one. Daily by default, instant on demand for a fee somebody absorbs — the driver, or us as a retention cost — and that one is a business call rather than an architectural one.

Step 1 of 14. Pin the trip's states before drawing a box. Before I draw anything I want the state machine on the board, because every decision after this one is about defending a single row.

Requirements

Functional

  • 01Drivers publish position and duty state while on duty, and stop when they go off duty.
  • 02A rider requests a ride from a pickup to a dropoff and sees a price and an ETA before committing.
  • 03Match one rider to one nearby driver, with a bounded, explainable search when the first candidates decline.
  • 04A trip is a state machine — requested, matching, offered, accepted, arriving, arrived, in progress, completed, plus unmatched for a search that finds nobody — with cancellation reachable only from the states where it is legal.
  • 05Cancellation from either side, priced on trip state, on who cancelled, and on the work already done — with the driver's share of any fee reaching the driver.
  • 06Authorize the estimated fare before anyone is dispatched, capture what the trip actually cost at completion, and move the driver's split as its own transfer.
  • 07Trip history for both sides: what happened, when, where the car went, and what it cost.
  • 08Support and dispute answers: given a trip id, the whole timeline including the route actually driven.

Non-functional

  • 01Location writes are the highest-volume thing here by two orders of magnitude, and they are the least valuable byte in the system.
  • 02Matching is soft real time: a rider should see a driver in seconds, and a slow match is a lost ride rather than an outage.
  • 03A trip must never be double-assigned. One accept wins, and the loser is told so in a sentence a person can read.
  • 04Regional isolation: the system is sharded by city and a city going down must not be a global event.
  • 05Money reconciles exactly. A double-entry ledger per trip is the source of truth, and the rail's balance transactions are reconciled against it daily rather than the other way round.
  • 06Every money mutation is idempotent on trip plus operation. Retries are guaranteed, and a second charge is not a bounded error the way a second push notification is.
  • 07Degrade in the right order — stale positions and coarse ETAs long before failed rides or unbilled trips.
  • 08Observable per city: match rate, time to match, offer decline rate, unmatched requests, and cancellation rate by fault, each with a threshold that pages.
The Arithmetic

Do it out loud, round hard, and say the assumption you rounded from. The number matters less than the fact that it was derived.

Drivers online at peak
1M
The only number I am guessing. Everything below is derived from it, so if the real figure is 100K the shape holds and the boxes get smaller.
Ping interval
4s while on duty
Every 4s idle and available, every 4s carrying a rider, nothing at all off duty. This is the single knob that moves every number under it, and it is a battery decision as much as an infrastructure one.
Location writes
250K/s peak · ~100K/s avg
1M ÷ 4s = 250,000 writes/s. The online fleet averages about 40% of peak over a day, so 400K ÷ 4s = 100K/s flat. This is the headline number and the reason the ping path is drawn apart from everything else.
Pings per day
~8.6B
100K/s x 86,400s = 8.64B position writes a day, against 20M trips. Roughly 430 pings for every ride that happens.
Ride requests
20M / day ≈ 230/s avg
20M ÷ 86,400s = 231/s. Morning and evening commute peaks run about 5x flat average, so size the request path for ~1.2K/s.
Write ratio
~200 : 1
250K/s of pings against 1.2K/s of requests at the same moment. Two orders of magnitude is not a tuning difference, it is a different system, which is why they share nothing but a phone.
Nearby reads
~25K cell reads/s
1.2K requests/s x 7 cells — one plus its ring — x up to 3 offers before the radius widens = 25.2K/s. Every one is a key lookup on an in-memory map, which is the only reason that number is boring.
Live index size
~200 MB
1M drivers x ~200 B of state — id, position, heading, cell, duty state, current trip, timestamp. Three replicas and generous slack is still under a gigabyte. The entire live map of every driver fits in memory, and that single fact is the whole argument for the design.
Location stream / day
~864 GB
8.64B pings x ~100 B on the wire. Retained six hours as a buffer — 216 GB — and downsampled to one point per 20s for the archive, which is ~170 GB/day and 15 TB at 90 days in object storage.
Trip and event rows / day
~72 GB
20M trips x ~2 KB of trip row = 40 GB, plus 8 state transitions x ~0.2 KB x 20M = 32 GB of events. Twelve times smaller than the ping stream, and the only part of the system that has to survive everything.
Retention
6h · 90d · 7y
Six hours of stream because a position is worthless the moment a newer one lands. Ninety days of trips hot, partitioned by day so expiry is a partition drop. Seven years of ledger, because that one is not our decision.
Hot cell
~28 requests/s, one key
A 20,000-seat venue empties, a quarter of them want a car, and they ask inside three minutes: 5,000 ÷ 180s = 28/s against a single cell that normally sees one request every few seconds. Nothing about the average prepares that key for it.
Offer budget
15s x 3 x 3 rounds ≈ 135s
A 15-second lease, three candidates before the radius widens, three rounds before the search gives up. One round is 45s and the whole search is capped at 135s, after which the trip is unmatched and the rider is told no. That ceiling is why the state machine has a matching state and a terminal one rather than a spinner.
Trip outcomes
17.2M done · 2.4M cancelled
Of the 20M requests a day: 86% complete, 12% cancel, 2% find nobody and go unmatched. 17.2M + 2.4M + 0.4M = 20M. Those three ratios are policy parameters I would want measured per market rather than assumed, and every money number below moves with them.
Who cancels
1.4M rider · 1.0M driver
7% of requests end in a rider cancellation and 5% in a driver one, which is the 12% above. A driver cancelling does not end the trip by itself — it goes back to matching and is offered again — so the 1.0M is the subset where the re-match failed too or the rider gave up while it ran. The driver half never appears on anyone's bill, and that is exactly why it is the half that gets forgotten.
Rider cancels, nobody committed
Free
Requested, matching, or offered. No driver has taken the trip, so no work exists to destroy. Every figure in the six rows below is a policy parameter tuned per market, not a measurement — I would write them in a table and expect to be argued with.
Rider cancels, inside grace
Free
Accepted or arriving, inside 120s and under 0.5 km travelled. Both conditions, because a grace window on time alone quietly stiffs the driver who was three kilometres out and already moving.
Rider cancels, past grace
≈ minimum fare
Accepted or arriving, past either bound. A flat fee sized to the deadhead already driven, not to the trip that never happened — a $60 airport run and a $9 hop destroy the same fifteen minutes.
Rider cancels, driver waiting
≈ minimum fare
Arrived, inside the 5-minute wait window. The car is parked at the kerb and not earning, which is the same destroyed value as the row above.
Rider cancels, past the wait
Fee + per minute
Arrived, past 5 minutes: the flat fee plus waiting billed at the standard per-minute rate. Waiting is work, and the meter is the only part of this schedule that is not flat.
Rider ends it mid-trip
Not a cancellation
In progress is early termination, priced as base plus the distance and time actually driven and booked as a completed trip. Different event, different accounting, different row in the ledger.
Driver cancels
$0 · dispatch priority
Never cash, in any state. Cancellation rate against dispatch priority with a suspension threshold behind it — the reason is an incentive, not sentiment, and it is the argument in the tradeoffs below.
Fee-bearing cancels
420K / day ≈ 4.9/s
Most rider cancellations are free — before an accept, or inside the grace window. Say 30% of the 1.4M land past it: 420K ÷ 86,400s = 4.9/s, and ~24/s at the same 5x commute peak. That is 2.1% of all requests, and a rounding error against 250K location writes a second. The cancellation path is a policy problem, never a throughput one.
Cancellation money
$2.1M / day · $0.445 to the rail
420K fees x $5, the minimum fare. The driver takes $4.00 and the platform $1.00, of which 2.9% + $0.30 = $0.445 is processing, leaving us $0.555. Nearly half our cut of a $5 charge is the rail's fixed fee, which is the arithmetic reason the split is compensation and not margin.
Holds outstanding
20M / day · ~1.2K/s peak
One manual-capture PaymentIntent per request, created before dispatch, so the authorization rate is the request rate: 231/s average and ~1.2K/s at peak. 2.8M a day are released without ever being captured — the 2.4M cancellations and the 0.4M unmatched — and a released hold still sat on somebody's card for a few minutes.
Transfers
17.6M / day ≈ 204/s
One transfer per completed trip plus one per fee: 17.2M + 0.42M = 17.62M ÷ 86,400s = 204/s. Separate charges and transfers is what makes that number exist at all — with a destination charge the rail splits inside the charge, and then there is no way to pay two drivers out of one pooled payment.
Ledger rows
~88M / day ≈ 17.6 GB
Five entries per money event — rider debit, driver credit, processing cost, platform revenue, tax — against 17.62M events = 88.1M rows at ~200 B = 17.6 GB/day. The processing row is the one people leave out, and leaving it out means every trip reconciles against the rail with a break exactly the size of the rail's fee. Seven years of that is 45 TB, and it is the cheapest 45 TB in the company.
Trade-offs
01

Pushing offers vs drivers polling

ChoseAn open socket per on-duty driver, offers pushed down it
OverDrivers polling for available work on an interval

An offer is worth seconds and a poll interval short enough to feel instant is a second firehose — a million drivers polling every two seconds is 500K requests/s to save a socket.

CostWe hold a million long-lived connections, which makes deploys a reconnect storm, and the channel becomes a stateful tier with its own scaling and its own failure mode.
02

Greedy nearest vs a batched matching window

ChoseOffer the best candidate now, on arrival
OverHolding a two-second window and solving the assignment across the batch

Greedy is simple, explainable to a driver, and the latency it saves is latency the rider can feel. Batching wins a few percent of global efficiency in exchange for a wait on every single request.

CostWe leave real optimisation on the table — pairings that a global solver would have improved — and during a demand spike greedy can send two drivers past each other in opposite directions.
03

In-memory cell index vs a geospatial database

ChoseAn in-memory index keyed by cell, rebuilt from the stream
OverA spatial index in a database with proper geometry types

The whole live fleet is a few hundred megabytes and every write overwrites the last, so durability buys nothing and costs everything at 250K writes a second.

CostIt is losable, so a cold start is blind for one ping interval, and we own sharding, replication, and rebalancing that a database would have owned for us.
04

Strong consistency on the accept vs eventual everywhere

ChoseOne linearizable conditional write on the trip row
OverCoordinating through the lease store or a consensus round in the matcher

Exactly one place needs to be right, and narrowing it to a single row means the expensive guarantee is paid once per trip rather than once per ping.

CostThat row is a serialisation point, so the accept path inherits the trip database's latency and availability — when it is down, rides in flight continue and no new ride can be accepted.
05

Shard by city vs shard by hash

ChoseGeography as the partition, resolved at the edge
OverHashing trip or driver id across a flat fleet

Every query in the matching path is already geographic, so the natural key makes nearby a local read and makes a city the unit of failure, deploy, and capacity.

CostUneven shards. One metropolitan area can outweigh fifty small cities, so the biggest shards need bespoke capacity, and boundary-crossing trips need an explicit owner.
06

Buying the road graph vs building it

ChoseA third-party routing and traffic provider behind our own service
OverOur own map, our own graph, our own traffic model

Routing quality is a decade of work and a data business, and wrapping it in our own service means the vendor can change without the matcher noticing.

CostPer-call pricing on the hot path, so ETAs get cached by cell pair and coarsened under load, and a provider outage degrades ranking to straight-line distance.
07

Reputation vs cash penalties for driver cancellations

ChoseCancellation rate against dispatch priority, with a suspension threshold
OverCharging the driver a fee the way we charge the rider one

A cash penalty does not stop the cancellation, it changes its shape: the driver stalls, drives slowly, or rings the rider and asks them to cancel, and a driver penalty becomes a rider fee we then have to defend.

CostThe deterrent is delayed and statistical rather than immediate, a bad week can push an honest driver past a threshold, and the asymmetry only holds up if induced-cancellation detection actually works — a detector whose false positives waive fees a driver genuinely earned.
08

Separate charges and transfers vs destination charges

ChoseCharge on the platform account, then Transfer to the driver's connected account
OverOne charge with transfer_data.destination and an application fee, split at the rail

The split is not knowable at charge time. Tips land after the trip closes, a pooled ride pays two drivers out of one payment, and adjustments and waived fees happen post hoc — a destination charge has to be right on the first attempt and it is not.

CostWe are holding other people's money: funds sit on our balance, the regulatory weight is ours, reconciliation has to be exact rather than approximately right, and we now issue 17.6M transfers a day that are our failure mode instead of the rail's.
09

Authorizing at request vs charging at completion

ChoseA manual-capture hold for the estimate plus a buffer, taken before dispatch
OverCharging the stored instrument once the ride is over

A card that will decline should be discovered while nobody has driven anywhere. Learning it afterwards means a completed ride, a driver already owed, and a debt to chase.

CostA hold is visible on the rider's statement and is genuinely hostile on a debit card, holds expire after about a week, and a fare that outruns the hold needs an incremental authorization or a second intent — extra machinery on a path we would rather keep boring.
Failure Modes
When

A cell goes hot

Symptom

One key's read and write rate is fifty times its neighbours, match times in that cell climb past ten seconds, and every rider in one postcode is looking at a searching screen.

Mitigation

Split the cell at a finer resolution and spread it across shards, serve candidates from the ring while it rebalances, and let surge do the job it exists for. The queue holds the burst so the failure mode is a longer wait rather than a rejection, and the alert is per-cell time-to-match, not a global average that will never move.

When

A driver's app goes offline mid-trip

Symptom

The rider's map freezes with the car half a block away, the ETA stops falling, and support gets a call about a driver who is actually still driving.

Mitigation

The app buffers positions locally and replays them on reconnect, stamped with their original times, so the trace is complete even when the live map was not. The trip state does not depend on the ping stream at all: the driver can complete the trip from a queued action, and the fare falls back to the route estimate when the trace has a hole in it.

When

Nobody accepts

Symptom

The radius has widened twice, every candidate has declined or let a lease expire, and the rider has watched a searching screen for two minutes. Nothing is broken. There is simply no car, and this is the failure a rider meets far more often than any of the others here.

Mitigation

The bound is written before the search starts: three candidates a round, three rounds, 135 seconds. At the ceiling the trip moves to unmatched, the request leaves the queue, the hold taken at request is released, and the rider gets a sentence rather than a spinner — no cars nearby, pickup still filled in, re-request is one tap. Unmatched rate per cell is the supply signal and it pages dispatch and pricing rather than on-call, because the fix is drivers on the road, not servers.

When

A double-assignment race

Symptom

Two drivers pull up to the same rider and both apps say the trip is theirs — or, in the other direction, one driver's phone shows two accepted rides in opposite corners of the city.

Mitigation

One conditional write decides both directions, because both are in the predicate: the trip's state and offer id, and the driver's current trip being null. The second attempt changes zero rows and that driver is told it was taken before they moved. The lease makes either race rare; the two rows make the outcome certain, which is why the guarantee does not evaporate when the lease store does. If it ever happens anyway, the trip log has every attempt with timestamps, which is the difference between a bug report and a guess.

When

A driver farms the cancellation fee

Symptom

One driver's accepted trips end in rider cancellations three times as often as anyone else's in the same city, always past the grace window, always with a fee. The driver accepts, stops moving, and either waits the rider out or rings and asks the rider to cancel, citing traffic.

Mitigation

This is the failure the asymmetry creates, so the detector is part of the policy rather than an afterthought: stationary far from the pickup, heading away from it, or arriving well past a recomputed ETA waives the rider's fee, re-attributes the cancellation to the driver, and counts it against dispatch priority. The trace archive makes the pattern provable after the fact, and repeat attribution is a suspension conversation, not a refund conversation. False positives are the acceptable direction of error here: waiving a fee a driver genuinely earned costs us five dollars, while charging a rider for a driver's stall costs a dispute and the rider.

When

A rider farms the grace window

Symptom

One account requests, waits for an accept, and cancels at 110 seconds, four or five times a day. Every cycle is free by the schedule and correct by the schedule, and every cycle costs a driver an accept, a couple of minutes off the market, and nothing earned.

Mitigation

The schedule prices one trip; abuse is a property of a sequence, so the limiter sits above the schedule rather than inside it. A per-rider cancellation rate over a rolling window, and past a threshold that account loses the grace window rather than the app — the next cancellation after an accept is priced from the first second. The driver is paid either way: in-grace cancellations attributed to a rate-limited rider are compensated out of platform funds, because the drive happened whichever side we blame. The symmetry is the point. We built dispatch priority for drivers who cancel, and leaving the cheapest abuse on the board undefended because it happens to be free is not a policy, it is an oversight.

When

A refund after the transfer has already gone out

Symptom

A cancellation fee is waived a day late. The refund comes off our balance in one call and the driver's four dollars sit there untouched, because we charged on our own account and moved the driver's share as a separate Transfer — refunding that charge does not reach it. The money is already on the connected account and already paid out.

Mitigation

This is the bill for having split the two movements, and it has to be paid explicitly: the transfer is undone by its own reversal against that transfer, not by a flag on the refund. That flag belongs to destination charges, where the rail made the split and the rail can unmake it; here we made the split, so we unmake it. Then state the policy rather than discover it. Most reversals land inside the payout window and never touch the driver at all. Below a threshold we absorb it — and absorbing is not a write-off in our own ledger, because a negative connected balance is real: the rail holds a reserve against it and in some markets will try to debit the driver's bank account, so absorbing means transferring the money back in to clear it. Above the threshold it is recovered from the driver's next credits over several days rather than from a payout somebody is relying on this week. A negative connected balance is an alert with a name attached, never a silently failing payout job.

When

A capture that exceeds its authorization

Symptom

A trip estimated at $30 takes a detour and ends at $46. The capture is refused at the rail, after the ride, with the driver's share already owed.

Mitigation

The hold is the estimate x 1.25 with a floor — $37.50 here — and the trip watches actual against it: crossing 80% of the hold triggers an incremental authorization mid-trip, while the rider is still in the car and the card is more likely to say yes. Overcapture does not rescue this one. Our category is allowed roughly twenty percent over on the networks that offer it, which caps this hold at $45.00 against a $46 fare, and that is before interchange-plus pricing, the EEA carve-out and authentication rules take the allowance away. So if the bank refuses the increment, the difference becomes a second intent at completion, and if that declines too it is a debt on the rider's account that the next request checks before dispatch. What we never do is re-run the whole fare as a fresh charge.

When

The same money instruction arrives twice

Symptom

A capture times out, the worker retries, and the rider is charged twice — or a transfer is issued twice and the driver is paid twice for one trip.

Mitigation

Every mutation on the money path carries an idempotency key built from trip id plus operation — capture, fee, transfer, reversal — so the retry returns the original object instead of creating a second one. The ledger enforces the same identity as a unique constraint, because the key is a fast path and the constraint is the guarantee. Retries here are not an edge case: they are the normal behaviour of every timeout in the system.

When

Payment capture fails after the ride happened

Symptom

The trip completed, the driver is owed, and the capture against a hold taken an hour ago comes back declined.

Mitigation

Most of this failure was removed by moving the authorization to request time — a bad card is now a screen before dispatch rather than a debt afterwards. The residue is real: holds expire, banks reverse them, and a capture can still fail. So the ride is never held hostage to the rail. The trip stays completed, the driver's credit is written and paid on schedule out of our own balance, and the shortfall becomes a rider debt with its own retry ladder, checked before the next dispatch. A rider's declined card is not a driver's problem.

When

Onboarding never finishes

Symptom

A driver has completed forty trips, can see the earnings in the app, and cannot withdraw any of it, because a document is missing from Connect onboarding and nobody said which one.

Mitigation

Payouts are KYC-gated, so those earnings accrue to a held balance rather than failing transfer by transfer. The ledger credits the driver normally — what somebody is owed is not contingent on our vendor's verification queue — and the app surfaces the exact missing requirement rather than a generic pending. Past a trip count or a balance threshold, onboarding becomes blocking at dispatch, because letting the balance grow is the worse outcome of the two.

When

A chargeback on a completed ride

Symptom

A rider disputes a fare weeks later. The money was transferred to the driver, paid out, and spent.

Mitigation

The platform eats it by default and the driver is not debited, because a driver cannot see, prevent, or appeal card fraud, and clawing it back teaches every driver that a completed ride is conditional. The evidence bundle assembles itself from what we already keep — trip timeline, map-matched route, pickup and dropoff, receipt — which is the whole argument for the trace archive. Only a pattern moves the loss: the same rider and driver repeatedly, or a trace showing no ride happened, at which point it is a fraud case rather than a dispute.

When

Matching goes down for a whole city

Symptom

Requests pile up in one city's queue, time-to-match goes vertical, and rides in progress carry on completely unaffected.

Mitigation

The blast radius is the point of the sharding: one city's matchers, index, and queue are its own, and neighbouring cities do not notice. Requests hold in the queue with an honest screen rather than being failed, in-flight trips complete because they only need the trip row, and if the outage is long the city sheds new requests deliberately instead of building a backlog it cannot drain. Cancellations during that window are platform fault by definition, and they are free.

When

GPS drift or a spoofed position

Symptom

A driver teleports two kilometres between pings, or appears at the airport queue without having driven there.

Mitigation

The writer rejects positions that imply an impossible speed and holds the last credible fix, so drift becomes a stale dot rather than a bad match. Fares are computed on the map-matched route, not on raw point-to-point distance, which makes noise cost cents instead of dollars. Repeat teleporters are a fraud signal, and the trace archive is what makes that case provable after the fact.

Listening For

What is actually being scored while you talk. None of it is the drawing.

  • 01Whether you separated the location path from the trip path in the first two minutes, or bolted it on when the write rate was pointed out to you.
  • 02Whether you said the number out loud — a quarter of a million writes a second — and then designed as if you believed it.
  • 03Whether nearby is a key lookup on a cell, or a distance query you are hoping an index will save.
  • 04Whether matching is an offer with an expiry or an assignment you then have to undo.
  • 05Whether you can name the exact place strong consistency is required, and defend everything else being eventual.
  • 06Whether you picked a shard key that matches the queries you already drew, and named what it costs when cities are uneven.
  • 07Whether the cancellation fee is sized to the work already destroyed rather than to the trip, and whether you noticed it has to reach the driver.
  • 08Whether you can say why driver cancellations cannot cost cash, and then build the detector that asymmetry forces on you.
  • 09Whether the hold happens before dispatch or after the ride, and whether you can say what bounds the capture afterwards and how much the escape hatches actually cover.
  • 10Whether you noticed that the schedule prices a single trip while abuse is a property of a sequence, and put a limiter on both sides of the market rather than one.
  • 11Whether charges and transfers are separate movements with a ledger between them, rather than one call to a provider and a hope.
  • 12Whether the money path has a ledger and a reconciliation, rather than a call to a payment provider and a shrug.
  • 13Whether every box arrived with a failure mode you volunteered before anyone asked for one.