Server Sent Events 101
Updated On
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 SSE 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 the pub/sub architecture wherein you have subscribers subscribing to specific "named" events and emitters (publishers) which 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()}`)
CAUTION
The EventSource API doesn't allow you to pass custom headers natively. You have to rely on polyfills or query parameters to pass additional context about the client. Learn more about the limitations of the EventSource API here.
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 you 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 setup an interval which 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 know 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.