Server-Sent Events
Learning Objectives
- You know what server-sent events are and what they are used for.
- You know how to create an application that uses server-sent events.
- You know how to send messages to multiple clients using server-sent events.
In the previous chapter, we looked into long and short polling. In both cases, the client initiates communication with the server, and the server responds to the client’s requests. In long polling, the server delays the response to wait for possible data, while in short polling, the client waits between requests.
One alternative to polling is server-sent events.
Server-sent events
Server-sent events is a technology that allows the server to push messages to the client, once the client has initiated a connection. The communication is one-way: the client cannot send messages to the server using the server-sent events connection.
Server-sent events are implemented using the Streams API that allows programmatic access to streams of data. To initiate a server-sent event connection, the client sends a request to the server, and the server responds with the Content-Type
header value text/event-stream
. The server then starts writing data to the response stream that is kept open.
Server-sent events are based on a persistent HTTP connection with a specific text format
text/event-stream
.
The data is written in a text-based format where each data entry is prefixed with data:
, which is followed by the actual data that is being sent to the client. Events are separated from each others with two line breaks.
One of the benefits of server-sent events is that the connection is kept open, and the server can push messages to the client without the client having to request them. This is particularly useful in cases where the server has information that the client should be aware of, but where the client does not know when the information is available.
This also means that, contrary to polling, there is no need to keep opening and closing connections, as the connection is kept open. Implementation-wise, the APIs used for server-sent events also have built-in functionality for handling reconnections in case the connection is lost.
Server-sent events with Hono
Hono comes with a helper function for server-sent events, which simplifies the creation of the event stream. Below, the helper is used to create an API endpoint with a stream that sends a message “ping” with the timestamp from the server each second.
// other imports
import { streamSSE } from "@hono/hono/streaming";
// ...
app.get("/api/pings/stream", (c) => {
return streamSSE(c, async (stream) => {
while (true) {
await stream.writeSSE({
data: `ping ${Date.now()}`,
});
await stream.sleep(1000);
}
});
});
// export app
The function streamSSE
takes two parameters: the context c
and a function that is called when the connection is established. The function that is passed to streamSSE
is called with a stream
object that has two methods: writeSSE
for writing data to the stream, and sleep
for waiting a given amount of time before continuing.
When the streamSSE
method finishes — in the above case never unless the server is stopped — the connection is closed.
On the client, the server-sent events are handled using the EventSource interface. The following code outlines how to open a connection to the server and how to handle incoming messages.
<script>
let ping = $state("");
const listenPings = () => {
const eventSource = new EventSource("/api/pings/stream");
eventSource.onmessage = (event) => {
ping = event.data;
};
};
if (!import.meta.env.SSR) {
listenPings();
}
</script>
<p>{ping}</p>
Above, the listenPings
function is called when the component is rendered on the client. The function creates a new EventSource
object that opens a connection to the server. The onmessage
event is used to handle incoming messages: whenever a new message comes in, the data in the message is stored in the ping
variable. As the ping
variable is reactive, the value is updated and shown on the screen.
If EventSource connects to a port or a server that differs from the host, CORS needs to be enabled on the server. Hono’s cors-middleware is sufficient for this.
Readable streams
Under the hood, the server-side functionality is built using the ReadableStream interface, which is a part of the Web Streams API. The ReadableStream
is used to define a function start
that is called when a connection is established, and a function cancel
, that is used to end the connection.
The following example outlines server-side functionality built using the ReadableStream
interface, which sends ping events every second, similar to the earlier example with Hono.
// imports
app.get("/api/pings/stream", (c) => {
const encoder = new TextEncoder();
let interval;
const body = new ReadableStream({
start(controller) {
interval = setInterval(() => {
controller.enqueue(encoder.encode(`data: ping ${Date.now()}\n\n`));
}, 1000);
},
cancel() {
clearInterval(interval);
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream",
},
});
});
// export app
Streaming events to multiple connections
In the above examples, the message-sending functionality is connection-specific, albeit the same for all connections. If we would wish to create functionality where the events are sent to all the clients that are connected to the server, we need to keep a collection of open connections. One approach would consist of a Set that provides easy adding and removing of objects, and allows iterating over objects within the set.
In such a case, we would modify the functionality so that whenever a connection is opened, the stream is added to the set, while when a connection is closed, the stream is removed from the set. One possible approach for this is outlined below — below, we use the properties aborted
and closed
to check if the stream is still open.
// imports etc
const streams = new Set();
app.get("/api/pings/stream", (c) => {
return streamSSE(c, async (stream) => {
streams.add(stream);
while (!stream.aborted && !stream.closed) {
await stream.sleep(1000);
}
streams.delete(stream);
});
});
// export app
Now, the streams that the server has opened are all stored in the streams
set. If we would wish to send a message to all the clients, we would iterate over all streams in the set and send the message to all of them. In the following, we expand on the previous, and send popular lyrics to all clients connected to the server.
// imports etc
const streams = new Set();
const content = ["Never", "gonna", "give", "you", "up"];
let i = 0;
setInterval(() => {
for (const stream of streams) {
console.log("Writing to a stream...", content[i]);
stream.writeSSE({
data: content[i],
});
}
i = (i + 1) % content.length;
}, 1000);
app.get("/api/pings/stream", (c) => {
return streamSSE(c, async (stream) => {
streams.add(stream);
while (!stream.aborted && !stream.closed) {
await stream.sleep(1000);
}
streams.delete(stream);
});
});
// export app
Now, when a client connects to the server, the server sends the lyrics of a popular song to all connected clients, one word at a time.
Chat with server-sent events
Let’s revisit our earlier chat application that used polling and modify it so that it uses server-sent events.
Server-side functionality
The key functionality is similar to the polling examples: there’s an API endpoint for sending a message, and an API endpoint for getting messages. However, the underlying approach has changed. Now, instead of storing the messages in a list, we do not store the messages at all. Instead, whenever a client sends a message to the server, the server sends the message to all connected clients.
This functionality is shown below. Note also that below, the data is sent as JSON.
// imports etc
app.post("/api/messages", async (c) => {
const message = await c.req.json();
message.date = Date.now();
for (const stream of streams) {
try {
stream.writeSSE({
data: JSON.stringify(message),
});
} catch (e) {
console.log(e);
}
}
return c.json({ status: "ok" });
});
app.get("/api/messages", (c) => {
return streamSSE(c, async (stream) => {
streams.add(stream);
while (!stream.aborted && !stream.closed) {
await stream.sleep(1000);
}
streams.delete(stream);
});
});
// export app
Client-side functionality
Similarly, on the client-side, the functionality has changed. Instead of polling the server for new messages, the client opens a connection to the server and listens for incoming messages. When a new message arrives, the message is added to the list of messages that are shown on the screen.
<script>
let message = $state("");
let messages = $state([]);
const sendMessage = async () => {
await fetch("/api/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ message }),
});
message = "";
};
const getMessages = async () => {
const eventSource = new EventSource("/api/messages");
eventSource.onmessage = (event) => {
const newMessage = JSON.parse(event.data);
messages = [newMessage, ...messages];
messages = messages.slice(0, 10);
};
};
if (!import.meta.env.SSR) {
getMessages();
}
</script>
<p>Chat!</p>
<input type="text" bind:value={message} />
<button onclick={() => sendMessage()}>Send</button>
<ul>
{#each messages as message}
<li>{message.message}</li>
{/each}
</ul>