1 Building Uber’s Dynamic Pricing Engine in .NET: Supply-Demand Algorithms, Geospatial Indexing, and Real-Time Market Simulation
Uber’s pricing engine is one of the most fascinating real-time systems ever deployed at scale. It balances millions of drivers and riders across constantly changing geographies, optimizing prices every few seconds to match market conditions. In this article, we’ll reconstruct that core system — not the entire Uber stack, but the essential architectural, algorithmic, and engineering ideas that power dynamic pricing — using modern .NET 8/9 technologies.
We’ll move from concept to implementation: building event-driven microservices, handling live geospatial data, calculating real-time supply-demand ratios, and integrating predictive models with ML.NET. The focus is on production-grade architecture — code that can run at scale, not just a toy simulation.
1.1 The Business Imperative: Beyond “Surge”
Most people think “surge pricing” is about profit. It’s not — it’s about market equilibrium. When too many riders request rides and too few drivers are available, prices must rise to restore balance. The system isn’t punishing demand; it’s broadcasting scarcity. The goal is threefold:
- Rider Availability: Ensure users can still find rides when demand spikes.
- Driver Retention: Encourage drivers to stay online and move toward high-demand areas.
- Platform Trust: Keep the market transparent and fair, avoiding extreme volatility.
At Uber-scale, a small pricing mistake can ripple across continents. Undershoot, and riders wait 30 minutes for a car. Overshoot, and drivers idle because passengers churn. Dynamic pricing isn’t about greed — it’s about keeping both sides of a two-sided marketplace functional under chaos.
The system must:
- Detect imbalance in real time (supply vs. demand).
- Adjust prices fast enough to influence driver and rider behavior.
- Avoid oscillations (price flickering) and maintain geographic fairness.
Getting that right requires combining real-time analytics, predictive modeling, and low-latency distributed systems — all in milliseconds.
1.2 The Core Technical Challenge
Dynamic pricing is a “hard” problem because it combines several difficult domains:
Scale
Uber processes millions of (lat, long) pings per second. Every driver and rider contributes to a live global dataset. Handling this volume requires horizontally scalable ingestion pipelines — typically built on Kafka or Event Hubs — and low-latency stream processors.
Real-Time Decisioning
Pricing must react within seconds. A delay of 60 seconds could make a price irrelevant — demand might already have shifted elsewhere. That means caching and aggregation layers (like Redis) must operate on sub-second latency.
Geospatial Complexity
Two riders standing 200 meters apart might belong to different pricing zones. The system must continuously map coordinates to geospatial indices that can be aggregated and compared — efficiently and fairly.
Predictive Modeling
Simple reactive algorithms (like Demand / Supply) aren’t enough. The engine must forecast where demand will be, not where it was. This involves lightweight ML models running in real time — often trained offline but deployed as embedded prediction functions in the pricing microservice.
Regulatory & Fairness Constraints
Rules differ by city, event, or emergency. The pricing engine must integrate a rules layer to cap surges, enforce fairness zones, or disable dynamic pricing entirely during emergencies — all while maintaining audit logs for regulators.
The outcome: a distributed system that acts like a single mind — observing, predicting, and adjusting in near real time.
That’s what makes this one of the toughest engineering problems in production computing.
1.3 Why .NET for This Fight?
Historically, systems like this were built in Java, Go, or Python. But modern .NET 8/9 has evolved into a top-tier platform for distributed, high-performance workloads. Here’s why it’s a great choice for a real-time market engine:
Performance & Low Latency
.NET 8’s Kestrel server and gRPC stack deliver sub-millisecond response times. Combined with Native AOT compilation, .NET services can achieve cold-start times below 50 ms — ideal for microservices scaling on demand in Kubernetes.
Strong Async Model
Async/await and channels in .NET provide an elegant way to handle concurrent event streams. For example, System.Threading.Channels can be used to build ingestion pipelines rivaling Java’s reactive streams.
ML.NET Integration
With ML.NET, models for demand forecasting or elasticity estimation can be embedded directly in the service — no external Python bridge required. This allows true real-time predictions per request, without costly API calls to external ML services.
Unified Ecosystem
From high-performance APIs (ASP.NET Core) to caching (StackExchange.Redis), observability (OpenTelemetry), and streaming (Confluent.Kafka), the .NET ecosystem has matured into a cohesive stack for cloud-native, event-driven systems.
Deployment & Cost Efficiency
.NET’s Native AOT binaries are compact and start instantly — perfect for serverless or containerized environments where horizontal scaling is frequent. The reduced CPU footprint also translates into significant cloud cost savings.
In short, .NET 8/9 brings the performance of Go, the safety of Rust, and the developer productivity of C# — a compelling combination for large-scale dynamic systems.
1.4 The Architectural North Star
Before diving into implementation, let’s align on what we’re building. Here’s the high-level architecture (a C4-level view):
- Driver & Rider Apps → send location pings via Kafka.
- LocatorService → normalizes and publishes structured events.
- GeospatialService → maps coordinates to hex grids (H3 indexes).
- AggregationService → counts supply/demand in each grid in real time.
- PricingEngine → calculates dynamic surge multipliers.
- SimulationService → runs “digital twin” simulations to test algorithms.
All components communicate via Kafka (for event flow) and gRPC (for internal requests). Redis serves as the shared real-time cache for stateful metrics.
Conceptually:
[Driver/Rider Apps]
↓
Kafka Topic
↓
[LocatorService] → (lat,long)
↓
[GeospatialService] → (HexID)
↓
[AggregationService] → (Supply/Demand per Hex)
↓
[PricingEngine] → Surge Multiplier
↓
gRPC/API Gateway
The key architectural principles are:
- Event-driven: React to data changes, not batch jobs.
- Stateless services: Compute in motion, not at rest.
- Geospatial partitioning: Operate per hex region.
- Observability-first: Every decision is logged and traceable.
This architecture scales linearly with the number of active users — a necessity for global systems.
2 The System Blueprint: An Event-Driven Architecture
Dynamic pricing is fundamentally reactive: prices change when the environment changes. That makes an event-driven architecture the most natural fit. Instead of polling databases, services listen to streams — location updates, ride requests, and trip completions — and react accordingly.
Let’s walk through the service ecosystem and the technologies that bind it together.
2.1 The Service Ecosystem
Each service has a single, well-defined responsibility. Together, they form a loosely coupled system.
2.1.1 LocatorService: Ingesting Location Pings
The LocatorService consumes raw driver and rider telemetry — millions of (lat, long, timestamp) events per second. It standardizes these into structured Kafka messages.
Example:
public class LocationEvent
{
public string UserId { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
public bool IsDriver { get; set; }
public DateTime Timestamp { get; set; }
}
public class LocatorService : BackgroundService
{
private readonly IProducer<string, LocationEvent> _producer;
public LocatorService(ProducerConfig config)
{
_producer = new ProducerBuilder<string, LocationEvent>(config)
.SetValueSerializer(new JsonSerializer<LocationEvent>())
.Build();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var evt = await GetLocationPingAsync();
await _producer.ProduceAsync("location-events", new Message<string, LocationEvent>
{
Key = evt.UserId,
Value = evt
});
}
}
}
The output stream (location-events) feeds downstream consumers — the GeospatialService and AggregationService.
2.1.2 GeospatialService: The Map Brain
This service transforms raw coordinates into H3 hex indexes, effectively partitioning the earth into uniform, hierarchical zones. It ensures that both supply and demand are counted in consistent spatial buckets.
It also handles neighbor smoothing, merging small adjacent zones when traffic is sparse.
2.1.3 AggregationService: Real-Time Counting
The AggregationService subscribes to the geospatially tagged stream and updates supply/demand counts for each hex in Redis. It uses atomic counters (INCRBY) to maintain accurate tallies under high concurrency.
Redis keys might look like:
CURRENT_SUPPLY:<HexID>
CURRENT_DEMAND:<HexID>
This service runs as a high-performance background processor using channels or TPL Dataflow for throughput.
2.1.4 PricingEngine: The Surge Brain
This is the heart of the system — a gRPC microservice that:
- Reads real-time state from Redis.
- Applies a pricing model (ratio-based, smoothed, or predictive).
- Returns a surge multiplier.
It’s stateless, deterministic, and auditable — meaning every pricing decision can be reproduced from logged inputs.
2.1.5 SimulationService: The Digital Twin
Before deploying new pricing strategies to production, the SimulationService models a virtual market. It simulates drivers and riders as “agents” that react to price changes, using frameworks like Microsoft Orleans or Akka.NET.
This lets engineers test elasticity, fairness, and edge cases in a sandboxed environment.
2.2 The Data Backbone: Kafka and gRPC
At the core of this architecture are two communication channels — Kafka for event flow and gRPC for synchronous interactions.
2.2.1 Apache Kafka (or Azure Event Hubs)
Kafka acts as the system’s nervous system — handling billions of events per day without backpressure. It allows microservices to remain decoupled: each can independently consume, replay, or transform streams.
We’ll use Confluent.Kafka — the high-performance .NET client.
Producer example:
var config = new ProducerConfig { BootstrapServers = "localhost:9092" };
using var producer = new ProducerBuilder<string, string>(config).Build();
await producer.ProduceAsync("location-events", new Message<string, string>
{
Key = "driver-123",
Value = JsonSerializer.Serialize(new { Lat = 40.7128, Lng = -74.0060 })
});
Consumer example:
var config = new ConsumerConfig
{
GroupId = "geo-consumers",
BootstrapServers = "localhost:9092",
AutoOffsetReset = AutoOffsetReset.Earliest
};
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe("location-events");
while (true)
{
var msg = consumer.Consume();
var evt = JsonSerializer.Deserialize<LocationEvent>(msg.Message.Value);
// process event
}
Kafka’s partitioning model ensures horizontal scalability across hex regions or cities.
2.2.2 gRPC (Protobuf)
For synchronous calls — e.g., the PricingEngine asking the AggregationService for the current demand of a hex — we use gRPC. It’s faster than REST and natively supported in .NET.
Example Proto definition:
syntax = "proto3";
service PricingEngine {
rpc GetSurgeMultiplier (PricingRequest) returns (PricingResponse);
}
message PricingRequest {
string hex_id = 1;
string time_of_day = 2;
}
message PricingResponse {
double multiplier = 1;
string model_used = 2;
}
C# server implementation:
public class PricingEngineService : PricingEngine.PricingEngineBase
{
private readonly IRedisClient _redis;
public override async Task<PricingResponse> GetSurgeMultiplier(
PricingRequest request, ServerCallContext context)
{
var supply = await _redis.GetAsync<int>($"CURRENT_SUPPLY:{request.HexId}");
var demand = await _redis.GetAsync<int>($"CURRENT_DEMAND:{request.HexId}");
var ratio = (double)demand / Math.Max(supply, 1);
var multiplier = Math.Min(1.0 + (ratio - 1.0) * 0.5, 3.5); // capped surge
return new PricingResponse { Multiplier = multiplier, ModelUsed = "Reactive" };
}
}
This simple version illustrates synchronous pricing requests running over gRPC in milliseconds.
2.3 The Caching & State Layer: Redis
Redis plays a critical role as the real-time data fabric. It stores transient state — supply, demand, and precomputed aggregates — at extremely low latency (<1 ms per operation).
Common key structure:
CURRENT_SUPPLY:<HexID> = 42
CURRENT_DEMAND:<HexID> = 87
To ensure atomicity during updates:
await using var redis = await ConnectionMultiplexer.ConnectAsync("localhost");
var db = redis.GetDatabase();
var tran = db.CreateTransaction();
tran.AddCondition(Condition.KeyExists("CURRENT_SUPPLY:hex123"));
tran.StringIncrementAsync("CURRENT_SUPPLY:hex123");
tran.StringIncrementAsync("CURRENT_DEMAND:hex123");
await tran.ExecuteAsync();
Redis also supports pub/sub for event propagation — allowing the PricingEngine to react instantly to supply/demand updates without polling.
3 Geospatial Indexing: Partitioning the World with .NET
A dynamic pricing system can’t reason about millions of unique coordinates. It needs a spatial abstraction — a way to bucket the world into zones small enough to capture local demand, yet large enough to be computationally manageable.
3.1 Why Grids?
Imagine trying to count drivers and riders individually for every coordinate in New York City — impossible at scale. Instead, we divide the earth into geospatial zones, each identified by a key.
This reduces complexity:
- Instead of 10 million GPS points, we manage ~20,000 zones.
- Aggregations (like
supply/demand) become O(1) per zone. - Visualizations and simulations become feasible.
The key is finding the right partitioning strategy.
3.2 Geohash vs. Hexagonal Grids
Geohash
Geohash encodes (lat, long) into alphanumeric strings like "dr5regw3". It’s simple and supported by many databases, but has limitations:
- Square cells distort near the poles.
- Adjacent hashes are not evenly spaced.
- Distances vary significantly by latitude.
Hexagonal Grids (H3)
Uber’s H3 library solves these issues. It divides the world into hexagons of roughly equal area. Each hex has:
- Uniform adjacency (6 neighbors).
- Natural hierarchy (zoom levels).
- Minimal distortion across latitudes.
This makes it perfect for spatial aggregation and neighbor smoothing in pricing algorithms.
3.3 Practical Implementation: The H3.NET Library
We’ll use H3.NET, a .NET binding for Uber’s open-source H3 library.
3.3.1 Converting (lat, long) to H3 Index
Install via NuGet:
dotnet add package H3.NET
Example code:
using H3;
public static class GeoHelper
{
public static ulong GetHex(double lat, double lng, int resolution = 8)
{
return H3Lib.GeoToH3(lat, lng, resolution);
}
}
// Usage
var hexId = GeoHelper.GetHex(37.775, -122.418, 8);
Console.WriteLine($"HexID: {hexId}");
At resolution 8, each hex covers roughly 0.74 km² — ideal for urban demand segmentation.
3.3.2 Finding a Hex’s Neighbors (k-ring)
Neighbor smoothing helps prevent “spiky” prices between adjacent hexes. Using H3Lib, we can find neighbors efficiently:
var neighbors = H3Lib.KRing(hexId, 1); // 1-ring neighbors
foreach (var n in neighbors)
Console.WriteLine($"Neighbor: {n}");
We can then average supply/demand across a hex and its neighbors for smoother pricing transitions.
3.3.3 Storing and Querying H3 Indexes
If we persist location data in PostgreSQL, the postgres-h3 extension adds native H3 functions.
Example schema:
CREATE TABLE driver_locations (
driver_id TEXT PRIMARY KEY,
h3_index BIGINT,
last_updated TIMESTAMP
);
CREATE INDEX idx_h3 ON driver_locations (h3_index);
Querying all drivers in a hex and its neighbors:
SELECT driver_id
FROM driver_locations
WHERE h3_index IN (UNNEST(h3_k_ring(588126571676401663, 1)));
For in-memory aggregation, Redis keys can also use H3 IDs directly:
CURRENT_SUPPLY:588126571676401663 = 25
By combining H3 indexing with event-driven aggregation, we transform a noisy stream of GPS data into a structured, queryable spatial model — the foundation of a dynamic pricing engine.
4 Real-Time Supply-Demand Aggregation
The engine we’ve built so far can locate drivers and riders and assign each to an H3 hex. But knowing where they are is just step one. To price a market dynamically, we need to know how many active participants exist in each area at any moment. This section focuses on how we aggregate those numbers in real time — efficiently, accurately, and continuously.
The challenge isn’t conceptual; it’s operational. We’re processing potentially millions of pings per second, each representing a change in supply or demand. That requires a design that can absorb high throughput, avoid contention, and maintain correctness even as data arrives out of order.
4.1 Defining the Metrics: What Is “Supply” and “Demand”?
Before we start counting, we need clear definitions. The strength of any dynamic pricing algorithm depends on how you define and measure supply and demand at a granular level.
4.1.1 Supply: Active Drivers per Hex
“Supply” represents the number of available drivers in a specific H3 zone who can accept rides right now. Drivers who are offline, already on a trip, or in transit to pick up a passenger do not contribute to active supply.
A minimal supply tracking record might look like this:
public class DriverStatus
{
public string DriverId { get; set; } = default!;
public ulong HexId { get; set; }
public bool IsAvailable { get; set; }
public DateTime LastUpdated { get; set; }
}
Every time a driver’s location or state changes, an event updates their IsAvailable flag and HexId. The aggregation layer simply counts all drivers where IsAvailable = true within the hex.
In production, this count isn’t recalculated from scratch — it’s incrementally updated via atomic operations in Redis or another in-memory store.
4.1.2 Demand: Riders with “Intent” per Hex
“Demand” isn’t just active ride requests. It represents intent to ride — any user action signaling near-future demand. This can include:
- Opening the app and checking prices.
- Starting a trip request but not confirming.
- Actually submitting a ride request.
Each signal has different strength. We can assign weights, for example:
| Event Type | Weight |
|---|---|
| Price check | 0.3 |
| Ride search | 0.7 |
| Ride request | 1.0 |
This approach lets us smooth spikes without undercounting real intent. Weighted demand events can be aggregated similarly to driver updates, but with fractional increments.
public class RiderIntent
{
public string RiderId { get; set; } = default!;
public ulong HexId { get; set; }
public double Weight { get; set; } // e.g., 0.3, 0.7, 1.0
public DateTime Timestamp { get; set; }
}
The result is a continuously evolving picture of each hex’s supply and demand landscape.
4.2 Building the Kafka Consumer
With metrics defined, we can start consuming the event stream. The AggregationService subscribes to topics such as driver-status-events and rider-intent-events. It runs as a long-lived background process that consumes, processes, and updates Redis in near real time.
Here’s a skeleton implementation using .NET’s BackgroundService:
public class AggregationConsumer : BackgroundService
{
private readonly IConsumer<string, string> _consumer;
private readonly IAggregationProcessor _processor;
public AggregationConsumer(ConsumerConfig config, IAggregationProcessor processor)
{
_consumer = new ConsumerBuilder<string, string>(config).Build();
_processor = processor;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_consumer.Subscribe(new[] { "driver-status-events", "rider-intent-events" });
while (!stoppingToken.IsCancellationRequested)
{
var msg = _consumer.Consume(stoppingToken);
await _processor.ProcessAsync(msg.Topic, msg.Message.Value);
}
}
public override void Dispose()
{
_consumer.Close();
base.Dispose();
}
}
The processor class decodes messages and delegates them to the correct aggregator. The key design point here is that processing must be idempotent. Duplicate messages can occur; the service must handle replays safely.
public class AggregationProcessor : IAggregationProcessor
{
private readonly Channel<(string Topic, string Payload)> _channel;
public AggregationProcessor()
{
_channel = Channel.CreateBounded<(string, string)>(new BoundedChannelOptions(50000)
{
FullMode = BoundedChannelFullMode.Wait
});
_ = Task.Run(ProcessLoopAsync);
}
public async Task ProcessAsync(string topic, string payload)
=> await _channel.Writer.WriteAsync((topic, payload));
private async Task ProcessLoopAsync()
{
await foreach (var (topic, payload) in _channel.Reader.ReadAllAsync())
{
if (topic == "driver-status-events")
await HandleDriverAsync(payload);
else if (topic == "rider-intent-events")
await HandleRiderAsync(payload);
}
}
private Task HandleDriverAsync(string json)
{
var driver = JsonSerializer.Deserialize<DriverStatus>(json)!;
return UpdateSupplyAsync(driver);
}
private Task HandleRiderAsync(string json)
{
var intent = JsonSerializer.Deserialize<RiderIntent>(json)!;
return UpdateDemandAsync(intent);
}
// Implementations shown next
}
This approach uses System.Threading.Channels to decouple consumption from processing, ensuring high throughput without blocking Kafka’s consumer loop.
4.3 High-Performance Aggregation
4.3.1 Using Channels for an In-Memory Pipeline
For efficient batching, we group events by HexId and periodically flush aggregates to Redis. The logic can be implemented with an in-memory dictionary protected by ConcurrentDictionary.
private readonly ConcurrentDictionary<ulong, (int Supply, double Demand)> _state =
new();
private async Task UpdateSupplyAsync(DriverStatus status)
{
if (!status.IsAvailable) return;
_state.AddOrUpdate(status.HexId, (1, 0),
(_, old) => (old.Supply + 1, old.Demand));
}
private async Task UpdateDemandAsync(RiderIntent intent)
{
_state.AddOrUpdate(intent.HexId, (0, intent.Weight),
(_, old) => (old.Supply, old.Demand + intent.Weight));
}
A timed loop flushes these counters to Redis every few seconds. The use of channels ensures that processing remains sequential per key but concurrent across keys — perfect for a large, geographically distributed dataset.
private async Task FlushLoopAsync()
{
while (true)
{
await Task.Delay(TimeSpan.FromSeconds(5));
var snapshot = _state.ToArray();
_state.Clear();
foreach (var (hex, counts) in snapshot)
{
await _redis.HashIncrementAsync($"HEX:{hex}", "SUPPLY", counts.Supply);
await _redis.HashIncrementAsync($"HEX:{hex}", "DEMAND", counts.Demand);
}
}
}
This simple batching strategy reduces write load dramatically while maintaining near-real-time accuracy.
4.3.2 Atomically Updating Counts in Redis
Redis operations must be atomic to avoid race conditions between flushes. Using Lua scripts or transactions ensures that updates from multiple services don’t overwrite each other.
private const string UpdateScript = @"
redis.call('HINCRBY', KEYS[1], 'SUPPLY', ARGV[1])
redis.call('HINCRBYFLOAT', KEYS[1], 'DEMAND', ARGV[2])
return 1
";
public async Task PersistAsync(ulong hexId, int supply, double demand)
{
var key = $"HEX:{hexId}";
var prepared = LuaScript.Prepare(UpdateScript);
await _redis.ScriptEvaluateAsync(prepared, new { KEYS = new[] { key }, ARGV = new object[] { supply, demand } });
}
Each hex becomes a small Redis hash structure:
HEX:617700498172215295
SUPPLY -> 45
DEMAND -> 56.3
UPDATED_AT -> 2025-11-12T08:05:00Z
This data forms the backbone of our real-time pricing engine — always current within seconds.
4.4 The Output: Real-Time State Map
After aggregation, the market’s state exists as a distributed map of hex-level supply and demand. Each Redis key holds a self-contained snapshot for that area.
A query might look like:
var hexId = 617700498172215295UL;
var hash = await _redis.HashGetAllAsync($"HEX:{hexId}");
var supply = (int)hash.FirstOrDefault(x => x.Name == "SUPPLY").Value;
var demand = double.Parse(hash.FirstOrDefault(x => x.Name == "DEMAND").Value);
These values feed directly into the PricingEngine, which computes surge multipliers based on local imbalances. Because all data lives in memory (Redis), we can achieve round-trip latencies under 10 ms even at massive scale.
By the time we reach this point, our system can continuously map global activity into a concise, queryable form — a real-time market matrix that powers pricing, forecasting, and simulation.
5 The Core Pricing Engine: Algorithms and Implementation
With aggregation in place, we can finally compute prices that respond to the world in motion. The pricing engine is the decision layer — it turns numerical imbalances into economic signals that shape behavior.
At its simplest, it adjusts base fares with a multiplier. But the hard part isn’t arithmetic; it’s stability and foresight. In this section, we’ll build and improve the algorithmic models that make surge pricing both responsive and predictable.
5.1 The Basic Formula
At its core, pricing is just:
FinalPrice = (BaseFare + BookingFee) * SurgeMultiplier
Where:
- BaseFare comes from static pricing tables (e.g., per km, per minute).
- BookingFee covers platform costs.
- SurgeMultiplier is dynamic, derived from local supply-demand imbalance.
The SurgeMultiplier is our primary control knob. Set it too low and rides are scarce; too high and riders churn. The challenge is finding a formula that reflects market pressure without causing oscillations.
5.2 Model 1: The Reactive Ratio
The most direct approach is to compute a simple Supply-Demand Ratio (SDR).
5.2.1 Algorithm: Multiplier = f(DemandCount / SupplyCount)
The reactive model adjusts prices based on the current ratio:
double GetSurgeMultiplier(int supply, double demand)
{
if (supply == 0) return 3.5; // hard cap for no drivers
double ratio = demand / supply;
return Math.Clamp(1.0 + (ratio - 1.0) * 0.5, 1.0, 3.5);
}
This works surprisingly well at small scale. If demand doubles and supply stays constant, prices rise by 50%. But real-world conditions are noisy — even small fluctuations can cause “flickering.”
5.2.2 The Flickering Problem
Reactive models respond too quickly. If five drivers enter a zone, prices drop immediately, even if those drivers are just passing through. Then, when they leave, prices spike again. The oscillation frustrates both drivers and riders.
Moreover, network latency and asynchronous updates can make state appear inconsistent. We might see outdated supply counts, leading to “false” surges or drops.
5.2.3 Solution: Exponential and Neighbor Smoothing
We can stabilize the signal by averaging over time and space.
Temporal smoothing (EMA):
double Smooth(double previous, double current, double alpha = 0.3)
=> previous + alpha * (current - previous);
Spatial smoothing (Neighbor averaging):
double SmoothWithNeighbors(ulong hexId, IReadOnlyList<ulong> neighbors)
{
var ratios = new List<double>();
ratios.Add(GetCurrentRatio(hexId));
foreach (var n in neighbors)
ratios.Add(GetCurrentRatio(n));
return ratios.Average();
}
Combining these creates a balanced multiplier that reacts to persistent trends, not transient noise.
5.3 Model 2: The Predictive Engine
Even with smoothing, a reactive model always lags reality. By the time it raises prices, riders have already experienced scarcity. To anticipate the market, we move from reactive to predictive pricing.
5.3.1 Why Reactive Is Not Enough
Predictive models estimate where demand will be in the near future — 10 to 15 minutes ahead. This allows the system to proactively adjust prices, guiding drivers toward high-demand areas before they spike.
In practice, we use short-horizon time-series forecasting per hex zone. Historical demand is the best predictor of near-term demand.
5.3.2 Integrating ML.NET: Forecasting Demand
With ML.NET, we can build and deploy forecasting models directly in C#. The simplest approach uses SSA (Singular Spectrum Analysis), a non-neural technique ideal for lightweight real-time predictions.
Training example:
var ml = new MLContext();
var data = ml.Data.LoadFromTextFile<DemandRecord>("demand-history.csv", hasHeader: true, separatorChar: ',');
var pipeline = ml.Forecasting.ForecastBySsa(
outputColumnName: "ForecastedDemand",
inputColumnName: "Demand",
windowSize: 15,
seriesLength: 200,
trainSize: 500,
horizon: 3);
var model = pipeline.Fit(data);
ml.Model.Save(model, data.Schema, "demandForecast.zip");
Prediction usage in PricingEngine:
var ml = new MLContext();
var model = ml.Model.Load("demandForecast.zip", out var schema);
var forecastEngine = model.CreateTimeSeriesEngine<DemandRecord, DemandForecast>(ml);
var prediction = forecastEngine.Predict();
double predictedDemand = prediction.ForecastedDemand[0];
This predicted demand replaces the raw demand in the pricing formula:
Multiplier = f(PredictedDemand / CurrentSupply)
5.3.3 The New Formula
The final multiplier might use both real-time and predicted data:
double CalculatePredictiveMultiplier(double predictedDemand, int currentSupply)
{
var ratio = predictedDemand / Math.Max(currentSupply, 1);
return Math.Clamp(1.0 + Math.Log1p(ratio) * 0.4, 1.0, 3.5);
}
This logarithmic scaling prevents extreme surges while maintaining sensitivity at low ratios.
5.4 Code Example: The C# PricingEngine gRPC Service
The PricingEngine wraps all of this logic in a gRPC service callable by other systems. It fetches current state from Redis, retrieves predictions from the ML model, applies smoothing, and returns a multiplier.
5.4.1 Input: (HexID, TimeOfDay)
Each request represents a specific area and time context:
message PricingRequest {
string hex_id = 1;
string time_of_day = 2;
}
5.4.2 Logic and Implementation
public class PricingEngineService : PricingEngine.PricingEngineBase
{
private readonly IConnectionMultiplexer _redis;
private readonly PredictionEngine<DemandRecord, DemandForecast> _forecast;
public PricingEngineService(IConnectionMultiplexer redis, MLContext mlContext)
{
_redis = redis;
var model = mlContext.Model.Load("demandForecast.zip", out _);
_forecast = mlContext.Model.CreatePredictionEngine<DemandRecord, DemandForecast>(model);
}
public override async Task<PricingResponse> GetSurgeMultiplier(PricingRequest request, ServerCallContext context)
{
var db = _redis.GetDatabase();
var hash = await db.HashGetAllAsync($"HEX:{request.HexId}");
var supply = (int)(hash.FirstOrDefault(x => x.Name == "SUPPLY").Value ?? 0);
var demand = double.Parse(hash.FirstOrDefault(x => x.Name == "DEMAND").Value ?? "0");
var predicted = _forecast.Predict(new DemandRecord { Demand = demand }).ForecastedDemand[0];
var multiplier = CalculatePredictiveMultiplier(predicted, supply);
return new PricingResponse
{
Multiplier = multiplier,
ModelUsed = "Predictive",
};
}
private double CalculatePredictiveMultiplier(double predicted, int supply)
{
var ratio = predicted / Math.Max(supply, 1);
return Math.Clamp(1.0 + Math.Log1p(ratio) * 0.4, 1.0, 3.5);
}
}
This service can handle thousands of requests per second and produce consistent, stable prices even under heavy load. Every response includes metadata for auditability, allowing later analysis of why a specific price was chosen.
6 Price Elasticity and Market Simulation
Our pricing engine can now respond dynamically to real-time supply and demand, but responsiveness alone doesn’t make it smart. True optimization requires understanding how users behave when prices change — specifically, how riders reduce or delay trips as costs rise, and how drivers respond to higher fares. This behavior is captured by a simple but powerful economic concept: price elasticity.
When we simulate the marketplace using elasticity-driven models, we move from reactive pricing to strategic pricing. The goal isn’t to maximize the multiplier — it’s to maximize completed rides and total revenue, while maintaining platform equilibrium.
6.1 The Economist’s View: Price Elasticity of Demand (PED)
In economics, Price Elasticity of Demand (PED) measures how sensitive consumers are to price changes. The formula is straightforward:
PED = (% change in quantity demanded) / (% change in price)
For example, if a 10% increase in price causes a 5% drop in rides, PED = -0.5 (inelastic). If the same increase causes a 20% drop, PED = -2.0 (elastic).
In dynamic pricing, elasticity tells us how aggressive we can be before demand collapses. If we know a city has low elasticity — say, Friday night downtown — we can safely increase prices without losing many riders. Conversely, in highly elastic zones (weekday suburbs), even a small bump may cause demand to evaporate.
At Uber scale, PED isn’t static. It varies by:
- Time of day (morning commuters are less price-sensitive).
- Geography (airports vs. local neighborhoods).
- Context (bad weather, events, emergencies).
The challenge is building a model that learns these nuances from real trip data and predicts the impact of any given price adjustment.
6.2 Why It Matters
A common misconception is that higher surge multipliers automatically increase revenue. But consider this scenario:
| Multiplier | Price per Ride | Expected Trips | Total Revenue |
|---|---|---|---|
| 1.0x | $10 | 1000 | $10,000 |
| 1.5x | $15 | 800 | $12,000 |
| 3.0x | $30 | 300 | $9,000 |
| 5.0x | $50 | 50 | $2,500 |
The 1.5x scenario earns more overall because it balances supply, demand, and user willingness to pay. A 5x surge might look lucrative, but if it destroys rider trust or usage, it’s a long-term loss.
From an engineering perspective, this means pricing models must be trained and tested against completed trips, not theoretical demand. Our optimization goal shifts from “maximize price” to “maximize throughput and satisfaction under equilibrium.”
6.3 Modeling Elasticity with ML.NET
To quantify elasticity, we can train a regression model that predicts the number of completed trips based on historical pricing and context. ML.NET makes this practical inside the same .NET environment as our services.
We’ll build a simple regression model where the target variable is CompletedTrips, and features include:
MultiplierHexIdTimeOfDayIsEvent
Training Example
public class ElasticityRecord
{
public double Multiplier { get; set; }
public string HexId { get; set; } = default!;
public string TimeOfDay { get; set; } = default!;
public bool IsEvent { get; set; }
public float CompletedTrips { get; set; }
}
var ml = new MLContext();
var data = ml.Data.LoadFromTextFile<ElasticityRecord>("elasticity_data.csv", hasHeader: true, separatorChar: ',');
var pipeline = ml.Transforms.Categorical.OneHotEncoding("HexId")
.Append(ml.Transforms.Categorical.OneHotEncoding("TimeOfDay"))
.Append(ml.Transforms.Concatenate("Features", "Multiplier", "HexId", "TimeOfDay", "IsEvent"))
.Append(ml.Regression.Trainers.Sdca(labelColumnName: "CompletedTrips", maximumNumberOfIterations: 200));
var model = pipeline.Fit(data);
ml.Model.Save(model, data.Schema, "elasticityModel.zip");
Once trained, the model can estimate how many trips we can expect at a given price multiplier in any hex and time context.
Inference Example
var predictionEngine = ml.Model.CreatePredictionEngine<ElasticityRecord, TripPrediction>(model);
var input = new ElasticityRecord
{
Multiplier = 2.0,
HexId = "617700498172215295",
TimeOfDay = "Evening",
IsEvent = true
};
var output = predictionEngine.Predict(input);
Console.WriteLine($"Predicted completed trips: {output.CompletedTrips}");
public class TripPrediction
{
public float CompletedTrips { get; set; }
}
This model becomes a key input to our SimulationService, allowing us to evaluate different surge policies without impacting real users.
6.4 The Digital Twin: Simulating the Market
Once we have an elasticity model, the next logical step is to simulate the entire market as a “digital twin” — a virtual environment where we can test new pricing strategies before deployment.
6.4.1 The Need
Running live experiments on real customers is risky. A faulty surge model could lead to outrage, regulatory attention, or driver strikes. A simulation environment lets us stress-test algorithms safely by modeling millions of synthetic agents (drivers and riders) whose behavior mimics real-world data.
A well-designed digital twin answers questions like:
- What happens if we change the smoothing constant in one region?
- How do drivers redistribute under new pricing incentives?
- How long does it take for a demand spike to normalize?
6.4.2 Agent-Based Modeling
We model each driver and rider as an independent agent with decision logic:
- Riders decide to request, wait, or cancel based on the current multiplier and their elasticity profile.
- Drivers decide to go online, move toward higher surges, or log off.
A simple base class might look like this:
public abstract class Agent
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public ulong HexId { get; set; }
public abstract Task ActAsync(SimulationContext context);
}
Driver agent:
public class DriverAgent : Agent
{
public bool IsAvailable { get; set; }
public override async Task ActAsync(SimulationContext context)
{
var localMultiplier = await context.PricingEngine.GetMultiplierAsync(HexId);
if (localMultiplier > 1.5 && !IsAvailable)
IsAvailable = true; // incentive to go online
else if (localMultiplier < 1.1)
IsAvailable = false; // not worth it
}
}
Rider agent:
public class RiderAgent : Agent
{
public double Elasticity { get; set; }
public override async Task ActAsync(SimulationContext context)
{
var price = await context.PricingEngine.GetCurrentPriceAsync(HexId);
var willingness = 1.0 - (Elasticity * (price - 1.0));
if (willingness > 0.5)
await context.RequestRideAsync(this);
}
}
Each agent updates state through simulated time ticks, interacting with our pricing engine’s gRPC API as though it were a real user.
6.4.3 Tech Options: Microsoft Orleans and Akka.NET
To model millions of concurrent agents, we need a framework that scales actor-based concurrency transparently.
Microsoft Orleans is a natural fit. It uses the virtual actor model — agents are represented as lightweight “grains” that can be activated or deactivated on demand, distributed automatically across a cluster.
Example Orleans grain for a driver:
public class DriverGrain : Grain, IDriverGrain
{
private bool _isAvailable;
private ulong _hexId;
public Task Initialize(ulong hexId)
{
_hexId = hexId;
_isAvailable = true;
return Task.CompletedTask;
}
public async Task TickAsync(IPricingEngine engine)
{
var multiplier = await engine.GetMultiplierAsync(_hexId);
if (multiplier > 1.5) _isAvailable = true;
else _isAvailable = false;
}
}
Alternatively, Akka.NET provides similar actor abstractions with more direct control over message routing and cluster sharding.
Either approach lets us run a distributed simulation where tens of thousands of concurrent “virtual” drivers and riders interact with our actual pricing logic — providing real telemetry and outcomes.
By the end of this phase, we can deploy new models confidently, knowing they’ve survived thousands of simulated market cycles.
7 A/B Testing, Governance, and Regulatory Compliance
No pricing engine should go directly from simulation to production without guardrails. Real markets are unpredictable, and regional regulations impose strict limits. This section covers how we manage change safely, enforce fairness, and maintain transparency at scale.
7.1 Safely Rolling Out Change: The A/B Testing Framework
Testing new pricing strategies requires controlled experiments. Rather than randomizing individual users, we randomize entire H3 hexes. This ensures that all drivers and riders in the same area experience consistent prices, avoiding cross-contamination.
7.1.1 Feature Flags for Model Assignment
We can use a feature flag system to dynamically assign pricing models per hex. With Microsoft.FeatureManagement, setup is straightforward.
public class PricingExperimentFilter : IFeatureFilter
{
public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context)
{
var hexId = context.Parameters.GetValue<string>("HexId");
var hash = BitConverter.ToInt64(SHA256.HashData(Encoding.UTF8.GetBytes(hexId)));
return Task.FromResult(hash % 2 == 0); // Even hexes use Model B
}
}
Configuration in appsettings.json:
"FeatureManagement": {
"PredictivePricing": {
"EnabledFor": [
{
"Name": "PricingExperimentFilter",
"Parameters": {
"HexId": "617700498172215295"
}
}
]
}
}
This setup allows half the zones to use the predictive model while others stick to reactive logic.
7.1.2 Monitoring Key Metrics
Every experiment must have clear metrics. For pricing models, we typically track:
- Rider churn: Percentage of users who abandon after viewing price.
- Driver online rate: How many drivers stay active per time window.
- Completed trips: The ultimate success indicator.
Metrics are aggregated per experiment group and exported to Prometheus or Application Insights dashboards. If any metric crosses safety thresholds, the experiment can be rolled back immediately through a feature flag toggle.
7.2 The Rule Engine: Handling Reality
Even the most sophisticated model must operate within policy constraints. Governments, airports, and cities often impose surge caps or no-surge zones. In emergencies, surge pricing may need to be suspended altogether.
7.2.1 Surge Caps
Surge multipliers should never exceed the maximum allowed per region. This can be configured as part of a ruleset.
{
"Rules": [
{ "Region": "NYC", "MaxMultiplier": 3.5 },
{ "Region": "CA_SF", "MaxMultiplier": 2.8 }
]
}
7.2.2 Geofencing and No-Surge Zones
Some locations — airports, stadiums, hospitals — may require fixed pricing. Geofencing rules are layered on top of H3 grids using polygon intersections.
public class GeoRule
{
public string RegionName { get; set; } = default!;
public List<(double lat, double lng)> Polygon { get; set; } = new();
}
Using the H3 API:
bool IsInsideNoSurgeZone(double lat, double lng)
{
var pointHex = H3Lib.GeoToH3(lat, lng, 8);
return _noSurgeZones.Any(z => H3Lib.PolygonContains(z.Polygon, (lat, lng)));
}
7.2.3 States of Emergency
In crises like hurricanes or protests, a global switch disables surge pricing entirely. This can be implemented as a simple distributed key in Redis:
if (await redis.StringGetAsync("SURGE_DISABLED") == "true")
multiplier = 1.0;
Administrators toggle it via a secure control panel or API.
7.2.4 Code Example: Rules Engine Implementation
A structured rules engine helps compose and apply these checks predictably. Using the open-source RulesEngine package simplifies management.
public class PricingRules
{
public async Task<double> ApplyRulesAsync(double multiplier, string region)
{
var input = new { Multiplier = multiplier, Region = region };
var result = await _rulesEngine.ExecuteAllRulesAsync("PricingPolicy", input);
return result.First().IsSuccess ? result.First().Output : multiplier;
}
}
Example rule JSON:
{
"WorkflowName": "PricingPolicy",
"Rules": [
{
"RuleName": "CapMultiplier",
"Expression": "Multiplier > 3.5",
"Actions": { "OnSuccess": { "Output": 3.5 } }
},
{
"RuleName": "DisableSurgeDuringEmergency",
"Expression": "context.SURGE_DISABLED == true",
"Actions": { "OnSuccess": { "Output": 1.0 } }
}
]
}
This layer guarantees that every surge decision is compliant before being returned to the client.
7.3 Auditability
Transparency is essential for regulatory trust. Every price computation must leave a traceable log explaining:
- The multiplier returned.
- The model used.
- The input data (supply/demand ratios).
- The rules applied.
Structured logging with correlation IDs ensures reproducibility.
_logger.LogInformation("PriceDecision {PriceId}: Multiplier={Multiplier}, Model={Model}, SDR={Ratio}, Rules={Rules}",
Guid.NewGuid(), multiplier, modelName, ratio, appliedRules);
Audit logs are archived in a time-series database for regulatory queries or retrospective analysis. This makes it possible to justify every price in hindsight — a non-negotiable requirement in regulated markets.
8 Conclusion: The Evolving .NET-Powered Marketplace
By this point, we’ve assembled a complete real-time pricing platform — a digital ecosystem that reacts, predicts, and self-regulates. From Kafka ingestion to ML-powered forecasting, every piece fits together into a living marketplace that maintains equilibrium through computation.
8.1 Recap: From Pings to a Scalable Market
We started with raw GPS pings and ended with a coordinated system that:
- Ingests millions of events via Kafka.
- Maps them into H3 zones with geospatial precision.
- Aggregates supply and demand in real time using Redis.
- Computes surge prices through reactive and predictive models.
- Simulates outcomes with digital twins.
- Enforces fairness and compliance through governance layers.
It’s a robust, production-grade architecture — all built on modern .NET foundations.
8.2 The Future of Pricing
Dynamic pricing is evolving beyond simple multipliers. The next generation of systems will personalize and learn continuously.
8.2.1 Hyper-Personalization
Elasticity isn’t uniform across users. Future engines can predict individual rider sensitivity using historical behavior, subscription tier, or loyalty status. Personalized surge multipliers could adapt to each user’s willingness to pay — as long as regulations permit.
8.2.2 Reinforcement Learning
Instead of predefining formulas, reinforcement learning can discover optimal strategies through continuous interaction with simulated markets. A model could learn the surge adjustment policy that maximizes total welfare (completed trips + driver earnings) without human tuning.
These systems would rely on feedback loops between real and simulated environments — learning from both live and synthetic data.
8.3 The .NET Advantage
The modern .NET ecosystem makes building such systems practical and elegant:
- .NET Aspire simplifies orchestration and observability across microservices.
- Native AOT compiles services into fast, memory-efficient binaries.
- ML.NET enables native AI within the same deployment.
- Orleans and gRPC power concurrent, low-latency distributed systems.
Together, these capabilities transform .NET from a web framework into a high-performance computing platform — capable of running the next generation of real-time, market-aware applications.
We’ve essentially recreated the brain of a billion-dollar transportation network — and we did it in C#.