← Back to blog

Real-time dispatching with SignalR: architecture for fleets

How to build real-time dispatching for a fleet with SignalR — auction model, 4 hubs, separating push from live sync, one source of truth, and 12 background jobs for timeouts.

2024, Carivio project. A taxi fleet, dozens of drivers online at peak, dispatchers wanting to see locations in real time, customers wanting to know where their car is. Seemingly simple — add SignalR. In practice, real-time that does not flood the network with duplicate events and does not break on reconnect is a matter of architectural discipline, not just installing a NuGet package.

Here is how we built it and where the traps are.

Four hubs, four domains

First decision: one hub or several? One hub is tempting — simpler routing, a single connection endpoint. But then messages with completely different priorities flow into the same channel: a system notification (new ride — critical), a chat message (driver writing to dispatcher — less critical), a location update (every 3 seconds — high frequency). Mixing these means location messages flood the log and critical notifications become hard to trace.

We split into 4 hubs by domain:

  • NotificationsHub — system messages, new orders, offer status
  • ChatHub — driver ↔ dispatcher communication within a ride
  • RidesHub — order state transitions (accepted, boarded, completed)
  • DriversHub — driver locations, online/offline transitions

Each hub has its own SignalR groups — a driver joins the group for their current ride or area. Broadcasts go only where they need to go.

Auction dispatching: how it works

The sequential dispatching model — offer one driver, wait 30 seconds, then try the next — is slow and unfair. Drivers closer to the dispatch office have an advantage simply because they are first in the queue.

The auction model works differently:

  1. A customer places a ride order.
  2. The system identifies eligible drivers in range (distance, online status, capacity).
  3. The offer is broadcast to all of them simultaneously via NotificationsHub.
  4. Drivers respond — each sends a bid (interest in the ride, optionally with a price).
  5. The system picks the best bid based on a weighted score (distance, rating, response time).
  6. The result comes back via SignalR: the winner gets confirmation, the rest get "taken".

The full cycle typically takes 10–20 seconds. Without real-time this would not be workable.

The tricky part: after sending the offer, the system must handle the case where two drivers submit a bid in the same second. Acceptance must be idempotent — one winner, no race condition. We handle this with optimistic locking at the DB record level when selecting the winner.

Push vs. SignalR: do not confuse, do not mix

This is where the most common mistake in real-time dispatching hides. The driver has a mobile app. The app can be in the foreground (active session) or in the background (a push notification arrived, the user has not opened it yet). These are two different states with different channels:

Push notifications (FCM/APNs): arrive even when the app is asleep. They serve as a wakeup — telling the driver "open the app, you have an offer". We do not expect push to drive state directly.

SignalR: the live channel for an active session. Updates the UI in real time, carries location, ride states, chat messages.

When you merge the two — send a push with a payload and a SignalR message with the same content — the driver's app processes the same offer twice. Once from the push handler, once from the SignalR handler. This shows up as a duplicate entry in the UI or mysterious double-processing on the server.

The rule we adopted: push is wakeup only, no business payload. The payload comes via SignalR after reconnect. When a push notification arrives, the app connects to the hub and fetches the current state.

One source of truth

A driver's state can be "seen" in three places: local cache in the app, SignalR connection state on the server, and the DB record. When these three diverge, you get the classic problem: the dispatcher sees the driver as online, the app shows them as offline, the DB says "last ping 4 minutes ago".

The rule: the single source of truth for critical states is the DB. SignalR connection state and local cache in the app are replicas — they may lag, but they must converge.

Specifically:

  • Driver online/offline state is written to the DB on every heartbeat (every 2 minutes). The heartbeat travels over SignalR but is written to the DB.
  • Pending ride offers exist in the DB. SignalR is just transport for the real-time notification.
  • Active ride — state in the DB, SignalR carries transitions, but after reconnect the app always fetches current state from a REST endpoint, not from SignalR messages.

This means reconnect is safe: the app can disconnect and reconnect at any time and receives a consistent state.

Reconnect and timeouts: 12 background jobs

A real-time system without timeout logic stops working the moment a driver loses signal for 3 minutes. Or when the server restarts. Or when the driver closes the app without logging out.

We run 12 Quartz background jobs, each with a specific responsibility:

  • HeartbeatTimeoutJob — marks a driver offline if no heartbeat has arrived for more than 5 minutes
  • OfferExpirationJob — expires unclaimed ride offers after N seconds
  • RideTimeoutJob — escalates rides where the driver has not arrived within X minutes of accepting
  • ChatCleanupJob — archives old conversations
  • ... and more for cleanup, reconciliation, and monitoring

Quartz provides persistence — a job is not forgotten even after a server restart. This is critical: without persistence, timeout jobs are lost on deploy and offers hang open indefinitely.

Reconnect handling on the client: the app attempts reconnect with exponential backoff (1s, 2s, 4s, max 30s). After a successful reconnect it calls the REST endpoint for current state and rejoins the appropriate SignalR groups. Messages that arrived during the outage are fetched from the DB — SignalR is not a persistent message queue and we do not use it as one.

What to learn before you start

Real-time dispatching for a fleet is not "add SignalR and broadcast". The key decisions that will affect debugging for months ahead:

Separate push from live sync from the start. Merging them is easy, splitting them hurts. Duplicate events surface in production, where they are hardest to trace.

One source of truth in the DB, always. SignalR connection state is ephemeral. On reconnect, restart, or a new server instance it is gone. The DB is not.

Timeout jobs are core, not nice-to-have. A fleet of 50 drivers a day generates dozens of situations where a driver loses connectivity, closes the app, or does not respond. Without jobs these accumulate as zombie records.

The auction model requires idempotence in winner selection. Two bids in the same second is normal traffic, not an edge case. Handle it from the start.

We built this architecture at Carivio. If you are working on real-time dispatching for a fleet, get in touch — we are happy to walk through your design.

FAQ

Why separate push notifications from SignalR?

Push and SignalR serve different roles. Push is a wakeup — it reaches the driver even when the app is asleep. SignalR is live sync for an active session. When you merge them into one layer, you get duplicate events: the driver receives the notification via push and simultaneously via SignalR, and processes it twice. That surfaces as a mysterious bug where one order looks like two.

What is the auction dispatching model and when should you use it?

A ride offer is broadcast to a group of drivers simultaneously. Each can respond. The system picks the best bid and broadcasts the result to the others. Those who did not win receive "taken". Compared to sequential dispatching it is faster and fairer — but requires timeout logic and bid deduplication.

How many SignalR hubs is enough and how should you split them?

There is no fixed rule. I recommend splitting by domain, not by technical layer. For fleet dispatching, four areas arise naturally: notifications, chat, rides, and drivers. One hub for everything seems to simplify the code but mixes messages with different priorities — and debugging turns into a nightmare.

Facing a similar problem? Get in touch.

Book a consultation