6 minutes read

Realtime Cursor Tracking with .NET and React using SignalR

Realtime cursor tracking is a key feature in modern collaborative web apps, enhancing user interaction and experience. This series will guide you through creating production-ready cursor tracking using ASP.NET Core SignalR together with a React web app!

Three separate browsers getting combined into one by a SignalR server

We will cover the following over a series of articles:

Check back for updates to this series!

Why SignalR?

SignalR enables real-time communication between the server and clients. The traditional client-server model is only one-way: the client can send requests to the server, but the can't send information back on its own.

Over the years, there have been several ways for servers to communicate with clients:

  • WebSockets: Provides a simultaneous two-way communication channel over a single TCP connection, allowing both client and server to communicate using WebSockets.
  • Server-Sent Events: Uses a long-running HTTP connection to send messages from the server to the client.
  • Long polling: Keeps a connection open until the server has a message to send, then reopens the connection.

While WebSockets are the gold standard for real-time communication, they may not always be supported. SignalR shines by implementing all three methods to allow for graceful fallback, without requiring us to write any extra code. Let’s start by enabling clients to broadcast their cursor position to all other clients.

Adding SignalR to a .NET Web Project.

SignalR is part of the ASP.NET Core Shared framework, so setup is straightforward with no need for additional libraries.

We begin by creating a Hub. Hubs manage all the connections, groups, and messaging, allowing us to focus on client-server communication. Any method in a Hub is like an API endpoint, except that it is called by a client using the SignalR connection instead of the HTTP protocol.

Since it is a two-way connection, we can use the Hub to send messages back to clients using the SendAsync method.

A method that will allow Clients to broadcast their position will look something like this:


_10
public class CursorHub : Hub
_10
{
_10
public async Task UpdateCursorPosition(string user, int x, int y)
_10
{
_10
await Clients.All.SendAsync("CursorPositionUpdated", user, x, y);
_10
}
_10
}

Next, we have to register our CursorHub and assign it an endpoint so we can connect with it:


_11
// Program.cs
_11
_11
var builder = WebApplication.CreateBuilder(args);
_11
_11
builder.Services.AddSignalR(); // Add all services required by SignalR
_11
_11
var app = builder.Build();
_11
_11
app.MapHub<CursorHub>("/cursor"); // Map our hub endpoints and sockets to /cursor
_11
_11
app.Run();

SignalR Type Safety

Any messages sent to clients are defined by a string, which can lead to typos. Instead of using SendAsync, we can make our Hub strongly typed with Hub<T>, where T is an interface containing client method abstractions.

In our case, clients can receive a CursorPositionUpdated event that contains the username and, the x position and the y position. To type this we can create an interface called ICursorClient like this:


_10
public interface ICursorClient
_10
{
_10
Task CursorPositionUpdated(string user, int x, int y);
_10
}

Now we can change our CursorHub to this:


_10
public class CursorHub : Hub<ICursorClient>
_10
{
_10
public async Task UpdateCursorPosition(string user, int x, int y)
_10
{
_10
await Clients.All.CursorPositionUpdated(user, x, y);
_10
}
_10
}

Cursor API Design

Currently, our UpdateCursorPosition takes three parameters: a username, and the cursor's x and y positions. However, this does go against some important considerations we should make when designing the API surface of our CursorHub.

When designing any API that is consumed by another application, we should always prepare for breaking changes. This design doesn't accommodate changes without breaking compatibility. SignalR servers and clients will encounter errors when invoking a method with an incorrect parameter count.

To avoid this, we should replace our three parameters with a single custom object. Since adding additional properties to our custom object doesn’t change the number of parameters, it won’t be a breaking change. The same goes for any outgoing messages.

Let’s update our Hub to reflect these best practices. While we’re at it, let’s introduce a cursor color as we will be needing it in the future:


_10
// CursorModels.cs
_10
public record UpdateCursorMessage(string Color, string Name, int X, int Y);
_10
_10
// CursorHub.cs
_10
public async Task UpdateCursorPosition(UpdateCursorMessage message)
_10
{
_10
await Clients.All.CursorPositionUpdated(new {user, x, y});
_10
}

Updating Clients When Connecting or Disconnecting

Next, we'll address what happens when a client connects or disconnects from our Hub.

Currently, two issues arise:

  • A newly connected client only receives updates about other connected clients when they move their mouse. They start in an empty application, regardless of how many others are connected.
  • Disconnected clients still appear for other clients since there's no way to remove a cursor.

Both issues are easily fixed using SignalR’s OnConnectedAsync and OnDisconnectedAsync connection events. But before we can implement these, we should find a way to keep track of all connected clients.

State Management

Though SignalR manages all connections and the sending and receiving of messages, it does not maintain a list of all connected users or their groups, as noted in this issue.

Fortunately, keeping track of connected clients doesn't have to be complex. All SignalR connections are transient. If your backend crashes, all clients will need to reconnect anyway. So, we can keep our connected clients in-memory without any drawbacks

We'll use ConcurrentDictionary, expecting multiple hubs to access the dictionary simultaneously. We'll use the dictionary because each connection should be unique, and the ConnectionID helps avoid duplicate data.

It is important to note that SignalR Hubs are transient, so we shouldn’t keep any state in them. We could add a static variable to the Hub, but separating concerns and keeping state and logic separate is better.

Something like this InMemoryDataStore should suffice, but remember to register it as a singleton in your DI container:


_10
public class InMemoryDataStore : IDataStore
_10
{
_10
public ConcurrentDictionary<string, Cursor> Mice { get; set; } = [];
_10
}
_10
_10
public interface IDataStore
_10
{
_10
ConcurrentDictionary<string, Cursor> Mice { get; set; }
_10
}

Connection Events

With state management sorted, we can update all clients when another client joins or leaves the session. We can do this by overriding the OnConnectedAsync and OnDisconnectedAsync methods, respectively.

We want the following to happen:

  • When connecting:
    • Create a new Cursor object that contains all required information (like the x and y positions).
    • Store the object in our datastore.
    • Notify all other clients that a new client has connected.
    • Supply a list of already connected clients to the newly connected client.
  • When disconnecting:
    • Remove the object from our datastore.
    • Notify all other clients that a client has disconnected.

When implemented, it should look something like this:


_48
public class CursorHub(IDataStore dataStore) : Hub<ICursorClient>
_48
{
_48
// ... Other methods
_48
_48
public override async Task OnConnectedAsync()
_48
{
_48
var connectionId = Context.ConnectionId;
_48
// We will revisit this part in a future post,
_48
// but for now let's use Bogus to create some fake user data!
_48
var faker = new Faker();
_48
dataStore.Mice[connectionId] = new Cursor
_48
{
_48
ConnectionId = connectionId,
_48
Color = faker.Commerce.Color(),
_48
Name = faker.Name.FirstName(),
_48
X = 0,
_48
Y = 0
_48
};
_48
_48
var currentCursor = dataStore.Mice[connectionId];
_48
var otherCursors = dataStore.Mice.Values
_48
.Where(m => m.ConnectionId != connectionId).ToList();
_48
_48
// SignalR also has a 'Clients.Others' property,
_48
// but that doesn't work in 'OnConnectedAsync'
_48
await Clients.AllExcept(connectionId).CursorConnected(currentCursor);
_48
await Clients.Caller.ConnectionEstablished(otherCursors);
_48
_48
await base.OnConnectedAsync();
_48
}
_48
_48
public override async Task OnDisconnectedAsync(Exception? exception)
_48
{
_48
var connectionId = Context.ConnectionId;
_48
dataStore.Mice.Remove(connectionId, out _);
_48
await Clients.All.CursorDisconnected(connectionId);
_48
await base.OnDisconnectedAsync(exception);
_48
}
_48
}
_48
_48
// ICursorClient.cs
_48
public interface ICursorClient
_48
{
_48
// ... Other client methods
_48
Task CursorConnected(Cursor cursor);
_48
Task CursorDisconnected(string connectionId);
_48
Task ConnectionEstablished(List<Cursor> otherMice);
_48
}

Your SignalR setup now tracks connections and updates clients as users join or leave. In the next article, we'll connect this backend to a React frontend for real-time cursor tracking.

Quick Recap

In this article, I’ve shown how easy it is to get started with SignalR in ASP.NET Core. Next, we made our CursorHub strongly typed by implementing the Hub<T> and wired everything up to the ASP.NET WebApplication.

With everything running, we added the basic requirements for broadcasting the cursor information, like keeping track of all connections and handling clients connecting and disconnecting from our service.

You now should have a functional SignalR setup ready for real-time cursor tracking. In the next article, we’ll explore the React side of our little project.

What to read next:

I really hope you enjoyed this article. If you did, you might want to check out some of these articles I've written on similar topics.