Content, State, Communication

WebSockets


Learning Objectives

  • You know what WebSockets are and what they are used for.
  • You know how to manage implement an application that uses WebSockets for bidirectional communication between the client and the server.
  • You know how to send messages to multiple clients using WebSockets.

WebSockets

WebSockets provide a way for both the client and server to exchange messages in real time. Unlike server-sent events — which allow the server to push messages to the client in one direction — WebSockets enable two-way communication. They operate over the WebSocket Protocol, which runs on top of TCP, rather than HTTP.

Similar to server-sent events, opening a WebSocket connection is done over HTTP. However, instead of server-sent events that create a persistent connect with a specific text format, the request involves headers asking to upgrade the connection to a WebSocket connection. If the server accepts the upgrade request, the connection is established, and the client and server can exchange messages in real time. This is shown in Figure 1 below.

Figure 1 — Establishing a WebSocket connection starts with an HTTP request that is upgraded to a websocket connection.

WebSockets are implemented using the WebSocket API, which provide methods for sending and receiving messages, as well as for opening and closing connections. Contrary to server-sent event connections, browsers do not automatically try to re-establish a WebSocket connection if it is closed. This means that the client must handle re-establishing the connection if needed.

Frameworks and libraries often also provide abstractions on top of the WebSocket API, which can simplify the process of managing connections and messages.

If you are interested in building cross-platform experiences, see Device-Agnostic Design with Flutter and Dart. The course also briefly discusses connecting devices and communicating using WebSockets.

WebSocket support

Based on data from caniuse.com, approximately 98% of clients have WebSocket support. To provide support for clients that do not have WebSocket support, one can use a polyfill such as SockJS and a corresponding server-side library.


Loading Exercise...

Messaging with WebSockets

Hono comes with a WebSocket helper that makes it easier to work with WebSockets. A server that uses the WebSocket helper to create an endpoint that listens to incoming messages and thanks the sender for each message is implemented as follows.

// imports etc
import { upgradeWebSocket } from "@hono/hono/deno";
// other stuff

app.get(
  "/api/pings/ws",
  upgradeWebSocket((c) => {
    return {
      onMessage(event, ws) {
        ws.send(`Thanks for ${event.data}!`);
      },
    };
  }),
);

// export app

On the client, a WebSocket connection is established using the WebSocket interface. The following example shows a client that connects to the above API, listens to incoming messages, and allows sending messages to the server. Whenever a message is received, it is added to the reactive messageFromServer variable, which is then shown to the user.

<script>
  let message = $state("");
  let messageFromServer = $state("");
  let connection;

  const openConnection = async () => {
    connection = new WebSocket("/api/pings/ws");

    connection.onmessage = (event) => {
      messageFromServer = event.data;
    };
  };

  const sendMessage = async () => {
    connection.send(message);
    message = "";
  };

  if (!import.meta.env.SSR) {
    openConnection();
  }
</script>

<p>WS example!</p>

<input type="text" bind:value={message} />
<button onclick={() => sendMessage()}>Send</button>

<p>Message from server: {messageFromServer}</p>

Reopening a WebSocket connection

Browsers do not automatically try to re-establish a closed WebSocket connection. If the client-side application needs to be connected to the server at all times, the client must handle re-establishing the connection if needed. Broadly speaking, there are two types of cases where the connection might need to be re-established: when the connection is closed and when there is an error.

For both, the WebSocket API provides events that can be used to handle these cases. The onclose event is triggered when the connection is closed, and the onerror event is triggered when there is an error. The following example shows how to handle re-establishing a WebSocket connection in a Svelte component.

// ...
  let connection = "";

  const reconnect = () => {
    setTimeout(() => {
      connection.close();
      openConnection();
    }, 500);
  };

  const openConnection = async () => {
    connection = new WebSocket("/api/pings/ws");

    connection.onmessage = (event) => {
      messageFromServer = event.data;
    };

    connection.onclose = () => {
      reconnect();
    };

    connection.onerror = () => {
      reconnect();
    };
  };
// ...

Now, when the connection is closed or there is an error, the reconnect function is called. The function has a small delay that helps avoiding a situation where the connection is re-established too quickly. This is important, as for some implementations, re-establishing the connection too quickly might lead to a situation where the connection is closed again immediately.

When developing applications, the hot reload functionality of the development server might cause the WebSocket connection to be re-established multiple times, which can be seen as multiple open connections. This comes from a new connection being formed when the Svelte component is re-rendered.

Loading Exercise...

Sending messages to multiple clients

Similarly when looking at server-sent events, we can keep track of the connections to the server, and send messages to all of the connected clients. This would be done by storing the connections in a set, and sending the message to all of the connections.

Hono’s WebSocket helper provides additional functions onOpen, onClose, and onError that are useful for this. The onOpen method can be used to store the connection in a set, while the onClose and onError methods can be used to remove the connection from the set.

The example below showcases how to implement a server that keeps track of the connections.

// ...
const sockets = new Set();

app.get(
  "/api/pings/ws",
  upgradeWebSocket((c) => {
    return { // asd
      onOpen: (event, ws) => {
        ws.send("Hello from server!");
        sockets.add(ws);
      },
      onMessage(event, ws) {
        // ...
      },
      onClose: (event, ws) => {
        sockets.delete(ws);
        ws.close();
      },
      onError: (event, ws) => {
        sockets.delete(ws);
        ws.close();
      },
    };
  }),
);

// ...

Now, the onMessage function could be used to send the incoming messages to all connected clients.

// ...
      onMessage(event, ws) {
        for (const socket of sockets) {
          socket.send(`Message: ${event.data}`);
        }
      },
// ...

With this, whenever one of the clients sends a message to the server, the server sends the message to all of the connected clients.

Chat with websockets

With websockets, our chat application can handle both sending and receiving messages through the same connection. The above example, where the server sends messages from the client to all connections would directly serve as the server functionality for a chat.

However, we might also want to include the earlier functionality, where the messages were sent as JSON documents. To account for this, we could modify the endpoint as follows.

const sockets = new Set();

app.get(
  "/api/ws-chat",
  upgradeWebSocket((c) => {
    return {
      onOpen: (event, ws) => {
        sockets.add(ws);
      },
      onMessage(event, ws) {
        const message = JSON.parse(event.data);
        message.date = Date.now();

        for (const socket of sockets) {
          socket.send(
            JSON.stringify(message),
          );
        }
      },
      onClose: (event, ws) => {
        sockets.delete(ws);
        ws.close();
      },
      onError: (event, ws) => {
        sockets.delete(ws);
        ws.close();
      },
    };
  }),
);

On the client, the functionality would be also similar to the earlier examples.

<script>
  let message = $state("");
  let messages = $state([]);
  let connection = "";

  const reconnect = () => {
    setTimeout(() => {
      connection.close();
      openConnection();
    }, 500);
  };

  const openConnection = async () => {
    connection = new WebSocket("/api/ws-chat");

    connection.onmessage = (event) => {
      const newMessage = JSON.parse(event.data);
      messages = [newMessage, ...messages];
      messages = messages.slice(0, 10);
    };

    connection.onclose = () => {
      reconnect();
    };

    connection.onerror = () => {
      reconnect();
    };
  };

  const sendMessage = async () => {
    connection.send(JSON.stringify({ message }));
    message = "";
  };

  if (!import.meta.env.SSR) {
    openConnection();
  }
</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>

Assigning names to chatters

In the course Device-Agnostic Design, one of the applications for chatting also assign names to the chatters. This is not done on the client, but on the server.

To concretely assign names to chatters, we would need to modify the server to create and keep track of the names of the chatters. One possibility would be to create a map that maps the connections to names, and use this information at the time of the connection, where the server could decide a name for the chatter and associate it with the connection.

Concretely, this could look as follows.

const sockets = new Set();
const socketsToNames = new Map();

app.get(
  "/api/ws-chat",
  upgradeWebSocket((c) => {
    return {
      onOpen: (event, ws) => {
        sockets.add(ws);
        socketsToNames.set(ws, `User ${Math.floor(1000 * Math.random())}`);
      },
      onMessage(event, ws) {
        const name = socketsToNames.get(ws);
        const message = JSON.parse(event.data);
        message.date = Date.now();
        message.message = `${name}: ${message.message}`;

        for (const socket of sockets) {
          socket.send(
            JSON.stringify(message),
          );
        }
      },
      onClose: (event, ws) => {
        sockets.delete(ws);
        socketsToNames.delete(ws);
        ws.close();
      },
      onError: (event, ws) => {
        sockets.delete(ws);
        socketsToNames.delete(ws);
        ws.close();
      },
    };
  }),
);

Now, whenever a user connects to the server, the server assigns a random name to the user. This name is then used to identify the user in the chat.

Alternative options could include the user providing a name, or the server assigning a name based on some other criteria.

Next steps for Web Sockets

One of the challenges with web sockets is that the amount of data or messages that one can send to a web socket (or receive from a web socket) is not limited. This means that one could, in principle, overload a web socket. Web sockets have a property bufferedAmount which provides information on the amount of data queued to send but not yet transmitted, which could be monitored to maintain a stable pace of messages.

One potential solution for this is the WebSocketStream API, which is currently under development.


Loading Exercise...