Server-Sent Events (SSE) support for ASP.NET Core

The web socket protocol is currently the most popular one for pushing data to browsers, however it's not the only one. The Server-Sent Events (SSE) is a very interesting alternative which can provide better performance for specific use cases.

What is SSE

The Server-Sent Events is a unidirectional (server to browser) protocol for streaming events. The protocol delivers text-based messages over a long-lived HTTP connection. It also has built in support for events identification, auto-reconnection (with tracking of last received event) and notifications through DOM events. Its biggest advantage is high performance as events can be pushed immediately with minimum overhead (there is an already open HTTP connection waiting, which thanks to text-based messages can utilize HTTP compression mechanisms). A considerable limitation is general lack of support for binary streaming (but JSON or XML will work nicely).

Why use SSE

In general web sockets can do everything that Server-Sent Events can and more as they provide bidirectional communication. There is also broader browser support (93%) for web sockets. So why would one consider the SSE (assuming bidirectional isn't a requirement, or the client to server communication is occasional and can be done in a REST style)? The fact that it runs over a long-lived HTTP connection is the game changer here. In case of web sockets we are talking about custom TCP based protocol which needs to be supported by the server and entire infrastructure (proxies, firewalls etc.), any legacy element along the way may cause an issue. There are no such issues for SSE, anything that speaks HTTP will speak SSE and the aspect of browser support (87%) can be addressed with polyfills. Taking into consideration this and notably lower latency, Server-Sent Events is a very compelling choice for scenarios like stock ticker or notifications.

Bringing SSE to ASP.NET Core

One of key concepts behind ASP.NET Core is modular HTTP request pipeline which can be extended through middlewares, so I'm going to create one for Server-Sent Events. But first some prerequisites are needed.

The middleware will require an abstraction for representing a client. As previously stated SSE runs over a long-lived HTTP connection, which means that channel for communication with client is HttpResponse instance. The abstraction will simply wrap around it.

public class ServerSentEventsClient
{
    private readonly HttpResponse _response;

    internal ServerSentEventsClient(HttpResponse response)
    {
        _response = response;
    }
}

Also there is a need for some kind of service which will serve as bridge between the middleware and the rest of application. Its primary goal will be managing the collection of connected clients. Below is a simple implementation based on ConcurrentDictionary.

public class ServerSentEventsService
{
    private readonly ConcurrentDictionary<Guid, ServerSentEventsClient> _clients = new ConcurrentDictionary<Guid, ServerSentEventsClient>();

    internal Guid AddClient(ServerSentEventsClient client)
    {
        Guid clientId = Guid.NewGuid();

        _clients.TryAdd(clientId, client);

        return clientId;
    }

    internal void RemoveClient(Guid clientId)
    {
        ServerSentEventsClient client;

        _clients.TryRemove(clientId, out client);
    }
}

With those elements in place the middleware can be created. It will have two responsibilities: establishing the connection and cleaning up when client closes the connection.

In order to establish the connection the middleware should inspect the Accept header of incoming request, if its value is text/event-stream it means that client is attempting to open SSE connection. In such case the Content-Type response header should be set to text/event-stream, headers should be send and connection needs to be kept open.

The clean up part requires detecting that client has closed the connection. This can be done by waiting on CancellationToken available through HttpContext.RequestAborted property. An important thing to note here is that closed connection can only be detected when sending new event. This limitation is often being solved by sending dedicated heartbeat event which client should simply ignore.

public class ServerSentEventsMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ServerSentEventsService _serverSentEventsService;

    public ServerSentEventsMiddleware(RequestDelegate next, ServerSentEventsService serverSentEventsService)
    {
        _next = next;
        _serverSentEventsService = serverSentEventsService;
    }

    public Task Invoke(HttpContext context)
    {
        if (context.Request.Headers["Accept"] == "text/event-stream")
        {
            context.Response.ContentType = "text/event-stream";
            context.Response.Body.Flush();

            ServerSentEventsClient client = new ServerSentEventsClient(context.Response);
            Guid clientId = _serverSentEventsService.AddClient(client);

            context.RequestAborted.WaitHandle.WaitOne();

            _serverSentEventsService.RemoveClient(clientId);

            return Task.FromResult(true);
        }
        else
        {
            return _next(context);
        }
    }
}

With the connection management part in place the sending part can be added. The message format in SSE is a very simple one. The basic building blocks of every message are fields which general format looks like this: <FieldName>: <FieldValue>\n. There are three types of fields (well in fact four as there is an additional one for controlling client reconnect interval):

  • id - The identifier of the event.
  • event - The type of the event.
  • data - A single line of data (entire payload of message is represented by one or more adjacent data fields).

Only the data field is required and the entire message is being terminated by additional new line (\n).

public class ServerSentEvent
{
    public string Id { get; set; }

    public string Type { get; set; }

    public IList<string> Data { get; set; }
}

internal static class ServerSentEventsHelper
{
    internal static async Task WriteSseEventAsync(this HttpResponse response, ServerSentEvent serverSentEvent)
    {
        if (!String.IsNullOrWhiteSpace(serverSentEvent.Id))
            await response.WriteSseEventFieldAsync("id", serverSentEvent.Id);

        if (!String.IsNullOrWhiteSpace(serverSentEvent.Type))
            await response.WriteSseEventFieldAsync("event", serverSentEvent.Type);

        if (serverSentEvent.Data != null)
        {
            foreach(string data in serverSentEvent.Data)
                await response.WriteSseEventFieldAsync("data", data);
        }

        await response.WriteSseEventBoundaryAsync();
        response.Body.Flush();
    }

    private static Task WriteSseEventFieldAsync(this HttpResponse response, string field, string data)
    {
        return response.WriteAsync($"{field}: {data}\n");
    }

    private static Task WriteSseEventBoundaryAsync(this HttpResponse response)
    {
        return response.WriteAsync("\n");
    }
}

The above helper can be used in order to expose the send method on the client abstraction.

public class ServerSentEventsClient
{
    ...

    public Task SendEventAsync(ServerSentEvent serverSentEvent)
    {
        return _response.WriteSseEventAsync(serverSentEvent);
    }
}

Last step is exposing send method at the service level - it should perform send for all connected clients.

public class ServerSentEventsService
{
    ...

    public Task SendEventAsync(ServerSentEvent serverSentEvent)
    {
        List<Task> clientsTasks = new List<Task>();
        foreach (ServerSentEventsClient client in _clients.Values)
        {
            clientsTasks.Add(client.SendEventAsync(serverSentEvent));
        }

        return Task.WhenAll(clientsTasks);
    }
}

We can say that this gives us what project managers like to call minimum viable product. After extending pipeline with the middleware and adding service to services collection (as singleton) we can send events from any desired place in the application. In case of a need for exposing more than one endpoint a derived services can be created, added to services collection and passed to the respective middlewares during initialization.

I've made an extended version (support for reconnect interval, extensibility point for auto-reconnect and extensions for service and middleware registration) available on GitHub and as a NuGet package.

SSE at work

I've also created a demo application which utilizes the above components, it can be found here. The application exposes two SSE endpoints:

  • /see-heartbeat which can be "listened" by navigating to /sse-heartbeat-receiver.html. It sends an event every 5s and is implemented through an ugly background thread.
  • /sse-notifications which can be "listened" by navigating to /notifications/sse-notifications-receiver. Sending events to this endpoint can be done by navigating to /notifications/sse-notifications-sender.

It might be a good starting point for those who would like to play with what I've shown here.