Published at

Server-Sent Events Explained

Server-Sent Events Explained

Discover the power of Server-Sent Events (SSE) to build dynamic, real-time web applications. Learn how to leverage SSE to create engaging user experiences with live data updates

Authors
Table of Contents

what is server sent events

Server-Sent Events (SSE) is a server push technology that allows servers to send real-time updates to web clients over a single, long-lived HTTP connection

SSE vs Web Sockets

Server-Sent Events (SSE) provide one-way communication from server to client, while WebSockets enable two-way, full-duplex communication where both the client and server can send and receive messages

Example Project

Let’s build a small group chat application to demonstrate how to implement Server-Sent Events (SSE). For this example, we will use Hono and Bun (although you may use Node.js if you prefer). All of the code is available on my GitHub repository; feel free to check it out.

To enable Server-Sent Events (SSE) for our application, you can use the streamSSE function in Hono, which sets the appropriate Content-Type: text/event-stream header for us. This informs the browser that the response will be an SSE, allowing it to handle the incoming stream of events appropriately.

app.get('/sse', async (c) => {
  return streamSSE(c, async (stream) => {
    while (true) {
      const message = `hello clint`
      await stream.writeSSE({
        data: message,
        event: 'greeting',
      })
      await stream.sleep(3000)
    }
  })
})

If you save the code and navigate to your browser at the /sse endpoint, you will observe that the server sends a new message saying “hello client” every 3 seconds. This is because the server is continuously writing new SSE events with this message at 3-second intervals. This demonstrates the real-time update capability of Server-Sent Events, where the server pushes updates to the client without the client having to request them repeatedly.

What are data and event properties? Data is the information we send to the client, and an event is used to listen for specific events from our client using the EventSource API

The code below handles the /sse endpoint for a group chat application. Each user must provide a unique id and a name. Here’s a breakdown of how it works:

  1. Parameter Check: When a user connects to the /sse endpoint, the server checks for the id and name parameters. If either is missing, it sends a message back to the client, requesting both id and name.
  2. Stream Setup: If both id and name are provided, the server initializes a Server-Sent Events (SSE) stream using the streamSSE function. This function takes a Hono context and a callback function with a stream object.
  3. Send Current Users: Upon connection, the server sends a list of all currently connected users to the new user.
  4. Notify Existing Users: The server notifies all existing users about the new user who just joined the chat.
  5. Handle User Leaving: When a user leaves the chat, the server removes the user from the list and notifies all remaining users that the user has left.
  6. Periodic Updates: The server continuously sends updates to all connected users every 2 seconds, listing all current users.

This implementation ensures real-time updates and notifications for user connections and disconnections in a group chat using Server-Sent Events (SSE).

const users: { id: string; name: string; stream: SSEStreamingApi }[] = [];

app.get("/sse", async (c) => {
  const userID = c.req.query("id");
  const name = c.req.query("name");

  if (!userID || !name) {
    return c.text("id and name are required");
  }

  return streamSSE(c, async (stream) => {
    stream.writeSSE({
      event: "all-users",
      data: JSON.stringify(users.map((u) => ({ id: u.id, name: u.name }))),
    });

    users.forEach(({ stream }) => {
      stream.writeSSE({
        data: JSON.stringify({
          id: userID,
          name,
        }),
        event: "new-user",
      });
    });

    users.push({ id: userID, name, stream });

    stream.onAbort(() => {
      users.splice(
        users.findIndex((u) => u.id === userID),
        1
      );
      users.forEach((user) =>
        user.stream.writeSSE({
          data: JSON.stringify({ id: userID, name }),
          event: "user-leave",
        })
      );
      console.log(`user ${userID} disconnected`);
    });

    while (true) {
      await stream.writeSSE({
        data: users
          .map((u) => ({
            id: u.id,
            name: u.name,
          }))
          .join("\n"),
        event: "users",
        id: String(Math.random()),
      });
      await stream.sleep(2000);
    }
  });
});

Next, we have the /message endpoint, which takes a message from the client along with the user’s name and id, and distributes it to all currently connected users. Here’s how it works:

  1. Parse Request Body: When a user sends a message to the /message endpoint, the server parses the request body to extract the message, id, and name.
  2. Broadcast Message: The server then broadcasts the message to all connected users using the writeSSE method.
  3. Return Confirmation: Finally, the server responds to the client with the same message content as a JSON object to confirm that the message has been received and processed.

Here’s the implementation:

app.post("/message", async (c) => {
  const body = (await c.req.parseBody()) as {
    message: string;
    id: string;
    name: string;
  };

  users.forEach((user) => {
    user.stream.writeSSE({
      data: JSON.stringify(body),
      event: "message",
    });
  });

  return c.json(body);
});

In this implementation:

  • The server first parses the request body to extract the message, id, and name.
  • It then loops through all connected users and sends the new message to each of them using the writeSSE method.
  • The data field of the SSE contains the message, user ID, and name in JSON format.
  • The server responds to the client with the same message content as a JSON object, confirming that the message has been successfully sent to all users.

Next, we have the following endpoint to serve static files from the ./public directory:

app.get(
  "*",
  serveStatic({
    root: "./public",
  })
);

This endpoint handles any incoming GET requests and serves static files from the ./public directory. For example, if you have an index.html file in ./public, it will be served when the client accesses the root URL ("/")

Now we are done with the server part.

To handle the client-side functionality, we can use the following implementation:

function getUserName() {
  const name = prompt("What is your name?");
  if (!name) return getUserName();
  return name;
}

const me = {
  name: getUserName(),
  id: crypto.randomUUID(),
};

handleSSEConnection();

function handleSSEConnection() {
  const eventSource = new EventSource(`/sse?id=${me.id}&name=${me.name}`);

  eventSource.addEventListener("message", (event) => {
    const data = JSON.parse(event.data);
    showMessage(data);
  });

  eventSource.addEventListener("new-user", (event) => {
    const user = JSON.parse(event.data);
    console.log(user);
    showUser(user);
    showUserJoinAndLeaveMessage(user, true);
  });

  eventSource.addEventListener("all-users", (event) => {
    const data = JSON.parse(event.data);
    showUsers(data);
  });

  eventSource.addEventListener("user-leave", (event) => {
    const data = JSON.parse(event.data);
    users = users.filter(({ id }) => data.id !== id);
    usersContainer?.childNodes.forEach((node) => node.remove());
    showUsers(users);
    showUserJoinAndLeaveMessage(data, false);
  });
}

In this implementation:

  • getUserName(): Prompts the user for their name. If no name is provided, it prompts again until a name is entered.
  • me: An object representing the current user, containing their name and a unique ID generated using crypto.randomUUID().
  • handleSSEConnection(): Establishes a connection to the server using the EventSource API and listens for various events such as message, new-user, all-users, and user-leave.

Each event handler processes the received data accordingly:

  • "message": Parses the incoming message data and calls showMessage(data) to display it.
  • "new-user": Parses the data for a new user, logs it, and calls showUser(user) and showUserJoinAndLeaveMessage(user, true) to display the new user and a join message.
  • "all-users": Parses the data for all users and calls showUsers(data) to display the list of users.
  • "user-leave": Parses the data for a user who left, removes them from the list, and calls showUsers(users) and showUserJoinAndLeaveMessage(data, false) to update the user list and display a leave message.

These event handlers—message, new-user, user-leave—are mapped to the event property from the server side, which is set using the writeSSE method in the server-side code

Summary

In this article, we demonstrated how Server-Sent Events (SSE) can be used for real-time updates in a group chat application with Hono, Bun, and Vanilla JS. SSE provides a one-way communication channel from the server to the client, ideal for pushing live updates without the complexity of WebSockets.

Key Points:

  • Server Setup: We utilized Hono’s streamSSE function to manage user connections, broadcast messages, and provide real-time updates.
  • Client Handling: The EventSource API was used to receive and display messages, manage user lists, and handle user join/leave events.

This setup ensures that users receive timely updates and notifications, creating an interactive and responsive chat experience

Sharing is caring!