A ride-hailing product looks simple from the passenger's seat: tap, wait, ride, pay. Behind that simplicity sits one of the most demanding real-time systems a small team can build. In this article we walk through the architecture we designed for a ride-hailing platform serving the Finnish market — four applications, one backend, and a lot of decisions about what has to be instant and what merely has to be reliable.
The four applications
- Rider app (Flutter) — booking, live tracking, payments, ratings.
- Driver app (Flutter) — availability, trip offers, navigation, earnings.
- Web admin (Angular) — live fleet map, fares, promotions, disputes, reports.
- Backend API (ASP.NET Core) — the source of truth for trips, users, pricing and payments.
Choosing Flutter for both mobile apps let one team share design tokens, networking and analytics code between rider and driver, while still shipping native-feeling apps on iOS and Android. Angular's strong typing and opinionated structure made it a natural fit for a data-heavy admin console maintained by .NET developers.
Real-time: SignalR or Firebase?
The two hot paths are driver location updates (thousands of small writes per minute) and trip status events (few, but critical). We use both technologies, deliberately:
| Concern | SignalR (ASP.NET Core) | Firebase Realtime Database |
|---|---|---|
| Latency | Very low, persistent WebSocket | Low, managed globally |
| Offline / reconnect | Manual handling | Built-in local cache and sync |
| Fan-out to many riders | Groups, needs Redis backplane at scale | Native, scales automatically |
| Business rules | Runs inside your domain code | Security rules only |
| Cost model | Compute you already run | Per connection / bandwidth |
Our rule: anything that changes money or trip state goes through the API over SignalR; the high-frequency, low-value location stream is mirrored into Firebase so the rider app can subscribe cheaply and keep working through tunnels and dead zones.
// ASP.NET Core — driver location hub
public class TrackingHub : Hub
{
private readonly ITripService _trips;
public TrackingHub(ITripService trips) => _trips = trips;
public async Task UpdateLocation(Guid tripId, double lat, double lng, double heading)
{
await _trips.RecordLocationAsync(tripId, lat, lng, heading);
await Clients.Group($"trip:{tripId}")
.SendAsync("driverMoved", new { lat, lng, heading, at = DateTime.UtcNow });
}
public Task WatchTrip(Guid tripId) =>
Groups.AddToGroupAsync(Context.ConnectionId, $"trip:{tripId}");
}Matching drivers to riders
Dispatch is a geospatial query followed by an offer workflow. We store the last known driver location as a spatial column and query the nearest available drivers within a radius, then rank by ETA from the Google Maps Distance Matrix API rather than straight-line distance — a lake or a railway line makes a big difference in Helsinki.
-- Nearest available drivers (PostGIS / SQL Server spatial)
SELECT TOP (10) d.Id, d.Location.STDistance(@pickup) AS Metres
FROM Drivers d
WHERE d.Status = 'Available'
AND d.Location.STDistance(@pickup) < 4000
ORDER BY Metres;The offer itself is a small state machine: Offered → Accepted | Declined | TimedOut. A Hangfire delayed job enforces the timeout, so a driver who loses connectivity never blocks the queue.
The rider's live map
In Flutter, the rider screen simply subscribes to a stream of positions and re-renders the marker. Because the stream comes from Firebase, it survives app backgrounding and flaky connections with no extra code.
// Flutter rider app — live driver marker
StreamBuilder<DriverPosition>(
stream: trackingService.positions(tripId),
builder: (context, snap) {
if (!snap.hasData) return const Center(child: CircularProgressIndicator());
final p = snap.data!;
return GoogleMap(
initialCameraPosition: CameraPosition(target: LatLng(p.lat, p.lng), zoom: 15),
markers: { Marker(markerId: const MarkerId('driver'), position: LatLng(p.lat, p.lng), rotation: p.heading) },
);
},
);Payments, SMS and background work
- Stripe handles cards, Apple Pay and Google Pay. We authorise an estimate at booking and capture the final fare at drop-off.
- Twilio sends OTP codes for phone verification and trip notifications where push isn't delivered.
- Hangfire runs the timers, weekly driver payouts, receipt emails and nightly reports — all visible in its dashboard.
Built for Europe
Serving Finland meant euro pricing with VAT breakdowns on receipts, Finnish and English localisation, and strict GDPR handling: location history is pseudonymised after 30 days, and every personal-data export or deletion is a single API call. Hosting in an EU region on Azure keeps data residency simple.
Takeaways
- Separate state (API + SignalR) from telemetry (Firebase).
- Rank drivers by real ETA, not distance.
- Make every transition idempotent and timed.
- Share code between rider and driver apps with Flutter packages, not copy-paste.
Planning a ride-hailing, taxi or fleet product? Talk to our team — we've already solved most of the hard parts.