Why do I prefer EventSource over WebSocket?

About two years ago, I joined the company I currently work for and started a new project using Node.js and React. During the process, there emerged a need to implement instant message pushing for various events, such as updating the user profile, sending a notification, logging in and out of the user, and even adding a new translation to the current language pack.

The first solution that springs to people’s minds, might be using WebSocket to do the job, after all, it’s the most popular and wide-known two-way communicational tool in the contemporary web development world. But I went the other way, instead, I chose another not-so-much-familiar mechanism, known as the Server-Sent-Events, or SSE.

The design of SSE is very simple and straightforward, it relies on the traditional HTTP protocol, and is primarily designed to replace the more traditional way of polling messages which is called Comet, or long-hold request for future responses. The mechanism uses very simple segments to indicate and transmit streaming messages in an HTTP response, including event , data , id and retry . And because the protocol is simple, it requires very little effort to implement the mechanism both on the server side and the client side. In fact, there is EventSource in the browser already, which is the standard client for SSE, and I had written a server-side tool called sfn-sse mapping the coding style of EventSource.

For EventSource, any new message is an event, we can simply use the standard API addEventListener to listen for them, unlike WebSocket we need to implement a message encoding and decoding method, EventSource comes with this out-of-the-box and is ready for use. For Node.js users, it’s even more familiar since we have already used on and emit hundreds of times in our code, and that’s how I designed sfn-sse. To send an event to the client, just emit(eventName, data) , and the client will receive it as addEventListener(eventName, handlerFunction) . Yes, it’s that simple.

Since I was new to React at that time, I’d like to keep things neat and clear, as simple as they can be. I’d hardly used any third-party tools and packages in a component, which might increase the complexity of the program. So using native APIs had become my primary choice when designing a new function or introducing a new technique into the project. If I chose WebSocket, I might have to design the scheme for emitting events and encoding/decoding messages, or import socket.io, both on the server side and the client side. But I didn’t really care about two-way communication since all regular queries were done by regular HTTP requests, I just needed the server to positively send updates when there were any.

The following code is a very simple example of how to use SSE in a project, it may not be able to express the real-world scenario, but it does demonstrate the essential spirit.

// server side
const express = require("express");
const cookieParser = require("cookie-parser");
const bodyParser = require("body-parser");
const SSE = require("sfn-sse").default;
const app = express();

/** @type {{ [userId: string]: SSE[] }} */
const sseStore = {};

app.use(cookieParser());
app.use(bodyParse.json());

app.use((req, res, next) => {
    const userId = req.cookies["userId"];
    req.isEventSource = SSE.isEventSource(req); // detect request from an EventSource

    if (req.isEventSource) {
        res.sse = new SSE(req, res);
        req.sessionId = res.sse.id;
        // `sse.id` is initiated when the connection is established, and will
        // persist even after reconnection because it is used as the
        // message's `id`, which is used as `Last-Event-Id` during
        // reconnection.
        (sseStore[userId] ??=[]).push(res.sse);

        res.once("close", () => {
            sseStore[userId] = sseStore[userId].filter(sse => sse !== res.sse);
        });
    }
});

app.post("/api/user/updateByAdmin", async (req, res) => {
    let user = await findUser(req.query["userId"]);
    const updates = req.body;
    user = await patchUser(user, updates);
    const sseSessions = sseStore[user.id];

    if (sseSessions?.length) {
        sseSession.forEach(sse => {
            sse.emit("user-update", user); // emit will auto-serialize data to JSON
        });
    }
});

app.listen(8080);
JavaScript
// client side
import React, { useState, useEffect } from "react";

function UserView() {
    const [sse, setSSE] = useState<EventSource>(null);
    const [user, setUser] = useCurrentUser(); // better use redux for this

    useEffect(() => {
        // Just connet, don't have to concern about the URL pathname, since
        // we automatically check whether the request is fired by EventSource
        // on the server. **BUT do care if it conflicts with the route.**
        const sse = new EventSource("http://localhost:8080");

        sse.addEventListener("user-update", event => {
            const user = JSON.parse(event.data);
            setUser(user);
        });

        return () => {
            sse.close();
        };
    }, []);

    return (
        <div>
            <div><strong>User Name</strong>: <span>{user.name}</span></div>
        </div>
    );
}
JSX

You can see how simple it is to use SSE both on the server side and on the client side. With this mechanism, I’ve already implemented a lot of push events in my project, making the web application fully acknowledged to server changes and responsive just in time.

Leave a comment