Surge pricing looks like a multiplication. Base fare times some number, show it to the rider, done. The interviewer lets that sit for about a minute, then asks the questions that break the toy: the number is different on this block than the next one over, it changes every 30 seconds, there are a million of these blocks worldwide all updating at once, a rider who sees 1.8x must be charged 1.8x even if the number moves while they are tapping “confirm,” a driver deciding whether to drive to the airport needs the surge there to still be real when they arrive 12 minutes later, and if you get the number wrong in either direction you either strand riders or enrage them. Surge is not a multiplication. It is a real-time distributed control loop over supply and demand, and the price is just its output.
That control loop is the spine of this design. Everything hard here hangs off it: how you measure demand and supply per tiny geographic zone without a database meltdown, how you aggregate that into a multiplier for a million zones every sub-minute, how you serve the current price as an O(1) read on the hot path, how you keep the number stable enough that it does not flicker and honest enough that it actually moves cars, and how you hold a quote steady for a rider while the underlying signal keeps changing. Let me build it properly.
Functional Requirements (FR)
In scope:
- Compute a surge multiplier per geo zone in real time. Divide the world into small zones (geohash cells / hexes). For each active zone, continuously compute a price multiplier from the live ratio of demand to supply, updated on a sub-minute cadence.
- Serve the current price to riders. When a rider requests an estimate or a trip, return the multiplier for their pickup zone with the base fare, fast, before they confirm.
- Quote consistency. A price shown to a rider is honoured for a short window (say 2 minutes) even if the zone’s surge moves in the meantime. The rider is never charged more than the quote they accepted.
- Ingest demand and supply signals. Consume the stream of ride requests (demand) and the stream of available-driver positions (supply) that the platform already produces, bucketed per zone.
- Smoothing and safety rails. Cap the multiplier (e.g. 1.0x to 5.0x), smooth it so it does not jitter tick to tick, and apply hysteresis so it does not oscillate.
- Driver-facing surge view. Show drivers a heatmap of where surge is high so they can reposition, and guarantee a driver who accepts a surged trip earns that surge for the whole trip.
- Expose surge for downstream systems. Matching, ETA, and forecasting all read the same multiplier map.
Explicitly out of scope (say this out loud so you own the scope):
- The base fare engine. Per-km and per-minute base rates, tolls, and product pricing (X vs XL vs Black) are a separate pricing catalog. Surge multiplies the base; I do not design the base.
- Driver-rider matching and dispatch. The geospatial index, nearest-driver search, and the assignment lock are their own system. I consume “available drivers per zone” as a signal and I emit a multiplier that matching reads; I do not build the matcher.
- Payments, billing, and payouts. The trip ends by handing a final fare (base times surge times distance/time) to a billing service. I do not design the ledger.
- The demand-forecasting ML model. Predictive surge (raising price before a concert lets out) is an ML system on top of this one. I design the reactive control loop and leave a clean seam where a forecast can be blended in.
The one decision that drives everything: surge is a streaming aggregation problem, not a request/response problem. The expensive work - measuring supply and demand across a million zones - happens continuously and asynchronously off the request path. The rider’s request only ever reads a pre-computed number. Get that separation right and the system is tractable; blur it and you are computing supply/demand inside a hot request and it collapses.
Non-Functional Requirements (NFR)
- Scale: ~1M active zones worldwide (geohash precision-6, roughly 1km cells, of which a large fraction are populated at any moment). ~5M drivers streaming positions, ~20M trips/day driving demand signals. Peak demand-signal and supply-signal ingest in the low millions of events/sec.
- Freshness: a zone’s multiplier must reflect conditions no more than ~30-60 seconds old. Surge that lags two minutes behind reality is worse than useless - it prices the last crisis, not the current one.
- Read latency: the price lookup on the rider’s estimate/request path must be p99 under ~10ms. It is a cache read; it competes with a whole request budget of a couple hundred ms.
- Availability: 99.99%+ on the read path. If surge lookup fails, the correct fallback is fail to 1.0x (no surge), never fail the ride. Never block a rider from getting a car because the pricing service is down.
- Consistency: the multiplier itself is eventually consistent - a zone reading a value that is 20 seconds stale is fine. But the quote is strongly consistent per rider: once quoted, that price is locked to that rider for its TTL, and the charged surge must equal the quoted surge. Two reads by the same rider in one flow must not disagree.
- Durability: live multipliers are ephemeral (recomputed every tick), so they need no durability. But every quote issued and every surge value actually charged must be durably logged - they are financial and audit records, and disputes (“why was I charged 2.3x?”) must be answerable.
- Stability: the price must not oscillate. A multiplier that flips 1.5 -> 2.5 -> 1.5 every 30 seconds destroys rider trust and driver planning. Stability is a first-class requirement, not a nicety.
Back-of-the-Envelope Estimation (BoE)
Real numbers with the arithmetic, because they dictate the architecture.
Zones:
World land area ≈ 150M km^2, but rides happen in populated areas.
Geohash precision-6 cell ≈ 1.2km x 0.6km ≈ 0.7 km^2.
Active (populated with drivers or requests) zones at peak ≈ 1M.
Multiplier per zone = ~16 bytes (zone_id + float + version + ts).
Live multiplier map = 1M * ~50 bytes (with keys/overhead) ≈ 50 MB.
50MB. The entire live price map for the planet fits in memory on a single node, trivially replicated. That is the key realization: storing the output is free; computing it continuously is the hard part.
Demand signal (ride requests):
20M trips/day, but requests > trips (estimates, cancels, re-requests).
Assume ~3x request-events per completed trip = 60M demand events/day
= 60,000,000 / 86,400 ≈ 700 events/sec average
Peak (5x commute crunch) ≈ 3,500 demand events/sec.
Supply signal (available-driver positions):
5M drivers pinging every ~4s = 1.25M pings/sec average, ~2.5M peak.
But surge does not need every ping. We only need per-zone available-driver
COUNTS, refreshed every ~30s. We downsample: the location system already
maintains a live per-zone driver set; we read a count snapshot per zone
per tick rather than every raw ping.
Supply snapshot volume = 1M zones / 30s window ≈ 33k zone-counts/sec.
That downsampling is the first real design move: surge consumes an aggregated supply count per zone, not the raw 2.5M/sec location firehose. The location plane already buckets drivers by zone; we read counts, not pings.
Aggregation compute:
1M zones, recomputed every 30s = 1M / 30 ≈ 33k zone-multiplier
computations/sec. Each is a tiny arithmetic op (ratio -> curve -> clamp).
Comfortably a handful of stream-processing tasks partitioned by zone.
Read path (price lookups):
Every estimate + every request hits surge lookup.
~3,500 demand events/sec peak, each doing 1 O(1) GET on the multiplier map.
Plus driver heatmap reads. Call it <10k reads/sec. Trivial for a cache.
Quote storage:
Each issued quote ≈ 200 bytes (quote_id, rider, zone, base, surge, expiry).
Peak ~3,500 quotes/sec * 200 bytes = 700 KB/sec.
With 2-min TTL, live quote set ≈ 3,500 * 120 * 200 bytes ≈ 84 MB. In cache.
Durable audit log of charged quotes: 20M/day * ~300 bytes ≈ 6 GB/day ≈ 2.2 TB/year.
The takeaways: a tiny live output (50MB map) that is cheap to store and serve, a continuous aggregation over demand and supply streams that is the real cost, an explicit downsample of supply from a 2.5M/sec firehose to ~33k zone-counts/sec, and a quote layer that must be strongly consistent per rider and durably logged for audit. The aggregation pipeline and the quote consistency, not storage, are the design drivers.
High-Level Design (HLD)
The architecture is a write-side aggregation pipeline that continuously produces a zone -> multiplier map, and a read-side serving layer that hands that map out on the request path with a strongly-consistent quote wrapper. The two are joined only by the multiplier map, which the pipeline writes and the serving layer reads. Nothing on the request path ever computes a multiplier.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Ride Request │ │ Location Svc │ │ Rider App │
│ Stream │ │ (per-zone │ │ (estimate, │
│ (demand) │ │ driver counts)│ │ request) │
└──────┬───────┘ └──────┬────────┘ └──────┬───────┘
│ demand events │ supply counts │
│ │ │
┌──────▼────────────────────────▼──────┐ │
│ Ingestion (Kafka) │ │
│ topic: demand (key = zone_id) │ │
│ topic: supply (key = zone_id) │ │
└──────┬─────────────────────────────────┘ │
│ consumed, partitioned by zone_id │
┌──────▼─────────────────────────────────┐ │
│ Surge Aggregator (Flink / Kafka │ │
│ Streams), windowed per zone: │ │
│ - rolling demand count (2-min window) │ │
│ - current supply count │ │
│ - ratio -> curve -> smooth -> clamp │ │
│ - hysteresis vs previous value │ │
└──────┬─────────────────────────────────┘ │
│ writes zone -> multiplier │
┌──────▼───────────────┐ │
│ Multiplier Store │ │
│ (Redis, zone->mult, │◀──── GET surge:{zone} ───────────┤
│ TTL, versioned) │ │
└──────┬────────────────┘ ┌───────▼────────┐
│ read │ Pricing Service │
│ │ (quote issue, │
┌──────▼────────┐ ┌──────────────────┐ │ quote honour) │
│ Driver Heatmap │ │ Quote Store │◀──────────┤ │
│ Service │ │ (Redis, quote_id │ └───────┬────────┘
└────────────────┘ │ -> locked price) │ │
└──────────────────┘ ┌──────────▼────────┐
┌──────────────────┐ │ Audit Log │
│ Multiplier Store │ │ (Kafka -> OLAP / │
│ replicas (read) │ │ warehouse) │
└──────────────────┘ └───────────────────┘
Write path (the control loop), runs continuously:
- The Location Service already buckets drivers by zone for matching. Every ~30s it emits a supply snapshot per active zone (count of available drivers) onto a Kafka
supplytopic keyed byzone_id. - Every ride request/estimate emits a demand event onto a Kafka
demandtopic keyed byzone_id. - The Surge Aggregator (Flink or Kafka Streams) consumes both topics partitioned by
zone_id, so all events for one zone land on one task in order. Per zone it keeps a rolling 2-minute demand count and the latest supply count, computesratio = demand / max(supply, 1), maps it through a surge curve, smooths it (EMA), applies hysteresis against the previous value, clamps to [1.0, 5.0], and if the value changed materially writeszone -> multiplierto the Multiplier Store (Redis). - The Multiplier Store is the single source of truth for “the current price everywhere.” It is ~50MB, replicated to read replicas near the serving layer.
Read path (rider request), runs per request:
- Rider asks for an estimate. The Pricing Service computes the pickup zone from
(lat, lng), doesGET surge:{zone}against a Multiplier Store replica (O(1)), multiplies the base fare, and returns the quote. - The Pricing Service issues a quote: it writes
{quote_id, rider_id, zone, base, surge, total, expiry = now+120s}to the Quote Store and returnsquote_idwith the price. From this moment the price is locked for that rider for 120s regardless of what the zone’s live multiplier does. - Rider confirms with
quote_id. Pricing Service loads the quote, verifies it is unexpired, and stamps that surge onto the trip. The charged surge = quoted surge, always. - Every issued and every charged quote is streamed to the Audit Log (Kafka into a warehouse) for disputes and analytics.
The key insight: the price a rider sees is always a pre-computed cache read wrapped in a per-rider lock. The heavy, continuous supply/demand math lives entirely on the write side, isolated from every request. The read side never does more than a GET plus a small quote write.
Component Deep Dive
The hard parts, naive-first then evolved: (1) measuring demand and supply per zone at a million-zone scale, (2) turning the ratio into a stable multiplier, (3) quote consistency under a moving price, (4) tuning for driver income consistency and rider acceptance.
1. Measuring demand and supply per zone
Naive approach: on each rider request, query the database for nearby requests and nearby drivers, and divide. When a rider asks for a price, run SELECT count(*) FROM requests WHERE zone=? AND ts > now()-2min and SELECT count(*) FROM drivers WHERE zone=? AND status='available', compute the ratio, return the multiplier.
Where it breaks:
- Compute on the hot path. Every estimate now runs two aggregate queries. At 3,500 estimates/sec that is 7,000 count queries/sec against tables that are themselves being written millions of times/sec. The pricing read is supposed to be a 10ms cache hit; this makes it a multi-hundred-ms scan.
- Redundant work. A hundred riders in the same zone in the same minute each recompute the identical zone ratio. You are doing the same aggregation hundreds of times instead of once.
- The supply query hits the location firehose. Counting available drivers per zone means querying the location dataset that is being rewritten 2.5M times/sec. That table cannot also serve aggregate reads.
First evolution: cache the computed ratio per zone. Compute the ratio once, cache zone -> multiplier with a 30s TTL, serve from cache. This kills the redundant work and the hot-path compute. But who computes it, and when? If it is computed lazily on cache-miss, the first rider in a zone after each TTL expiry eats the slow path, and a zone with no current riders never updates its supply picture (so a driver repositioning there sees stale surge). Lazy recomputation ties the freshness of the price to the arrival of riders, which is backwards - you most need fresh surge in zones that are changing, not zones that happen to be queried.
The answer: a continuous windowed stream aggregation, partitioned by zone, that pushes multipliers into the cache proactively.
- Two input streams, both keyed by
zone_id. Demand events (ride requests) and supply snapshots (per-zone available-driver counts, emitted every 30s by the location system, not raw pings). Keying byzone_idguarantees all of a zone’s events reach the same aggregation task in order. - A stream processor (Flink / Kafka Streams) holds per-zone state. For each zone it maintains a sliding 2-minute demand count (a windowed aggregation) and the latest supply count. When either updates, it recomputes the multiplier and emits it.
- Partition the million zones across tasks by
zone_idhash. 1M zones recomputing every 30s is ~33k computations/sec, trivially spread across a few dozen parallel tasks. Each task owns a disjoint set of zones and its own local state - no cross-task coordination. - Emit only on material change. If the recomputed multiplier is within a small epsilon of the last written value, skip the write. Most zones are 1.0x and stay 1.0x; only the churning zones generate writes. This collapses 1M potential writes/tick down to the few thousand zones actually moving.
demand stream (key=zone) ─┐
├─> Flink keyed by zone_id:
supply stream (key=zone) ─┘ state[zone] = { demand_window, supply, last_mult }
on event:
d = demand_window.count(last 2 min)
s = max(supply, 1)
m = curve(d / s) # section 2
m = smooth_and_clamp(m, state[zone].last_mult)
if abs(m - last_mult) > EPS:
emit(zone, m); last_mult = m
--> Redis: SET surge:{zone} m (TTL 90s)
The 90s TTL on the cache entry is a safety net: if the aggregator dies and stops writing, entries expire and the read path falls back to 1.0x rather than serving a frozen stale surge forever. Failing open to no-surge is the correct degradation - never trap riders in a price the system can no longer justify.
Supply downsampling is the load-bearing decision. Surge does not need to know a driver moved 40 meters; it needs per-zone counts every 30s. By having the location plane emit counts rather than forwarding raw pings, we turn a 2.5M/sec firehose into a 33k/sec count stream before it ever reaches the aggregator.
2. Turning the ratio into a stable multiplier
Naive approach: multiplier = demand / supply, directly. Ten requests, four drivers, surge = 2.5x. Simple.
Where it breaks:
- Jitter. Counts are noisy. A zone hovering around 8 requests and 4 drivers will read 2.0, then a driver goes off-shift and it is 8/3 = 2.67, then two requests expire and it is 6/3 = 2.0. The price flickers every tick. Riders see the number jump while deciding; drivers cannot plan.
- Oscillation (the control-loop instability). Surge is a feedback loop: high surge attracts drivers and repels riders, which lowers the ratio, which drops surge, which sends drivers away and brings riders back, which raises it again. A naive ratio with no damping causes the oscillation it is meant to smooth - classic control-system ringing.
- Absurd extremes. A zone with 1 available driver and 50 requests reads 50x. Nobody should ever see 50x; it is both unusable and a PR disaster.
- Divide-by-zero and tiny-denominator noise. Zero drivers gives infinity; one driver makes the ratio wildly sensitive.
The fix: a shaped, smoothed, clamped, hysteretic surge curve - treat it as a damped controller, not a raw ratio.
- A tunable curve, not a raw ratio. Map the demand/supply ratio through a monotonic curve that is flat (1.0x) below a threshold and rises sublinearly past it. Below ratio 1.0 (more drivers than requests) surge is always 1.0x. Above it,
surge = 1 + k * (ratio - 1)^awitha < 1so it rises but decelerates. This decouples “how the number feels” from the raw arithmetic and lets pricing tune aggressiveness per city.
ratio (demand/supply) surge (example curve)
<= 1.0 1.0x
1.5 1.3x
2.0 1.8x
3.0 2.5x
5.0 3.5x
>= 8.0 5.0x (clamp ceiling)
-
Exponential moving average (EMA) for smoothing. Instead of writing the raw curve output, blend it with the previous value:
m_new = alpha * m_raw + (1 - alpha) * m_prev, with alpha around 0.3. This is a low-pass filter: it damps single-tick spikes and makes the price move gradually. A momentary blip does not move the shown price; a sustained shift does. -
Hysteresis (asymmetric raise/lower). Raise the multiplier readily but lower it slowly, and require the change to exceed a deadband before writing. Concretely: raise when the smoothed value exceeds the current by more than +0.1, but only lower when it falls below by more than -0.2, and hold for a minimum dwell time before dropping. This asymmetry is deliberate - dropping surge too fast pulls drivers out of a still-hot zone and re-triggers the shortage. Hysteresis is what breaks the oscillation.
-
Hard clamp.
clamp(m, 1.0, 5.0). Non-negotiable ceiling so no rider ever sees a runaway price, and it caps the feedback gain that would otherwise drive oscillation. -
Denominator flooring.
supply = max(raw_supply, 1)and, better, require a minimum sample size before surging at all: a zone with 2 requests and 0 drivers should not scream 5x on that little evidence. Blend toward 1.0x when the total signal is thin.
compute_surge(demand, supply, prev):
if demand + supply < MIN_SAMPLE: return blend(prev, 1.0) # too little signal
ratio = demand / max(supply, 1)
raw = 1.0 if ratio <= 1 else 1 + K * (ratio - 1) ** A
ema = ALPHA * raw + (1 - ALPHA) * prev # smoothing
if ema > prev + RAISE_BAND: target = ema # raise readily
elif ema < prev - LOWER_BAND and dwell_ok(): target = ema # lower slowly
else: target = prev # deadband: hold
return clamp(target, 1.0, 5.0)
The mental model: surge is a controller with a setpoint of “supply meets demand.” The curve is the gain, the EMA is the low-pass filter, hysteresis is the damping, and the clamp bounds the actuator. Skip any of them and the loop rings.
3. Quote consistency under a moving price
Naive approach: read the live multiplier at request time. Rider gets an estimate (reads 1.8x), taps confirm a few seconds later, the service reads the multiplier again (now 2.1x) and charges that.
Where it breaks:
- Bait and switch. The rider agreed to 1.8x and was charged 2.1x. Even though the number is “correct,” the rider experience is a betrayal, and in many jurisdictions it is illegal to charge more than the quoted price.
- The number moves mid-flow. Between estimate and confirm, the aggregator ticks. Two reads seconds apart legitimately disagree. Reading live at each step guarantees inconsistency within a single rider’s session.
- No audit trail. When a rider disputes “I was told 1.8x,” there is no record of what was actually shown. You cannot answer the dispute.
The fix: issue an immutable quote at estimate time, honour it for a TTL, and log both the quote and the charge.
- Quote issuance. When the rider gets an estimate, the Pricing Service snapshots the current multiplier into a quote:
{quote_id, rider_id, zone, base_fare, surge, total, issued_at, expiry = issued_at + 120s}, writes it to the Quote Store (Redis, TTL 120s), and returnsquote_idalongside the price. The multiplier is now frozen for this rider independent of the live map. - Quote honour. On confirm, the rider sends
quote_id. The service loads the quote, checksnow < expiry, and stampsquote.surgeonto the trip. Charged surge equals quoted surge, guaranteed, because the trip copies the frozen value, never re-reads the live map. - Expiry and re-quote. If the rider dawdles past 120s, the quote expires; on confirm the service returns “price updated, please review” with a fresh quote. This bounds how long the platform is exposed to a stale-low price during a fast-moving surge, while still giving the rider a fair, stable window.
- Strong consistency, cheaply. The quote is written before it is shown and read by
quote_id- a single-key operation on a replicated store. Two reads in one flow both go throughquote_id, so they cannot disagree. This is the only strongly-consistent part of the price path, and it is a trivial key-value read. - Durable audit. Every quote issued and every quote charged is streamed to the Audit Log (Kafka -> warehouse). Disputes are answerable (“you were quoted 1.8x at 18:42, charged 1.8x”), and the log feeds analytics on quote-to-confirm conversion by surge level.
POST /estimate -> compute zone, GET surge:{zone} = 1.8
-> write quote:{q_9} = {surge:1.8, expiry:+120s}, audit(issued)
-> return { quote_id: q_9, surge: 1.8, total: 342 }
POST /request { quote_id: q_9 }
-> load quote:{q_9}; assert now < expiry
-> trip.surge = 1.8 (copied, not re-read)
-> audit(charged, q_9, 1.8)
The principle: the live multiplier is eventually consistent and shared; the quote is strongly consistent and per-rider. You never make the whole price map strongly consistent - you wrap the one number a given rider cares about in a cheap per-rider lock with a TTL.
4. Driver income consistency and rider acceptance
This is the “hard part” the problem foregrounds, and it is where a naive surge system quietly fails even when the math is right.
Naive approach: the driver earns whatever surge is live when the trip completes. Driver accepts a ride into a 2.5x zone; by the time they finish, surge dropped to 1.2x, and that is what they are paid.
Where it breaks:
- Driver income whiplash. A driver drove across town because it was 2.5x, and got paid 1.2x because the surge they were chasing collapsed by the time they arrived. Drivers learn the surge heatmap lies, stop trusting it, stop repositioning, and the whole supply-steering mechanism dies. Surge only works if drivers believe it.
- The self-defeating heatmap. If showing drivers “airport is 3x” makes 200 drivers rush the airport, supply spikes, and surge craters before any of them arrive. The signal destroys itself. Naive real-time surge is unstable precisely because acting on it changes it.
- Rider acceptance collapse at high multipliers. If surge overshoots (say to 4x on thin signal), riders decline en masse, demand evaporates, and the zone was mispriced - you rationed away real demand that would have paid 2x. The multiplier is not just revenue; it is the knob that sets acceptance rate, and overshoot is as costly as undershoot.
The fixes, each targeting one failure:
-
Lock the driver’s surge at acceptance, for the trip. When a driver accepts a trip, snapshot the pickup-zone surge onto the trip (the same value the rider was quoted). The driver is paid that surge regardless of how the zone moves during the ride. This is the driver-side analogue of the rider quote: surge is fixed for both parties at the moment of agreement. It restores driver trust in the heatmap - what you see when you accept is what you earn.
-
Damp the heatmap to account for its own feedback. The driver-facing surge view should be smoothed harder and slightly lagged relative to the raw multiplier, and ideally blended with a short forecast, so that it does not swing drivers into a zone that will be oversupplied by the time they arrive. Some designs show a forecasted surge (where it will be when you get there) rather than the instantaneous value. The seam left for the forecasting model lives here.
-
Hysteresis protects driver income directly. The slow-to-lower rule from section 2 is not only about visual stability - it keeps the surge alive long enough for the drivers it attracted to actually capture it. Dropping surge the instant supply improves would pay the responding drivers nothing; holding it briefly rewards the response that fixed the shortage.
-
Tune the curve against acceptance-rate telemetry, not just the ratio. The audit log records, per surge level, the quote-to-confirm conversion (rider acceptance). If acceptance falls off a cliff above 3x in a city, the curve is too aggressive there and is rationing away payable demand. Pricing tunes
KandAper market from this feedback so the multiplier lands where it clears the market - high enough to pull supply, low enough that riders still accept. The right multiplier is the one that maximizes completed trips, not the one that maximizes the ratio. -
Cap the rate of change. Beyond hysteresis on direction, cap how fast the multiplier can move per minute (e.g. no more than +0.5/min). A zone cannot leap 1.0x -> 4.0x in one tick even if a stadium empties; it ramps. This bounds both rider shock and driver whiplash.
The synthesis: surge is a two-sided commitment device. Freeze the price for the rider at quote and for the driver at acceptance; smooth and lag the public heatmap so acting on it does not immediately falsify it; and tune the curve to the real objective (completed trips / market clearing), measured from acceptance telemetry, rather than to the raw supply/demand number.
API Design & Data Schema
The read path is REST plus a driver heatmap feed; the write path is streams.
REST (rider-facing)
POST /api/v1/pricing/estimate
body: { pickup:{lat,lng}, dropoff:{lat,lng}, product:"go" }
-> 200 { quote_id:"q_9", zone:"tdr1x8", base_fare:190,
surge:1.8, total:342, expires_in_s:120 }
POST /api/v1/rides/request
body: { quote_id:"q_9", pickup:{...}, dropoff:{...} }
-> 202 { trip_id:"t_51", surge:1.8, status:"matching" }
# 409 { error:"quote_expired", new_quote:{...} } if past TTL
GET /api/v1/pricing/zone?lat=..&lng=..
-> 200 { zone:"tdr1x8", surge:1.8, updated_at:1752... } # raw, unquoted
Driver-facing
GET /api/v1/drivers/surge-map?lat=..&lng=..&radius_km=10
-> 200 { cells:[ {zone:"tdr1x8", surge:2.1, forecast_10m:1.9},
{zone:"tdr1x9", surge:1.4, forecast_10m:1.6} ] }
# damped + forecasted values, not the raw instantaneous multiplier
// on trip accept, server stamps locked surge (driver is paid this)
{ "type":"TRIP_ASSIGNED", "trip_id":"t_51", "locked_surge":1.8 }
Internal (aggregator -> store, and signal ingest)
// demand event (emitted per ride request)
{ "type":"DEMAND", "zone":"tdr1x8", "ts":1752... }
// supply snapshot (emitted per zone every ~30s by location service)
{ "type":"SUPPLY", "zone":"tdr1x8", "available":4, "ts":1752... }
Data stores - the right tool per plane
1. Multiplier Store - Redis (in-memory KV), NOT a database. The live zone -> multiplier map. ~50MB, so it fits on one node and is cheaply replicated to read replicas near the serving layer. Short TTL so a stalled aggregator fails open to no-surge. This is deliberately non-durable, in-memory storage because the value is recomputed every tick and durability would be wasted.
Redis:
SET surge:{zone} <multiplier> EX 90 # written by aggregator
GET surge:{zone} # O(1) read on price path
# optional: HSET surge_meta:{zone} version .. updated_at .. for staleness checks
2. Quote Store - Redis with TTL, strongly-consistent per key. Frozen quotes keyed by quote_id, TTL 120s. Single-key reads/writes give per-rider consistency without any distributed transaction.
Redis:
SET quote:{quote_id} {rider,zone,base,surge,total,expiry} EX 120
GET quote:{quote_id} # on confirm; assert unexpired
3. Aggregator state - the stream processor’s local state store (RocksDB-backed in Flink), checkpointed. Per-zone rolling demand window and last multiplier. Checkpointed to durable storage so a failed task recovers its window state rather than cold-starting every zone to 1.0x. This is operational state, not a queryable store.
per zone: { demand_events: sliding_window(2min),
supply: int, last_mult: float, last_change_ts: ts }
Partitioned by hash(zone_id); checkpointed to S3/HDFS.
4. Audit Log - append-only stream into an OLAP warehouse. Every issued and charged quote. Durable, immutable, queried for disputes and for curve-tuning analytics (acceptance rate by surge level). This is the one plane that must never lose a record.
Kafka topic: pricing_audit -> warehouse (BigQuery / Snowflake / Iceberg)
{ quote_id, rider_id, driver_id, zone, base, surge, event:issued|charged,
accepted:bool, ts }
Partitioned by day; queried by zone/time/surge for BI and dispute resolution.
Why in-memory KV for the live map and streams for the pipeline, but a durable warehouse for audit: the planes have opposite needs. The live multiplier is ephemeral, tiny, and read hot - a durable relational store would add latency and durability we throw away every 30s. The aggregation is unbounded, continuous, and per-key stateful - a request/response database is the wrong shape; a stream processor with windowed keyed state is exactly right. The audit trail is financial and legal - it must be durable, immutable, and analytically queryable, so it goes to an append-only warehouse. There is no single “surge database”; each plane gets the store that matches its lifetime and access pattern.
Bottlenecks & Scaling
Where it breaks first, in order, and the fix for each:
1. Recomputing surge on the request path (breaks first). Computing demand/supply inside the rider’s estimate turns a 10ms cache read into a multi-hundred-ms scan and does the same work hundreds of times per zone.
Fix: move all computation to the asynchronous streaming aggregator; the request path only ever does GET surge:{zone}. This is the single most important separation in the design.
2. The supply firehose. Feeding 2.5M raw location pings/sec into the aggregator would drown it. Fix: downsample supply to per-zone counts emitted every ~30s by the location system. Surge needs counts, not pings; this cuts the input from 2.5M/sec to ~33k zone-counts/sec before it reaches the aggregator.
3. Aggregator throughput and hot partitions. One task cannot hold a million zones’ state or absorb the demand stream, and a viral zone (stadium, airport) concentrates events onto one partition.
Fix: partition the aggregator by zone_id hash across many parallel tasks - 33k computations/sec spreads trivially. For a genuinely hot single zone, the work is still bounded (one zone’s counts), but if one zone’s demand event rate is extreme, sub-partition it by splitting the zone into finer geohash children and aggregating up. Emit-only-on-material-change keeps write volume proportional to churning zones, not total zones.
4. Multiplier Store read load and staleness. All price reads hit the store, and a stalled aggregator could serve frozen surge. Fix: the map is ~50MB - replicate it to read replicas co-located with the serving layer so reads are local and cheap; and put a short TTL on entries so a stalled writer degrades to no-surge (fail open) rather than serving stale-high prices. Add a local in-process cache with a 1-2s TTL on the Pricing Service to absorb repeated reads of the same hot zone.
5. Oscillation and jitter (the stability bottleneck). A raw ratio makes the price flicker and, worse, the surge feedback loop rings. Fix: treat it as a damped controller - shaped curve (bounded gain), EMA smoothing (low-pass), hysteresis (asymmetric raise/lower with a deadband and dwell time), a per-minute rate cap, and a hard clamp. Stability is engineered in, not hoped for.
6. Quote consistency under a moving price. Re-reading the live multiplier between estimate and confirm charges a different price than shown.
Fix: issue an immutable quote frozen at estimate time, keyed by quote_id in a TTL store, honoured for 120s. Charged surge is copied from the quote, never re-read. Single-key operation, so it is strongly consistent per rider at negligible cost.
7. Driver income whiplash and heatmap self-defeat. Live surge that collapses before the attracted drivers arrive pays them nothing and kills trust; the heatmap that steers drivers falsifies itself. Fix: lock surge at driver acceptance for the whole trip (driver-side quote), damp and forecast the public heatmap so acting on it does not immediately invalidate it, and let hysteresis hold surge alive long enough for responders to capture it.
8. Mispricing / acceptance collapse. Overshoot rations away payable demand; undershoot fails to pull supply. Fix: tune the curve from acceptance telemetry in the audit log (conversion by surge level per market), optimizing for completed trips / market clearing rather than the raw ratio. Minimum-sample flooring prevents surging on thin evidence.
9. Single points of failure. The aggregator, the store, the pricing service. Fix: the aggregator is a checkpointed stream job (recovers window state on failover); the Multiplier and Quote stores are replicated Redis with failover; the Pricing Service is stateless behind a load balancer. If the whole pricing plane is unreachable, the read path fails open to 1.0x so rides continue - surge is a revenue optimization, never a gate on service.
10. Multi-region / global. Fix: deploy the pipeline per region and key zones by geography so a region only aggregates its own zones and a rider always reads a local replica - the latency-critical loop never crosses a region boundary. The audit log replicates asynchronously to a global warehouse. Since surge is intrinsically local (a zone’s price depends only on that zone plus immediate neighbours), no cross-region coordination is needed.
11. Neighbour smoothing / spatial coherence. Adjacent zones can end up with jarringly different multipliers (2.5x here, 1.0x one block over), which feels arbitrary and pushes riders to walk a block to game it. Fix: apply light spatial smoothing - blend each zone’s multiplier slightly with its neighbours’ (a cheap post-step in the aggregator since it already partitions spatially) so surge forms smooth gradients rather than sharp cliffs. This also damps single-zone noise.
Wrap-Up
The trade-offs that define this design:
- Streaming aggregation over request-time computation. The expensive supply/demand math runs continuously and asynchronously on the write side; the read path is only ever a cache GET. This is the separation that makes a million zones at sub-minute freshness tractable - blur it and you are scanning the location firehose inside a rider’s request.
- A damped controller over a raw ratio. Surge is a feedback loop that rings if left undamped, so the multiplier is a shaped curve plus EMA smoothing plus hysteresis plus a rate cap plus a clamp. We trade responsiveness for stability deliberately, because a flickering, oscillating price destroys trust on both sides.
- Eventual consistency for the map, strong consistency for the quote. The live
zone -> multipliermap is shared and eventually consistent; the one number a given rider or driver cares about is frozen at agreement time in a cheap per-key TTL lock. We pay for strong consistency exactly once per party, never across the map. - Two-sided commitment for income and acceptance. Price is locked for the rider at quote and for the driver at acceptance, the public heatmap is damped and forecast so acting on it does not falsify it, and the curve is tuned from acceptance telemetry toward completed trips. Surge only works if both sides believe it, so believability is engineered in.
- Fail open, always. Every failure mode in the pricing plane degrades to 1.0x, never to a failed ride. Surge is a revenue optimization layered on top of the ride service; it is never allowed to become a gate on it.
One-line summary: a surge pricing system where demand and supply signals stream through a per-zone, partitioned windowed aggregator that produces a damped, hysteretic multiplier for a million zones into a tiny in-memory map, the rider request path reads that map as an O(1) cache hit wrapped in an immutable per-rider quote, the driver’s surge is locked at acceptance so income matches the heatmap, and every quote is durably audited - with the whole plane failing open to no-surge so pricing never blocks a ride.
Comments