Dennis Tretyakov

Server-Sent Events — cost-efficient real time updates for web apps

Most web apps rely on JSON (occasionally XML) over HTTP. Client sends conventional GET, POST, PUT, etc. requests and receives response. However, sooner or later apps grow to the point when client must be notified about something by server. Typical solutions in this case are either WebSocket which comes with quite a high price, either some polling or long polling which don’t provide the best UX and is not easy to maintain.

Server-Sent Events (SSE) — a protocol designed to address that problem.

  • The backend implementation is very simple, and doesn’t require any special framework.
  • All the modern browsers support this protocol for more than a decade already, which includes browser native client.
  • It works over HTTP, therefore doesn’t require any additional investment to infrastructure.
  • Provides out-of-the box reconnection and event id tracking.

Yet at least in my experience it is unfairly unpopular.

In this article

How does SSE work? #

SSE establishes long-lasting HTTP connection. This is simplex (one way) connection, that allows server to send events to client in real time. Client can only listen to connection, it cannot send any data to server.

Events are sent in a simple SSE protocol defined text based format.

Before going forward, I’d like to emphasize that it is HTTP based protocol. What it means — that you won’t need any extra investment in infrastructure and security to use it, while WebSocket for example comes with a lot of challenges.

To be fair, on the same topic, I need to mention that as working over HTTP is a benefit, it also has a price. SSE consumes HTTP connection from browsers connection pool. As you might know, browser has a limit of concurrent connections per domain. What means that amount of SSE connections per domain is limited as well as having multiple connections can reduce app performance. For example atm Google Chrome has limit of 6 connections per domain.

Basic SSE example #

In case of issues refer to git repository example.

Client (browser) side #

const eventSource = new EventSource('/sse');

eventSource.onmessage = function(event) {
    const messageContent = event.data
    console.log(`Message received: `, messageContent);
};

// eventSource.close(); // to be invoked to disconnect

Once connected, even source will start firing onmessage event for events received from server. Whenever the connection is lost EventSource will keep trying to re-connect until eventSource.close() is called.

There are 3 more fields on event source:

  • onopen - fires when connection established, including if connection was re-established after reconnection.
  • onerror - fires on connection error, including every failed re-connection attempt.
  • addEventListener - to add even listener for custom events, will be explained later.

Server side (dotnet) #

In this very basic example, server will send a plain text message with current time to the client every second.

app.MapGet("/sse", async ctx =>
{
    var hosting = ctx.RequestServices.GetRequiredService<IHostApplicationLifetime>();
    using var requestOrServerAbort = CancellationTokenSource.CreateLinkedTokenSource(
        ctx.RequestAborted,
        hosting.ApplicationStopping);

    ctx.Response.Headers.Append("Content-Type", "text/event-stream; charset=utf-8");    ctx.Response.Headers.Append("Cache-Control", "no-cache");
    do
    {
        await ctx.Response.WriteAsync(            $"data: Server time {DateTime.Now}\n\n",            ctx.RequestAborted);
    } while (await SleepUntilNextMessage(requestOrServerAbort.Token));
});

async Task<bool> SleepUntilNextMessage(CancellationToken token)
{
    try
    {
        await Task.Delay(TimeSpan.FromSeconds(1), token);
        return true;
    }
    catch (TaskCanceledException)
    {
        return false;
    }
}

So, what happens in there.

Should be obvious but, just in case — the path "/sse" can be whatever you want.

Once the route triggered there are certain headers to be set.

  • Conent-Type: text/event-stream - that is required for browser to understand how to process response stream.
  • Cance-Control: no-cache - to prevent browser and proxies from caching the response.

Once headers are sent, we can start sending messages. In this example we send simple plain text message telling client server time.

There are two things to pay attention to. Message starts with data: prefix and ends with double new line \n\n. The data is event field name, colon (:) separates field name from field value. First new line character (\n) indicates end of field value, second new line character (\n) end of event payload.

Protocol payload #

There are several fields and event may have. Fields are separated by new line character (\n). Empty line indicates end of event. The field line format is same as http headers fieldName: field content where name and value separated by colon (:).

All fields are optional.

Example payload

event: DAILY_JOKE
data: I asked my North Korean friend how it was there, he said he couldn't complain.
id: 17

Let’s take a deeper look on each.

data #

data - the only field used in previous example. In previous example we were sending single line text messages. The protocol allows to send multiline message by repeating data field multiple times.

await response.WriteAsync("data: first line\n");
await response.WriteAsync("data: second line\n");
await response.WriteAsync("data: third line\n");
await response.WriteAsync("\n"); // end of event

However, I’d say the most common use case — is to send JSON without indentation, which is a single line.

In case if you are wondering about space between colon and content. 1 space will be trimmed. In other words "data:hello" and "data: hello" will result the same "hello" string. More than one spaces will be preserved, for example "data: hello" will result " hello". Same rule applies to other fields.

event #

event - the other field event may have. Indicates the name of event.

It is optional, however if specified must precede data field.

In the basic example, on client side, we used eventSource.onmessage to subscribe for server events. The eventSource.onmessage will be triggered for server events without name specified, or with name being set to "message". Using custom event name, you’ll need to use eventSource.addEventListener("MY_EVENT_NAME", hanler) to handle custom events.

server example

await response.WriteAsync("event: MY_EVENT_NAME\n");
await response.WriteAsync("data: custom event content\n");
await response.WriteAsync("\n"); // end of message

client example

eventSource.addEventListener("MY_EVENT_NAME", (e) => {
	console.log("MY_EVENT_NAME handled", e);
});

There are NO way to subscribe for ANY (*) event using event source. The most common approach is to use JSON content with custom event type field, handle all events using single handler and branch the logic based on custom field.

id #

id - event id field. String. As mentioned above SSE provides mechanism for Event Id tracking as well as automatic reconnection. Typically, the last field in event payload.

EventSource will keep tracking of last received id and in case of connection failure, it will keep trying to reconnect the same endpoint sending last received id value as Last-Event-ID header. On client side you can read the value from event object passed into event handler.

In a CORS setup, you might need to whitelist Last-Event-ID header

eventSource.onmessage = function(e) {
	const lastEventId = e.lastEventId
	console.log(lastEventId)
}

Mind that field name of event object is called lastEventId for a reason. If server will not send id for current event, lastEventId will be have last value received from server. For example if server sends event with id: 3 and after sends series of events without id field, all of them will have lastEventId equal to 3.

example of server sending event with id:

await response.WriteAsync("data: Hello\n");
await response.WriteAsync("id: 1\n");
await response.WriteAsync("\n"); // end of event paylaod

server example handling Last-Event-Id on reconnection

app.MapGet("/sse", async ctx =>
{
    ctx.Response.Headers.Append("Content-Type", "text/event-stream; charset=utf-8");
    ctx.Response.Headers.Append("Cache-Control", "no-cache");

    if (ctx.Request.Headers.TryGetValue("Last-Event-ID", out var lastEventId))
    {
        // this is reconnection
        // last event id is available
    }

    // ..
 }

There are no way to specify lastEventId for EventSource instance. In case if you need to pass lastEventId using new instance of EventSource (for example if you had it preserved in localStorage) - the only way is passing it as custom query string parameter. During reconnection EventSource will send last event id in as header, but for obvious reasons it will not remove it from query parameters.

retry #

retry - amount of milliseconds to wait between reconnection retry attempts, typically set the moment connection is established. There are no way to define it using JavaScript API. If not set by server browser will use it’s own retry policy.

Example setting reconnection retry timeout

app.MapGet("/sse", async (HttpContext ctx, CancellationToken ct) =>
{
    ctx.Response.Headers.Add("Content-Type", "text/event-stream");
    ctx.Response.Headers.Add("Cache-Control", "no-cache");

    await response.WriteAsync("retry: 5000\n"); // 5 seconds
    await response.WriteAsync("\n"); // end of message

    // todo: continue event streaming
});

Mind that browser may also have it’s own min/max limits, so setting limit for example as small as 200ms will be ignored and browser will use it’s own min value. Unfortunately I was unable to find more detailed specs on that topic.

If you want to have own retry policy, you can do so by closing current EventSource on error, and creating new one to reconnect. However, in this case you’ll need to track event Id manually as well, if it’s in use.

Crafting SSE payload in C# #

The payload structure described above can be summarized in one simple class responsible for formatting event payload.

public class SsePayload
{
    public string? Id { get; init; }
    public string? EventName { get; init; }
    public string? Data { get; init; }
    public int? RetryInterval { get; init; }

    public override string ToString()
    {
        var sb = new StringBuilder();

        if (RetryInterval != null)
            sb.Append("retry: ").Append(RetryInterval).Append('\n');

        if (EventName != null)
            sb.Append("event: ").Append(EventName).Append('\n');

        if (Data != null)
            foreach (var line in Data.Split('\n'))
                sb.Append("data: ").Append(line).Append('\n');

        if (Id != null)
            sb.Append("id: ").Append(Id).Append('\n');

        sb.Append('\n'); // finalize event

        return sb.ToString();
    }
}

There are few things to notice.

\n character at the end of every field line. (Don’t use Environment.NewLine, that will corrupt payload on windows, as windows new line is \r\n).

Data property is split to lines.

Whatever fields we wrote, we add one more \n character to indicate end of event payload.

Let’s try to put it in use, in another example.

Detailed SSE example #

I’ll try to combine almost most stuff mentioned above in a single example. This one will be a bit more complex and will behave in following matter:

  • on initial connection server will configure retry interval to 5 seconds and will send multiline welcome message.
  • on re-connection server will send yet another message multiline message.
  • server will send events to client every second with incremental id starting with 1.
  • server will send same plain text message reporting server time.
  • each 5th message will have CUSTOM_EVENT event name, therefore will have separate handler on client. It will also use JSON payload.
  • server will fail occasionally (approximately once in 10 seconds) - that will terminate current connection and will force EventSource to reconnect.

To implement it on server side I’ll use SsePayload class described above.

Server side (dotnet) #

In case of issues refer to git repository example.

Mind that for simplicity I use SendEvent local function defined at the top of handling delegate.

// used to generate occasional errors
var random = new Random();

app.MapGet("/sse", async ctx =>
{
    // local function writes input SsePayload to response
    Task SendEvent(SsePayload e) => ctx.Response.WriteAsync(e.ToString(), ctx.RequestAborted);
    var hosting = ctx.RequestServices.GetRequiredService<IHostApplicationLifetime>();
    using var requestOrServerAbort = CancellationTokenSource.CreateLinkedTokenSource(
        ctx.RequestAborted,
        hosting.ApplicationStopping);

    ctx.Response.Headers.Append("Content-Type", "text/event-stream; charset=utf-8");
    ctx.Response.Headers.Append("Cache-Control", "no-cache");

    var eventCounter = 0;

    if (ctx.Request.Headers.TryGetValue("Last-Event-ID", out var lastEventId))
        eventCounter = int.Parse(lastEventId.ToString());

    if (eventCounter == 0)
    {
        await SendEvent(new SsePayload
        {
            RetryInterval = 5000,
            Data = "Connection established\n" +
                   "Retry interval is set to 5 seconds\n"
        });
    }
    else
    {
        await SendEvent(new SsePayload
        {
            Data = "ReConnection successful\n" +
                   $"Serving messages since #{eventCounter}\n"
        });
    }

    while (await SleepUntilNextMessage(requestOrServerAbort.Token))
    {
        eventCounter++;

        if (eventCounter % 5 != 0)
        {
            await SendEvent(new SsePayload
            {
                Id = eventCounter.ToString(),
                Data = $"#{eventCounter} Server time: {DateTime.Now}"
            });
        }
        else
        {
            await SendEvent(new SsePayload
            {
                Id = eventCounter.ToString(),
                EventName = "CUSTOM_EVENT",
                Data = JsonSerializer.Serialize(new
                {
                    CustomEventType = "Hello",
                    Message = $"Hello, this is custom event #{eventCounter}"
                })
            });
        }

        if (random.Next(10) == 0)
            throw new Exception("Unexpected error, to abort connection");
    }
});

async Task<bool> SleepUntilNextMessage(CancellationToken token)
{
    try
    {
        await Task.Delay(TimeSpan.FromSeconds(1), token);
        return true;
    }
    catch (TaskCanceledException)
    {
        return false;
    }
}

Client (browser) side #

const eventSource = new EventSource('/sse');

eventSource.onmessage = function (event) {
    const message = event.data
    console.log(`MESSAGE`, message);
};

eventSource.addEventListener("CUSTOM_EVENT", (event) => {
    const messageObject = JSON.parse(event.data)

    console.log(`CUSTOM_EVENT`, messageObject)
})

// triggered when connection established
eventSource.onopen = function (event) {
    console.log(`CONNECTED`, event)
}

// triggered on connection error
// EventSource will keep trying to reconnect indefinitely (unless eventSource.close() is called)
eventSource.onerror = function (event) {
    console.error(`ERROR`, event)
}

// eventSource.close();

Authentication #

The only authentication method native EventSource truly supports is cookies.

To use headers, you’ll need a custom client for example EventSourcePolyfill available via npm package event-source-polyfill.

It is not a good idea to use query string for authentication.

Cookies #

Same Origin

When SSE route is under the same origin as app, as in provided examples, cookies will be sent out of the box on both initial connection and reconnection requests. If that’s your case — you are all good.

Cross origin

When app origin differ from SSE route origin, for example your app is hosted on https://www.myapp.com and SSE endpoint is located at https://api.myapp.com/sse, you can pass withCredentails flag to EventSource constructor, to allow it to use cookies on that origin.

The cookie must be set by the same origin as SSE endpoint.

example

const eventSource = new EventSource("https://api.myapp.com/sse", {withCredentials:true})

Headers #

Since native implementation supports only cookies, there is a custom implementation available via npm package event-source-polyfill. The package provides class EventSourcePolyfill that has API that matches native EventSource. Except it allows pass some more options to a constructor, including custom headers.

example

const headers = {
	"Authorization": "..."  // somewhat can update this token when it's refreshed
}

const eventSource = new EventSourcePolyfill("/sse", { headers })

Another Important difference between EventSorucePolyfill and native EventSource. The EventSourcePolyfill will send lastEventId as query string parameter, not header! You can also adjust the name of query string parameter (default is lastEventId)

const headers = {
	"Authorization": "..."  // somewhat can update this token when it's refreshed
}

const eventSource = new EventSourcePolyfill("/sse", {
	headers,
	lastEventIdQueryParameterName: "Last-Event-Id"
})

Few words about the library.

It is quite popular — almost 600k weekly downloads atm.

It haven’t been much updated within last two years, however it could be because the protocol is quite primitive and no updates were necessary, still the library is widely used nad there are no real issues opened on github.

It has aggressive comment on top of description “Пожалуйста не используйте эту обосранную библиотеку!” — what translates from russian as “Please don’t use that shit covered library”, however the library is quite a time tested.

Query String #

example

const eventSource = new EventSource("https://api.myapp.com/sse?accessToken=...")

The problems:

First — is a security exposure. There lots of reasons why access token should not be passed as query string. For example query string is part of URL that can be stored in logs, on application server, proxies, etc.

Second — typical access token has some expiration date, say 15 minutes. If the connection will be dropped after token has expired, the EventSource will keep trying to reconnect using the same URL, in other words using the same expired token.

SSE vs other approaches #

vs. WebSocket #

As was mentioned above, SSH works over HTTP, therefore whatever security measures you have (or planning to have) on your API, all of it will get applied to SSE.

And this is the main advantages over WebSocket. WebSocket use HTTP only for initial handshake thereafter the connection is switched to WebSocket protocol, leaving you with necessity to deal with Cross-Site WebSocket Hijacking (CSWSH), WSS implementation and most importantly DDoS exposure.

On the other side SSE operates one-way. Also having multiple connections per domain, will consume connection pool, therefore connection count is limited and affects app performance.

Another, maybe not so obvious downside of SSE in comparison to WebSocket is what I would call an “inability to adjust subscription”. Typically, using WebSocket client sends messages to server indicating to what topics it is or is not interested, for examples what channels to listen to in a chat application or in a more simple app, depending on object is displayed on app screen it might be interested only on changes regarding that object. Even tho it is not a part of protocol itself, it’s implied by bidirectional communication. SSE works one-way, there are no such option. Of course there can be some workarounds to use separate HTTP requests to implement similar behavior, but that’s out of SSE capabilities.

To summarize it all, feature-wise SSE is not a competitor to WebSocket. However, for very many apps having a single connection that doesn’t need adjustments is enough, and in this case SSE shines with it cost efficiency and simplicity in both infrastructure and codebase.

vs. Long-Polling #

IMHO from any perspective SSE supersedes long polling in a web app.

Long-Polling — is not a protocol, it’s a popular approach, therefore each implementation is unique. Even thought it might sound not a big deal, in a long term it results in a certain amount of maintenance of a typically not well documented solution.

The server side of a solution might seem quite simple and even similar, however client side implementation will have to address a lot of problems that SSE does out of the box. Even with all the problems addressed and ignoring future maintenance costs, we are still left with continuous redundant HTTP re-connections.

Apart from updates/notifications long-polling frequently used for fetching large data streams, even for that purpose, SSE not does more than just provide client, it also addresses the questions like batch size and id tracking problem.

vs. Polling #

In comparison to Long-polling, polling have all the reasons to exist. Any production environment would require at least two node setup for backend. What means that SSE, WebSocket or even Long-Polling would require back-end to have some pub/sub system (Kafka, Rabbit, Redis, etc.) in order to implement real time updates.

Certain projects, on early stages, cannot afford it, therefore comes polling, that polls some database state, but there are two things to keep in mind.

  • Polling cannot give real time updates, there will always be a time gap what affects UX.
  • With userbase growing, polling will become a performance problem, affecting both application and state (e.g. database) performance.

Useful resources #

Hope it was helpful. Here will be useful links.

Repository links

Npm package

© 2020 - 2024, Dennis Tretyakov