Implementation of a real-time school bus tracking system using:
~ ASP.NET Web API on .NET Framework
~ Redis
~ Redis Pub/Sub
~ Mobile app for bus drivers
~ Mobile app for parents
~ Background subscriber service
~ REST APIs for location updates and tracking
Idea is:
The bus driver’s mobile app will continuously send GPS coordinates to the backend API. The backend API will store the latest location in Redis and publish the update through Redis Pub/Sub. The parent app will receive the latest bus location in real time.
Redis is great because it is:
~ Extremely fast due to in-memory data-store(~1ms reads)
~ Single-threaded
High-Level Design:
Driver Mobile App
|
| POST /api/bus/location
v
ASP.NET Web API Backend
|
|-- Save latest location in Redis
|-- Publish location event to Redis Pub/Sub
|-- Optionally save periodic history in SQL Server
|
v
Redis
├── Key-Value Cache
│ └── bus:location:{busId}
│
└── Pub/Sub Channel
└── bus:location:{busId}
|
v
Real-Time Subscriber Service
|
|-- SignalR/WebSocket push
|-- Push notification
|-- Parent app polling fallback
v
Parent Mobile App
How Redis is useful here?
Really Fast Latest Location Lookup -> For parent mobile apps, we need only the latest bus location not the entire route history.
// example Redis key:
bus:location:BUS101
// value:
{
"busId": "BUS101",
"latitude": 28.6139,
"longitude": 77.2090,
"speed": 32.5,
"heading": 180,
"updatedAt": "2026-05-23T10:15:30Z"
}
How is Pub/Sub used Real-Time Updates?
Whenever Driver’s app sends location update, the backend publishes it to a Redis channel.
// example Redis Key:
bus:location:BUS101
~ Subscribers(in our case Parent’s Mobile App) listening to this channel can immediately receive the update.
~ Redis Pub/Sub is useful for fan-out messaging, but it is important to understand that it is not durable because if a subscriber is offline, it will miss messages.
~ Redis Pub/Sub is inherently stateless and follows fire-and-forget model. That is the reason why the data should also be stored in Redis as a key-value entry.
This helps us to reduced database Load:
So, instead of writing every GPS point to database, we can:
~ Store latest location in Redis every 5 seconds.
~ Publish every update through Redis Pub/Sub.
~ Store GPS point in SQL database only every 30 seconds or 1 minute depending on your requirements.
~ Store important events/statuses like trip started, trip ended, stop reached, student boarded.
So What are the main System Components?
1. Driver App
- Sends GPS location every few seconds.
- Sends busId, driverId, tripId, lat/lng, speed, timestamp.
2. Backend API
- Authenticates driver.
- Validates bus and active trip.
- Saves latest location in Redis.
- Publishes update to Redis Pub/Sub.
3. Redis
- Stores current location.
- Publishes live location events.
4. Parent App
- Fetches latest location.
- Receives live updates.
- Shows bus on map.
5. Admin Dashboard
- Tracks all buses.
- Views route progress.
- Handles alerts and delays.
6. SQL Server
- Stores schools, buses, drivers, students, parents, routes, stops, trips.
Very Important to Design Good Redis Key
Latest Bus Location:
bus:location:latest:{busId}
Active Trip for Bus:
bus:active-trip:{busId}
Parent Subscription Mapping:
parent:buses:{parentId} // this can be sorted set, values can be set like 101, 103 etc
Redis Pub/Sub Channel:
channel:bus:location:{busId}
Global Admin Channel:
bus:location:all
This is what the Real-Time Flow looks like:
Driver Mobile App
|
| Every 5 seconds
| POST /api/driver/location
v
ASP.NET Web API
|
| 1. Validate driver
| 2. Check active trip
| 3. Store latest location in Redis
| 4. Publish update to Redis Pub/Sub
v
Redis
|
| SET bus:location:latest:101
| PUBLISH channel:bus:location:101
| PUBLISH bus:location:all
v
Backend Redis Subscriber
|
| Receives Redis Pub/Sub event
| Pushes to SignalR group
v
Parent Mobile App
|
| Receives locationUpdated event
v
Map marker moves
It’s Code time:
Redis Connection Factory:
There is no need to create Redis connection for every request. We can reuse a single ConnectionMultiplexer.
using StackExchange.Redis;
using System;
using System.Configuration;
namespace SchoolBusTracking.Infrastructure
{
public static class RedisConnectionFactory
{
private static readonly Lazy<ConnectionMultiplexer> LazyConnection =
new Lazy<ConnectionMultiplexer>(() =>
{
string redisConnection =
ConfigurationManager.AppSettings["RedisConnection"];
return ConnectionMultiplexer.Connect(redisConnection);
});
public static ConnectionMultiplexer Connection
{
get { return LazyConnection.Value; }
}
public static IDatabase Database
{
get { return Connection.GetDatabase(); }
}
public static ISubscriber Subscriber
{
get { return Connection.GetSubscriber(); }
}
}
}
Redis Key Helper
We should use separate key names for storage and Pub/Sub channels.
namespace SchoolBusTracking.Infrastructure
{
public static class RedisKeys
{
public static string LatestBusLocation(int busId)
{
return "bus:location:latest:" + busId;
}
public static string ActiveTripForBus(int busId)
{
return "bus:active-trip:" + busId;
}
public static string ParentBuses(int parentId)
{
return "parent:buses:" + parentId;
}
public static string BusLocationChannel(int busId)
{
return "channel:bus:location:" + busId;
}
public static string GlobalAdminBusLocationChannel()
{
return "bus:location:all";
}
}
}
Location Request Model
This request comes from the driver mobile app.
using System;
namespace SchoolBusTracking.Models
{
public class BusLocationRequest
{
public int BusId { get; set; }
public int DriverId { get; set; }
public decimal Latitude { get; set; }
public decimal Longitude { get; set; }
public double? Speed { get; set; }
public double? Heading { get; set; }
public DateTime DeviceTimestamp { get; set; }
}
}
Redis Location Model
This is what we should store and publish in Redis.
using System;
namespace SchoolBusTracking.Models
{
public class BusLocationRedisModel
{
public int BusId { get; set; }
public int TripId { get; set; }
public int DriverId { get; set; }
public decimal Latitude { get; set; }
public decimal Longitude { get; set; }
public double? Speed { get; set; }
public double? Heading { get; set; }
public DateTime DeviceTimestamp { get; set; }
public DateTime ServerTimestamp { get; set; }
}
}
Trip Start API
When driver starts the bus trip, we should save active trip in Redis.
using SchoolBusTracking.Infrastructure;
using StackExchange.Redis;
using System;
using System.Threading.Tasks;
using System.Web.Http;
namespace SchoolBusTracking.Controllers
{
[RoutePrefix("api/driver/trip")]
public class DriverTripController : ApiController
{
private readonly IDatabase _redisDb;
public DriverTripController()
{
_redisDb = RedisConnectionFactory.Database;
}
[HttpPost]
[Route("start")]
public async Task<IHttpActionResult> StartTrip(StartTripRequest request)
{
if (request == null)
return BadRequest("Invalid request.");
if (request.BusId <= 0)
return BadRequest("Invalid bus id.");
if (request.DriverId <= 0)
return BadRequest("Invalid driver id.");
// In production, insert trip in SQL Server and get generated TripId.
int tripId = 101;
string activeTripKey = RedisKeys.ActiveTripForBus(request.BusId);
await _redisDb.StringSetAsync(
activeTripKey,
tripId,
TimeSpan.FromHours(4) // Redis will automatically expire/delete it after 4 hours.
);
return Ok(new
{
success = true,
tripId = tripId,
message = "Trip started successfully."
});
}
}
public class StartTripRequest
{
public int BusId { get; set; }
public int DriverId { get; set; }
public int RouteId { get; set; }
public string TripType { get; set; }
}
}
Final Redis data:
SET bus:active-trip:101 501
Driver Location Service
This service will:
- Check active trip.
- Store latest location.
- Publishe to bus-specific channel.
- Publishe to global admin channel.
using Newtonsoft.Json;
using SchoolBusTracking.Infrastructure;
using SchoolBusTracking.Models;
using StackExchange.Redis;
using System;
using System.Configuration;
using System.Threading.Tasks;
namespace SchoolBusTracking.Services
{
public class BusLocationService
{
private readonly IDatabase _redisDb;
private readonly ISubscriber _publisher;
public BusLocationService()
{
_redisDb = RedisConnectionFactory.Database;
_publisher = RedisConnectionFactory.Subscriber;
}
public async Task SaveAndPublishLocationAsync(BusLocationRequest request)
{
ValidateLocation(request);
int? activeTripId = await GetActiveTripIdAsync(request.BusId);
if (!activeTripId.HasValue)
{
throw new Exception("No active trip found for this bus.");
}
var location = new BusLocationRedisModel
{
BusId = request.BusId,
TripId = activeTripId.Value,
DriverId = request.DriverId,
Latitude = request.Latitude,
Longitude = request.Longitude,
Speed = request.Speed,
Heading = request.Heading,
DeviceTimestamp = request.DeviceTimestamp,
ServerTimestamp = DateTime.UtcNow
};
string json = JsonConvert.SerializeObject(location);
string latestLocationKey =
RedisKeys.LatestBusLocation(request.BusId);
string busLocationChannel =
RedisKeys.BusLocationChannel(request.BusId);
string globalAdminChannel =
RedisKeys.GlobalAdminBusLocationChannel();
int expiryMinutes = Convert.ToInt32(
ConfigurationManager.AppSettings["RedisLocationExpiryMinutes"] ?? "30"
);
await _redisDb.StringSetAsync(
latestLocationKey,
json,
TimeSpan.FromMinutes(expiryMinutes)
);
await _publisher.PublishAsync(busLocationChannel, json);
await _publisher.PublishAsync(globalAdminChannel, json);
}
public async Task<BusLocationRedisModel> GetLatestLocationAsync(int busId)
{
string key = RedisKeys.LatestBusLocation(busId);
RedisValue value = await _redisDb.StringGetAsync(key);
if (value.IsNullOrEmpty)
return null;
return JsonConvert.DeserializeObject<BusLocationRedisModel>(value);
}
private async Task<int?> GetActiveTripIdAsync(int busId)
{
string activeTripKey = RedisKeys.ActiveTripForBus(busId);
RedisValue value = await _redisDb.StringGetAsync(activeTripKey);
if (value.IsNullOrEmpty)
return null;
int tripId;
if (!int.TryParse(value, out tripId))
return null;
return tripId;
}
private void ValidateLocation(BusLocationRequest request)
{
if (request == null)
throw new ArgumentException("Invalid request.");
if (request.BusId <= 0)
throw new ArgumentException("Invalid bus id.");
if (request.DriverId <= 0)
throw new ArgumentException("Invalid driver id.");
if (request.Latitude < -90 || request.Latitude > 90)
throw new ArgumentException("Invalid latitude.");
if (request.Longitude < -180 || request.Longitude > 180)
throw new ArgumentException("Invalid longitude.");
}
}
}
Driver Location API
The driver’s app will call this API every 5 seconds.
using SchoolBusTracking.Models;
using SchoolBusTracking.Services;
using System;
using System.Threading.Tasks;
using System.Web.Http;
namespace SchoolBusTracking.Controllers
{
[RoutePrefix("api/driver")]
public class DriverLocationController : ApiController
{
private readonly BusLocationService _busLocationService;
public DriverLocationController()
{
_busLocationService = new BusLocationService();
}
[HttpPost]
[Route("location")]
public async Task<IHttpActionResult> UpdateLocation(BusLocationRequest request)
{
try
{
// Production checks:
// 1. Authenticate driver.
// 2. Check driver is assigned to this bus.
// 3. Check trip is active.
await _busLocationService.SaveAndPublishLocationAsync(request);
return Ok(new
{
success = true,
message = "Location updated successfully."
});
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
}
}
Driver Mobile App: Send Location Every 5 Seconds
let locationTimer = null;
function startSendingLocation(busId, driverId) {
locationTimer = setInterval(async function () {
try {
const position = await getCurrentLocation();
const payload = {
busId: busId,
driverId: driverId,
latitude: position.latitude,
longitude: position.longitude,
speed: position.speed,
heading: position.heading,
deviceTimestamp: new Date().toISOString()
};
await fetch("https://api.yourdomain.com/api/driver/location", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer DRIVER_AUTH_TOKEN"
},
body: JSON.stringify(payload)
});
console.log("Location sent", payload);
} catch (error) {
console.log("Failed to send location", error);
}
}, 5000);
}
function stopSendingLocation() {
if (locationTimer) {
clearInterval(locationTimer);
locationTimer = null;
}
}
async function getCurrentLocation() {
// Replace this with actual mobile GPS Data.
return {
latitude: 28.6139,
longitude: 77.2090,
speed: 32.5,
heading: 180
};
}
FYI: Redis Pub/Sub channels are dynamic. The channel exists only when someone publishes or subscribes to that channel. Channels are not created explicitly.
TLDR;
What Happens Internally on Every Location Update?
When driver sends this JSON:
{
"busId": 101,
"driverId": 22,
"latitude": 28.6139,
"longitude": 77.2090,
"speed": 32.5,
"heading": 180,
"deviceTimestamp": "2026-05-23T10:15:30Z"
}
Backend checks:
GET bus:active-trip:101
Suppose Redis returns:
501
Then backend stores latest location:
SET bus:location:latest:101 "{location json}"
Then backend publishes to bus channel:
PUBLISH channel:bus:location:101 "{location json}"
Then backend publishes to admin channel:
PUBLISH bus:location:all "{location json}"
Parent
Parent Access Mapping
A parent should only receive location updates for buses assigned to their children.
Example:
Parent ID: 501
Allowed buses: 101, 103
Redis Set:
SADD parent:buses:501 101
SADD parent:buses:501 103
Check access:
SISMEMBER parent:buses:501 101
If result is true, parent can track bus 101.
Parent Authorization Service
using SchoolBusTracking.Infrastructure;
using StackExchange.Redis;
using System.Threading.Tasks;
namespace SchoolBusTracking.Services
{
public class ParentBusAccessService
{
private readonly IDatabase _redisDb;
public ParentBusAccessService()
{
_redisDb = RedisConnectionFactory.Database;
}
public async Task<bool> CanParentTrackBusAsync(int parentId, int busId)
{
string key = RedisKeys.ParentBuses(parentId);
return await _redisDb.SetContainsAsync(key, busId); //Redis Set is simpler.
}
public async Task AddBusForParentAsync(int parentId, int busId)
{
string key = RedisKeys.ParentBuses(parentId);
await _redisDb.SetAddAsync(key, busId);
}
}
}
Get Latest Bus Location API
This is useful when the parent opens the app for the first time.
using SchoolBusTracking.Services;
using System.Threading.Tasks;
using System.Web.Http;
namespace SchoolBusTracking.Controllers
{
[RoutePrefix("api/parent")]
public class ParentBusController : ApiController
{
private readonly BusLocationService _busLocationService;
private readonly ParentBusAccessService _accessService;
public ParentBusController()
{
_busLocationService = new BusLocationService();
_accessService = new ParentBusAccessService();
}
[HttpGet]
[Route("bus/{busId:int}/location")]
public async Task<IHttpActionResult> GetLatestBusLocation(int busId)
{
// In production, get this from auth token.
int parentId = 501;
bool allowed = await _accessService.CanParentTrackBusAsync(
parentId,
busId
);
if (!allowed)
{
return Unauthorized();
}
var location = await _busLocationService.GetLatestLocationAsync(busId);
if (location == null)
{
return NotFound();
}
return Ok(location);
}
}
}
Parent Real-Time Updates Using SignalR
Redis Pub/Sub works inside the backend. Parent app receives updates through SignalR.
A bit about SignalR. SignalR is not exactly Server-Sent Events (SSE) because SSE is one-way only (server to client) but SignalR provides two-way (bi-directional) communication. SignalR can use WebSockets, SSE or Long Polling automatically based on your browser and server support.
But why not connect parent app directly to Redis?
Because:
1. Redis should not be exposed publicly.
2. Parent authorization must happen in backend.
3. Redis Pub/Sub has no user-level access control.
4. Mobile clients should not know Redis credentials.
5. Backend can control which parent receives which bus update.
So the flow is:
Redis Pub/Sub
↓
Backend Subscriber
↓
SignalR Group
↓
Parent Mobile App
SignalR Hub
Each parent joins a bus-specific group
Example Group:
bus-101
Code:
using Microsoft.AspNet.SignalR;
using SchoolBusTracking.Services;
using System.Threading.Tasks;
namespace SchoolBusTracking.Hubs
{
public class BusTrackingHub : Hub
{
private readonly ParentBusAccessService _accessService;
public BusTrackingHub()
{
_accessService = new ParentBusAccessService();
}
public async Task JoinBusGroup(int busId)
{
// In production, parentId should come from authenticated user token.
int parentId = 501;
bool allowed = await _accessService.CanParentTrackBusAsync(
parentId,
busId
);
if (!allowed)
{
return;
}
await Groups.Add(Context.ConnectionId, "bus-" + busId);
}
public async Task LeaveBusGroup(int busId)
{
await Groups.Remove(Context.ConnectionId, "bus-" + busId);
}
}
}
Redis Subscriber to SignalR Bridge
This class subscribes to Redis Pub/Sub and pushes updates to SignalR groups.
using Microsoft.AspNet.SignalR;
using Newtonsoft.Json;
using SchoolBusTracking.Hubs;
using SchoolBusTracking.Infrastructure;
using SchoolBusTracking.Models;
using StackExchange.Redis;
namespace SchoolBusTracking.Services
{
public class RedisToSignalRBridge
{
private readonly ISubscriber _subscriber;
public RedisToSignalRBridge()
{
_subscriber = RedisConnectionFactory.Subscriber;
}
public void Start()
{
SubscribeToAllBusLocations();
}
private void SubscribeToAllBusLocations()
{
string globalChannel = RedisKeys.GlobalAdminBusLocationChannel();
_subscriber.Subscribe(globalChannel, (channel, message) =>
{
var location =
JsonConvert.DeserializeObject<BusLocationRedisModel>(message);
var hubContext =
GlobalHost.ConnectionManager.GetHubContext<BusTrackingHub>();
string parentGroupName = "bus-" + location.BusId;
hubContext.Clients
.Group(parentGroupName)
.locationUpdated(location);
hubContext.Clients
.Group("admin-live-buses")
.locationUpdated(location);
});
}
}
}
Here we subscribe to:
bus:location:all
That is easier than subscribing to every bus channel separately.
For parents, we push only to this SignalR group:
bus-{busId}
So if location is for bus 101, only clients in group bus-101 get the update.
Start Redis Subscriber on App Start
In Global.asax.cs:
using SchoolBusTracking.Services;
using System.Web.Http;
namespace SchoolBusTracking
{
public class WebApiApplication : System.Web.HttpApplication
{
private static RedisToSignalRBridge _redisBridge;
protected void Application_Start()
{
GlobalConfiguration.Configure(WebApiConfig.Register);
_redisBridge = new RedisToSignalRBridge();
_redisBridge.Start();
}
}
}
Parent Mobile App SignalR Client
Parent opens tracking screen for bus 101.
const connection = $.hubConnection("https://api.yourdomain.com");
const busHub = connection.createHubProxy("busTrackingHub");
busHub.on("locationUpdated", function(location) {
console.log("Bus location updated:", location);
updateBusMarker(
location.Latitude || location.latitude,
location.Longitude || location.longitude
);
});
connection.start().done(function() {
busHub.invoke("JoinBusGroup", 101);
});
function updateBusMarker(latitude, longitude) {
console.log("Move bus marker to:", latitude, longitude);
// Google Maps / Mapbox / native map marker movement logic.
}
Parent App Initial Load + Real-Time Flow
When parent opens the app:
Step 1: Call REST API to get latest location.
Step 2: Show marker on map immediately.
Step 3: Open SignalR connection.
Step 4: Join bus group.
Step 5: Receive live location updates.
Step 6: Move marker every time update comes.
Why both REST API and Pub/Sub?
Because Redis Pub/Sub messages are not stored.
If parent opens the app after the driver has already sent updates, the parent will not receive old Pub/Sub messages.
So use:
GET /api/parent/bus/101/location
for current state.
Then use SignalR for live updates.
Complete Redis Command View
When trip starts:
SET bus:active-trip:101 501 EX 14400
When location update comes:
SET bus:location:latest:101 "{location-json}" EX 1800
Publish to bus-specific channel:
PUBLISH channel:bus:location:101 "{location-json}"
Publish to admin channel:
PUBLISH bus:location:all "{location-json}"
Parent access mapping:
SADD parent:buses:501 101
Parent permission check:
SISMEMBER parent:buses:501 101
Final Architechture:
Driver Mobile App
|
| Every 5 seconds
| POST /api/driver/location
v
ASP.NET Web API
|
| GET bus:active-trip:{busId}
|
| SET bus:location:latest:{busId}
|
| PUBLISH channel:bus:location:{busId}
|
| PUBLISH bus:location:all
v
Redis
|
| Pub/Sub message received
v
RedisToSignalRBridge
|
| Push to SignalR group: bus-{busId}
v
Parent Mobile App
|
| locationUpdated event
v
Move bus marker on map
Conclusion
The driver app sends GPS coordinates every 5 seconds to the backend. The backend validates the active trip, stores the latest location in Redis using
bus:location:latest:{busId}, and publishes the same location event to Redis Pub/Sub channelchannel:bus:location:{busId}and global admin channelbus:location:all.The parent app does not connect to Redis directly. It connects to the backend using SignalR. The backend has a Redis subscriber that listens to location events and forwards them to the correct SignalR group, such as
bus-101.Redis key-value storage gives us the latest bus location, while Redis Pub/Sub gives us live real-time updates.