paint-brush
Server Sent Events 101: A Guideby@murtuzaalisurti
167 reads

Server Sent Events 101: A Guide

by MurtuzaAugust 22nd, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Server Sent Events (SSE) are a way to communicate with the client by keeping a persistent connection. Unlike websockets, the connection is unidirectional, i.e. only the server has the capability to send messages and the client just listens. SSEs can only transmit data in `text/event-stream` format.
featured image - Server Sent Events 101: A Guide
Murtuza HackerNoon profile picture

Server Sent Events (SSE), as the name suggests, are a way to communicate with the client by keeping a persistent connection in which the server sends text messages to the client whenever they are available. They are similar to websockets but, unlike websockets, the connection is unidirectional, i.e., only the server has the capability to send messages and the client just listens.


Another key difference between SSEs and websockets is that websockets use their own ws:// websocket protocol while SSEs use the HTTP protocol. Also, SSEs can only transmit data in text/event-stream format.

Sending Events From Server

In a basic nodejs (express) server, you can define an endpoint to allow subscriptions from clients and store them in a unique Set.


const clients = new Set();

const addSubscription = (client) => {
    clients.add(client);
    console.log(`Client ${client} connected`);
}

const removeSubscription = (client) => {
    clients.delete(client);
    console.log(`Client ${client} disconnected`);
}

app.get("/subscribe", (req, res) => {
    const client = new URLSearchParams(req.query).get("id") || crypto.randomUUID();
    
    addSubscription(client);

    // ...

    req.on('close', () => {
        removeSubscription(client);
    })
})


Once a subscription is added and stored in the Set, you must set these response headers with a status code of 200 to let the client know that this is a text/event-stream, keep-alive connection.


app.get("/subscribe", (req, res) => {
    const client = new URLSearchParams(req.query).get("id") || crypto.randomUUID();
    
    addSubscription(client);

    res.writeHead(200, {
        "Content-Type": "text/event-stream",
        "Connection": "keep-alive",
        "Cache-Control": "no-cache"
    });

    req.on('close', () => {
        removeSubscription(client);
    })
})


Now that the connection is set, you can send messages to the client in the EventStream format. That's it, you can now listen to these event streams using the EventSource API which I will talk about more later in this post.


app.get("/subscribe", (req, res) => {
    const client = new URLSearchParams(req.query).get("id") || crypto.randomUUID();
    
    addSubscription(client);

    res.writeHead(200, {
        "Content-Type": "text/event-stream",
        "Connection": "keep-alive",
        "Cache-Control": "no-cache"
    });

    res.write(`data: ${message}\n\n`);

    req.on('close', () => {
        removeSubscription(client);
    })
})


You can also ping the client at regular intervals by using setInterval.


setInterval(() => res.write(`data: ping\n\n`), 5000);


This is all good, but what if you want to send messages when something happens, either in the server or in the database? For that, you need to use event emitters in nodejs to fire a specific event and capture that event in our request handler to send a message to the client.

Event Emitters

Event emitters are a type of pub/sub architecture wherein you have subscribers subscribing to specific "named" events and emitters (publishers) that publish/emit the event based on some operation.


Here's a simple example of an event emitter in nodejs:


import { EventEmitter } from 'events';

class UpdateEvents extends EventEmitter {
    constructor () {
        super();
    }

    new (data) {
        this.emit('new', data);
    }
}

const updates = new UpdateEvents();

export default {
    updates,
    newUpdate: (data) => updates.new(data)
}


The new method in the UpdateEvents class is an event emitter method which emits the named event new. This is what fires the event. Then, we create an instance of the UpdateEvents class and export it for it to be used for listening to the new event. You can listen to the event anywhere in your application code using:


updates.on('new', (data) => {
    // do something with the data
})


This is really useful for your SSE endpoint. For example, if you want to send events from an operation/event in some other part of the application and not necessarily inside the request handler, then you can fire an event from different places in your code and listen to it in the SSE endpoint.


// in some other part of the application
newUpdate({ message: "Hello World" })

// ----------------------------------

// in the SSE endpoint
app.get("/subscribe", (req, res) => {
    const client = new URLSearchParams(req.query).get("id") || crypto.randomUUID();
    
    addSubscription(client);

    res.writeHead(200, {
        "Content-Type": "text/event-stream",
        "Connection": "keep-alive",
        "Cache-Control": "no-cache"
    })

    updates.on('new', (data) => {
        res.write(`data: ${message}\n\n`);
    })

    req.on('close', () => {
        removeSubscription(client);
    })
})

Subscribing to SSE Events From Clients

SSE Events are captured using the EventSource web API. You just have to pass the URL of the SSE endpoint to the EventSource API. You can't pass your own custom headers in the EventSource, so you have to rely on query parameters to pass additional context about the client.


const url = new URL(SSE_ENDPOINT, YOUR_API_BASE_URL)
const event = new EventSource(`${url.href}?id=${crypto.randomUUID()}`)


Then, listen to the messages which are sent by the server by using the onmessage event.


const url = new URL(SSE_ENDPOINT, YOUR_API_BASE_URL)
const event = new EventSource(`${url.href}?id=${crypto.randomUUID()}`)

event.onmessage = (e) => {
    console.log(e.data);
}

event.onopen = (e) => {
    console.log('connection opened');
}

event.onerror = (e) => {
    console.log(e);
}


What happens when the connection to the server is lost? In that case, the browser tries to reconnect automatically within a certain interval of time known as the retry interval. The default retry interval is ~3 seconds in the browser. However, you can specify your own retry interval by sending the value (in milliseconds) in a retry field with the server-sent message.


// server
res.write(`data: ${message}\n`);
res.write(`retry: ${retryInterval}\n\n`); // in milliseconds


Pro Tip - Know how to properly send messages using the EventStream format in this article by web.dev.


If you don't want to rely on the automatic reconnect provided by the browser or if it's not working for you, you can implement your custom retry mechanism yourself. Let me show you how.


let retryInterval = 6000;

function listenToEvents(retryAfter) {
    let isListening = false;

    const interval = setInterval(() => {
        if (!isListening) {
            isListening = true;

            const url = new URL(SSE_ENDPOINT, YOUR_API_BASE_URL);
            const event = new EventSource(`${url.href}?id=${crypto.randomUUID()}`);

            event.onmessage = (e) => {
                const payload = JSON.parse(e.data);
                // do something with the payload
                payload.retry && (retryInterval = payload.retry);
            }

            event.onerror = (e) => {
                clearInterval(interval);
                event.close();
                listenToEvents(retryInterval);
            }
        }
    }, retryAfter);
}

listenToEvents(1000); // initially, establish the connection in 1 second


First of all, you have to set up an interval that will keep checking if the connection is still alive or not. And the interval can be set to a custom value, or to the retry value you get from the server. This interval will be wrapped in a function named listenToEvents which will accept a retryInterval parameter and initialize a local variable named isListening.


This interval will keep creating new eventsource objects if the isListening variable is false. It's set to false by default but, it's set to true when establishing the connection, so only one eventsource object will be created at the first round of the interval.


If the connection is lost, the onerror event will be fired closing the event, clearing the current interval, and invoking the function listenToEvents recursively.

Conclusion

In this guide, you got to learn about server-sent events, event emitters, and the EventSource API. Server Sent Events are almost similar to websockets with some key differences. If you want to learn more about websockets, check out the WebSockets 101 guide.