Real-Time Communication in Go: A Practical Guide to WebSockets

Real-Time Communication in Go: A Practical Guide to WebSockets


Introduction

WebSockets are one of the most widely used protocols in modern web applications, enabling real-time communication between clients and servers. Whether for real-time chat, instant notifications or collaborative tools, WebSockets help us to create a persistent and reliable connection, eliminating the necessity for continuous HTTP polling.

Unlike traditional HTTP, which follows a request-response model, WebSockets provide a full-duplex communication, allowing data to flow in both directions simultaneously. This reduces latency and improves responsiveness, making WebSockets an ideal choice for real-time applications. In this article, we will explore how to implement a simple WebSockets server using Go’s standard library by implementing a simple chat service with rooms functionality.

Understanding how websockets work

Before diving into implementation, let’s first understand how WebSockets work and how they differ from conventional HTTP communication. Both protocols operate using TCP, and WebSockets actually rely on HTTP to start a connection. However, the key difference lies in what happens after the connection is established. Here is the main flow of a WebSocket connection:

  • Handshake: The client first sends an HTTP request to the server with a special Upgrade header, indicating that it wants to switch from a HTTP connection to a WebSockets connection;
  • Protocol Upgrade: After that, if the server accepts the request, it responds with an HTTP 101 Switching Protocols status code, confirming that the connection was upgraded successfully;
  • Persistent Communication: Once the connection is established, the client and server can transfer data continuously in both directions without the overhead of repeated HTTP requests.

Why would we need to use websockets?

You may be wondering: “Why should we use websockets?”. The primary benefit is efficiency. That’s because, unlike traditional HTTP, where the client must repeatedly send requests to check for new data (polling), WebSockets provide instant updates by maintaining a persistent connection. This leads to:

  • Reduced Network Overhead: No need to repeatedly establish new connections or send unnecessary HTTP headers.
  • Lower Latency: Since data can be pushed from the server instantly, WebSockets are ideal for real-time applications like chat systems, live notifications, and collaborative tools.
  • Full-Duplex Communication: Unlike HTTP’s request-response cycle, WebSockets allow both the client and server to send data independently at any time.

For applications that require frequent data updates based on multiple users — like chats, collaborative applications (Notion, Google Docs…) — this approach significantly enhances performance by enabling real-time communication.

However, the main trade-off here is that we introduce statefulness into the application. Since each user needs to maintain a persistent connection with the server, the application must manage active connections, making horizontal scale more complex. While this introduces challenges, solutions like load balancing and distributed session management can help scale WebSockets based applications efficiently.

Setting up a server in Go

Let’s start by setting up our WebSocket server. First, we need to install a module that will help us handle WebSocket connections. Run the following command in your terminal:

A little side note here, i’m using this lib for simplicity purposes, if you want something to use in a production environment, try using something more robust, like gorilla websocket.

go get golang.org/x/net/websocket

And now we can start implementing our server, first we will need to create a struct to hold our existing rooms and connections:

type Server struct {
	rooms map[string][]*websocket.Conn

}

func NewServer() (*Server) {
	return &Server{
		rooms: make(map[string][]*websocket.Conn)
	}
}

Here’s what this struct does: • The rooms field is a map that associates room names with a list of WebSocket connections. This allows us to track which connections belong to which rooms. • The NewServer function serves as a constructor, initializing an empty rooms map.

Initializing the WebSocket Server

Now we can create a new Server in our main function and define an HTTP handler function for our connections:

import (
	"log"
	"net/http"

	"golang.org/x/net/websocket"
)

func main() {
	server := NewServer()

	http.Handle("/ws", websocket.Handler(server.HandleWS))

	log.Println("Server running at :3000")
	log.Fatal(http.ListenAndServe(":3000", nil))
}

Breaking Down This Code:

  • We instantiate a new Server using NewServer().
  • We register a WebSocket handler at the /ws endpoint. This will allow clients to connect using ws://localhost:3000/ws.
  • The http.ListenAndServe(“:3000”, nil) function starts an HTTP server that listens on port 3000.

At this point, our WebSocket server is up and running! It closely resembles a standard HTTP server, except that it now supports WebSocket connections.

Setting up the handler

To handle the requests, we’re going to implement a function that receives a new connection and adds it to a new room, or a existing room. But, to do this, i’ll break it down in three different parts, the handler itself, a read-loop function and a broadcast function. It should look like this:

HandleWS function

func (s *Server) HandleWS(conn *websocket.Conn) {
	room := conn.Request().URL.Query().Get("room")
	if room == "" {
		conn.Close()
		return
	}

	if _, ok := s.rooms[room]; !ok {
		s.rooms[room] = []*websocket.Conn{}
	}

	s.rooms[room] = append(s.rooms[room], conn)

	s.readLoop(conn, room)
}

This function receives a new WebSocket connection as its parameter, connand use it to get the room name, we get this name from the url query param, in this line:

room := conn.Request().URL.Query().Get("room")

Just like an HTTP server, we can include query params, in our case, our URL should look something like this: domain.com/ws?room=<name> — Or localhost:3000 if we’re running it locally. If this param isn’t defined or if it’s empty, we just close the connection, as you can see here:

if room == "" {
	conn.Close()
	return
}

If we connect successfully and define a room in the param, we check if that room was already created before, if not we create it. After creating, we add the new connection inside this room and pass this connection to a readLoop function:

if _, ok := s.rooms[room]; !ok {
	s.rooms[room] = []*websocket.Conn{}
}

s.rooms[room] = append(s.rooms[room], conn)

s.readLoop(conn, room)

I’ve created this readLoopfunction, just for cleaning purposes, if you want, you can implement all the functionalities inside this handler.

ReadLoop function

Let’s take a look inside this function:

func (s *Server) readLoop(conn *websocket.Conn, room string) {
	buf := make([]byte, 1024)
	for {
		n, err := conn.Read(buf)
		if err != nil {
			log.Printf("Error reading message: %+v", err)

			if err == io.EOF {
				break
			}
			continue
		}

		msg := buf[:n]
		s.broadcast(conn, msg, room)
	}
}

As the name sugests, it’s an infinite loop with a buffer that reads for a new message in the connection and broadcast it to all connections in the room, or break the loop in case of any errors, let’s break it down:

buf := make([]byte, 1024)

Here we create a buffer to store our message before properly handling it here:

n, err := conn.Read(buf)
if err != nil {
	log.Printf("Error reading message: %+v", err)

	if err == io.EOF {
		break
	}
	continue
}

In this snippet, we read from the connection and store it in the buffer, this method conn.Read() returns the numbers of bytes read (n) and an error err, which is used to show us if we could read from the connection successfully. After reading, we check if any errors occurred in this if statement:

if err != nil {
	log.Printf("Error reading message: %+v", err)

	if err == io.EOF {
		break
	}
	continue
}

We log the error and check for a specific error, the io.EOF, meaning that there is no more input to read, this is the only case that we break the loop and close the connection, if any other errors occurs, we just log it and ignore jumping to the next iteration. You must remember that we are inside an infinity loop scope.

If no errors occurs, we proceed to broadcast the message in this snippet of code:

msg := buf[:n]
s.broadcast(conn, msg, room)

Broadcast function

Let’s see how the `broadcast function works:

func (s *Server) broadcast(conn *websocket.Conn, msg []byte, room string) {
	for _, c := range s.rooms[room] {
		if c == conn {
			continue
		}

		if _, err := c.Write(msg); err != nil {
			log.Printf("Error writing message: %+v", err)
		}
	}
}

This is another simple function, we just loop through the room that that connection is — Remember that s.rooms is a map[string][]*websocket.Conn? So, by accessing a specific room, we receive a slice of connections in that room.

Then, we check if the connection is the connection sending the message:

if c == conn {
	continue
}

If so, we continue to the next iteration. This serves to avoid sending a message to the connection itself. After checking this, we proceed to send the message to other connections in the room:

if _, err := c.Write(msg); err != nil {
	log.Printf("Error writing message: %+v", err)
}

This snippet write a message into the connection, initialize and assign a variable err, after that, check if no error occurred — A simple go syntax sugar here. If any errors occurs, we log just log it.

Creating clients

Let’s create a simple html client here, if you prefer, you can run the javascript directly in your browser console, but let’s use some HTML here to create, we can create an index.html file with this content:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>WebSocket Client</title>
</head>
<body>
  <h1>WebSocket Client for room1</h1>
  <script>
    // Connect to the WebSocket server in room1
    const socket = new WebSocket("ws://localhost:3000/ws?room=room1");

    // When the connection is open
    socket.addEventListener("open", function () {
      console.log("Connected to WebSocket server");
    });

    // When a message is received
    socket.addEventListener("message", function (event) {
      console.log("Message from server:", event.data);
    });

    // When the connection is closed
    socket.addEventListener("close", function () {
      console.log("WebSocket connection closed");
    });

    // Send a test message every 5 seconds
    setInterval(() => {
      if (socket.readyState === WebSocket.OPEN) {
        socket.send("Hello from client n");
      }
    }, 5000);
  </script>
</body>
</html>

The main part of this code is in the <script> tag, that’s where, we create a new connection in a room with name room1:

const socket = new WebSocket("ws://localhost:3000/ws?room=room1");

And configure what we want to do when some events in the connection occurs. More specifically a connection is opened, when the connection closes and when a new message is received:

// When a connection is opened
socket.addEventListener("open", function () {
  console.log("Connected to WebSocket server");
});

// When a message is received
socket.addEventListener("message", function (event) {
  console.log("Message from server:", event.data);
});

// When the connection is closed
socket.addEventListener("close", function () {
  console.log("WebSocket connection closed");
});

After that we send a test message every 5 seconds, that should broadcast to all connections in the same room, and if a connection isn’t in the same room, it should not reach it:

// Send a test message every 5 seconds
setInterval(() => {
  if (socket.readyState === WebSocket.OPEN) {
	socket.send("Hello from client n");
  }
}, 5000);

We can run this code in multiple browser tabs changing this test message to another one to test, and changing the room name in the WebSocket connection to test the roons functionality.

Conclusion

In this article, we explored the fundamentals of WebSockets and demonstrated how to build a basic WebSocket server in Go. We covered everything from the protocol’s handshake mechanism to creating rooms for client communication, along with a simple browser-based client to test the connection.

This implementation is great for learning or prototyping, but if you’re building a production-ready system or if you want to improve the code in this article even more, consider addressing connection cleanup, concurrency safety (e.g., sync.Map or sync.Mutex for managing rooms), and replacing the standard WebSocket package with gorilla/websocket for better performance and support.

WebSockets are a powerful tool for real-time applications, and with Go’s simplicity and performance, you’re well-equipped to scale this foundation into a more robust solution.