theo-learns-gov0.1.0
02completeFeb 2026

ws-chat

A fullstack real-time chat app built with WebSockets.

GoWebSocketsgoroutineschannelsReactVite

Backend

The backend uses the coder/websocket implementation of WebSockets, following the approach from the gorilla/websocket chat example. The core of it is two structs — Client and Hub.

Each connected client is represented as:

go
type Client struct {
	hub    *Hub               // thing that ties it all together
	send   chan []byte        // channel for sending messages
	conn   *websocket.Conn   // websocket connection
	ctx    context.Context   // request context
	cancel context.CancelFunc // cancel func
}

The Hub ties all clients together and owns the broadcast logic:

go
type Hub struct {
	clients    map[*Client]bool // map of clients
	register   chan *Client     // channel to register client
	unregister chan *Client     // channel to unregister client
	message    chan []byte      // channel to broadcast message
}

Each client runs a read goroutine and a write goroutine. When a message arrives in the read goroutine, it gets forwarded to the hub's messagechannel. The hub then loops over all registered clients and drops the message into each client's send channel, where the write goroutine picks it up and pushes it down the WebSocket connection.

Storing ctx and cancel directly in the client struct makes cleanup clean: if one goroutine errors, calling cancel() signals the other to stop as well. No need to coordinate them separately.

Frontend

The frontend is a Vite app with Tailwind and shadcn. All the WebSocket logic lives in a useEffect that creates the connection and attaches event handlers. The interesting part is reconnection — it happens inside the onClose handler:

javascript
const closeEventListener = (event) => {
  setStatus("closed");
  connection.current = null;
  console.log(`OnClose: ${event.code} ${event.reason}`);
  setTimeout(
    () => {
      console.log("retrying");
      setRetryCounter((prev) => prev + 1);
    },
    (1 + retryCounter) * 1000,
  );
};

retryCounterserves two purposes: it's used for exponential backoff on the delay, and incrementing it re-triggers the effect, which creates a fresh connection. The cleanup function is where the distinction between "server closed the connection" and "component unmounted" is made:

javascript
return () => {
  console.log("running cleanup");
  if (connection.current) {
    console.log("running close");
    connection.current.removeEventListener("close", closeEventListener);
    connection.current.close();
    connection.current = null;
  }
};

When the component unmounts, the cleanup removes the closeEventListener before closing the connection. This means the retry logic never fires — the close was intentional. When the server drops the connection, the listener is still attached, so the backoff retry kicks in automatically.