Skip to main content
  1. Posts/

Server-Sent Events

·6 mins

We will take a look at what are Server-Sent Events (SSE) and what this technology can offer. In the second part, I will present two examples, one with pure Node.js without any external packages and one with middleware support for SSE implemented with Express framework.


It can be tempting to use WebSocket whenever we want to communicate with a server without making a new HTTP request. Apart from WebSockets, there are better means of real-time communication. Using the right tool for the right job is the developer’s true virtue.

SSE workflow can be described as follows:

A client requests a webpage from a server using regular HTTP. The client receives the requested webpage and executes the JavaScript on the page which opens a connection to the server. The server sends an event to the client when there’s new information available.

What are Long-Polling, Websockets, Server-Sent Events (SSE) and Comet?

Key Features #

  • based on HTTP (text/event-stream)
  • data encoded in UTF-8
  • mono-directional (Server to Client)
  • built-in support for reconnection

Message Structure #

  • a message consists of field name and data separated by :
  • each field ends with a new line character \n
  • the message is terminated by another new line character \n
  • the message can contain multiple data fields which are then concatenated
  • SSE specifies four types of fields: data, event, id, retry

Types of messages #

data field holds a text message e.g. JSON. Here is an example of two messages, one carrying Hello world string and second containing JSON. It’s still sent as a text, so we need to parse this JSON on the client-side.

data: Hello World

data: {"machine": "Linear bounded automaton"}

data field (without event field) can be processed by .onmessage = event => {...}.

const eventSource = new EventSource('/sse');
eventSource.onmessage = event => {
    // Message handling, JSON parsing, ...
};

Setting event field allows us to listen for specific named events. The default event type is message.

event: ping
data: pong

event filed can be processed by .addEventListener('<event_name>', event => {...}.

const eventSource = new EventSource('/sse');
eventSource.addEventListener('ping', event => {
    // Message handling, JSON parsing, ...
});

In contrast to WebSocket when the client is disconnected, SSE automatically tries to reconnect in the background. You can modify reconnect time by sending retry field with value as a reconnect time in milliseconds.

retry: 10000
data: Set delay to 10 seconds

Upon successful reconnect browser sends to the server HTTP header Last-Event-ID which contains the last id of the message that the browser received.

id: 1
data: Message

Example of SSE - Node.js #

First, let’s create a simple HTML page (client.html) to showcase the SSE setup of the frontend. In the code below there is prepared element ul with id events such that we can easily add incoming messages to this list. The following script handles the connection to our server. It also registers onmessage event through which we receive messages from the server. The function creates a new element with the property innerHTML which is set to our message. Finally, the newly created element is added to DOM as a child to our ul list. We could close the connection by eventSource.close(); after we are done with receiving messages.

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>SSE</title>
</head>
<body>
    <ul id="events"></ul>

    <script>
        const eventSource = new EventSource('/sse');
        const events = document.getElementById('events');

        eventSource.onmessage = event => {
            const newEvent = document.createElement('li');
            newEvent.innerHTML = `Incoming Message: ${event.data}`;
            events.appendChild(newEvent);
        };
    </script>
</body>
</html>

Now we create Node.js HTTP server (server.js) serving two routes.

URLFunction
/returns client.html file
/ssehandles SSE connections from clients
const fs = require('fs');
const http = require('http');
const path = require('path');

let clientIndex = 0;
const clients = new Map();

const handleClient = (_, res) => {
    res
        .writeHead(200)
        .end(fs.readFileSync(path.join(__dirname, 'client.html')));
};

const setupClient = (req, res, id) => {
    console.log('Client connected');
    clients.set(id, res);
    req.on('close', () => {
        console.log('Client disconnected');
        clients.delete(id);
    });
};

const handleEvents = (req, res) => {
    res
        .writeHead(200, {
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive',
            'Content-Type': 'text/event-stream',
        })
        .write('\n');

    
    setupClient(req, res, clientIndex++);
};

const requestListener = (req, res) => {
    if (req.url === '/') handleClient(req, res);
    if (req.url === '/sse') handleEvents(req, res);
};

http
    .createServer(requestListener)
    .listen(3000);

setInterval(() => {
    const message = Date.now();
    console.log(`Sending message: ${message}`);
    for (const res of clients.values()) {
        res.write(`data: ${message}\n\n`);
    }
}, 2000);

We are starting an HTTP server on port 3000. Function requestListener based on requested URL returns our created HTML file or setups event stream.

In case of the function handleEvents, we need to specify HTTP header Content-Type with MIME type text/event-stream. After that function setupClient is called with additional parameter clientIndex incremented by 1. This way the variable is globally incremented and passed as an argument. It allows us to set a unique ID to every connected client. setupClient adds the client to our global clients map. id is used as a key and response object as a value. Lastly, we register a function that takes care of cases when the client disconnects so we can stop sending him messages.

At the bottom of the code, there is our "message creator". It sends the timestamp to every connected client every two seconds.

Now try it on your own! Copy these files into client.html, server.js respectively and run node server.js. Go to localhost:3000 and after about a few seconds you will see incoming messages from the server.

Example of SSE – Express.js #

Since we implemented SSE in pure Node.js and understand concepts behind it, now it’s time to look at already implemented solutions. SSE support is in this example provided by express-sse package.

const path = require('path');

const express = require('express');
const SSE = require('express-sse');

const app = express();
const sse = new SSE();

app.get('/', (_, res) => res.sendFile(path.join(__dirname, 'client.html')));
app.get('/sse', sse.init);

app.listen(3000);

setInterval(() => {
    const message = Date.now();
    console.log(`Sending message: ${message}`);
    sse.send(message);
}, 2000);

There is less code than in the previous version. The main difference is sse.init function. It internally sets all the necessary headers, creates helper functions for data formatting and registers the close event. As a result of its event-driven nature, it shouldn’t come to you as a surprise that it utilizes Node.js Events.

Scaling #

During my research of this technology, I stumbled upon people mentioning scaling issues.

SSE is subject to limitation with regards to the maximum number of open connections. This can be especially painful when opening various tabs as the limit is per browser and set to a very low number (6).

WebSockets vs Server-Sent Events.

Scaling SSE from the client-side perspective with an HTTP/1.1 server can be limited by the number of allowed connections by the browser. This problem is solved by HTTP2 multiplexing as one TCP connection is reused by another request.

In summary, server-sent events should be used in places where messages are pushed (rather than pulled, or requested) from a server to a browser (Dashboards, Game scores).

Resources #