Consuming Server-Sent-Events in C#

A simple introduction into consuming Server-Sent-Events in C#

#.Net#SSE#C#

Why Server-Sent Events?

When consuming data from a backend, there are generally three main ways communication can be handled.

  1. Traditional HTTP call
  2. Websockets
  3. Server-Sent Events (SSE)

When you have a web application that needs regularly updated data one could of course just use polling. That is, periodically calling a REST endpoint on the backend to fetch the most current data. This works generally fine, but can lead to unnecessary traffic and has the added overhead of, in the worst case, having to establish a new connection every time a new request is made. On the other hand, this is a very simple to set up workflow, supported by virtually every platform

Websockets have also been around for a while. They are great when having to get updates in a very timely manner. You open a standing connection between the client and server and both can just push updates through the connection to each other. It does come with some added complexity. Not all reverse proxies just handle websockets out of the box in the same way they would plain HTTP calls. Often times the library support for websockets is also worse than that for plain old HTTP calls. Yet for certain applications it’s the only sensible choice.

And lastly we have Server-Sent Events. Its popularity is relatively recent and can be largely attributed to LLMs. Almost all LLM websites use SSE to “stream” in the answer text. And for good reason. Before the proliferation of SSE, for this type of use case often times a websocket connection was used. But to what end? The client only sends one request and doesn’t really have to update the backend. So the whole two way communication aspect of the Websocket is just adding unnecessary complexity.

How to SSE in C

In principle making a request to consume Server-Sent Events is very simple, because its almost just a simple HTTP request.

Two relevant changes from how you typically would make an HTTP request should be respected. Firstly when creating the HttpClient, set the default headers correctly for SSE i.e., text/event-stream

var client = new HttpClient();
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.ParseAdd("text/event-stream");

the next step is actually making the request. Here one more important configuration has to be made

using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, _cts.Token);
            response.EnsureSuccessStatusCode();

Traditionally the GetAsync method waits for the entire body of the request to have arrived. And for a normal HTTP call that makes perfect sense. But as we will effectively never have received the entire body as the new updates are just appended to it, we only care about the response headers to have arrived, this we use HttpCompletionOption.ResponseHeadersRead.

Next we need to actually read from the body.

using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);

while (!reader.EndOfStream)
{
    var line = await reader.ReadLineAsync();

    if (string.IsNullOrWhiteSpace(line))
        continue;

    if (line.StartsWith("data:"))
    {
        var data = line.Substring("data:".Length).Trim();
        //Handle your data here
        //e.g. 
        var update = JsonSerializer.Deserialize<Update>(data);
    }
}

We get a stream for the body and read from that until we reach the end of the stream. That will happen when the remote decides to close the connection. Other than that we just read the updates line by line. Here it start to become specific to how the data is being presented by the endpoint being used. In this example the lines containing data start with data: so we filter for those and parse the remaining part into whatever class we would want to have represent the data received from the backend.

And there we have it. A very basic implementation for receiving SSE in C#. For more complex use cases the parsing and handling different message types would require some more logic. But to get up and running this is all you need.