Golang Project Structure

Tutorials, tips and tricks for writing and structuring Go code

Building Real-Time Applications With Go and WebSockets

Language

  • unknown

by

Real-time applications (RTAs) have become an important part of modern software development.

From live chat applications to stock market updates and online gaming, users now expect instant interaction with all kinds of services.

While traditional HTTP request-and-response models may work well for many projects, they often fall short when it comes to implementing real-time communication due to their greater latency and the overhead of continuous polling.

This is where WebSockets, a protocol designed for two-way communication over a single TCP connection, comes into play. It allows us to share data between users at fast speeds.

A professional woman working on a laptop while sitting cross-legged on a bed in a hotel room.
Many of the modern online applications that we use everyday rely on fast two-way communication between clients and servers.

In this blog post, we will explore how to build real-time applications in Go using WebSockets.

We’ll start by understanding the fundamentals of WebSockets, why they are ideal for real-time systems and then walk through a hands-on guide to building a fully functional WebSocket server in Go, alongside a JavaScript client.

What Are WebSockets?

WebSockets allow full-duplex communication between a client (often a web browser) and a server.

This just means that messages can be sent in both directions: either the client or the server can send a message to the other machine.

Unlike HTTP, which follows a request-and-response pattern, WebSockets maintain an open connection between the client and the server.

Once established, either party can send messages at any time, eliminating the need for repeated requests to check for new data (this older technique is known as polling).

How Do WebSockets Work?

The WebSocket connection starts with an HTTP request from the client.

If the server supports WebSockets, it responds with an HTTP 101 status code (which is more descriptively known as “Switching Protocols”).

After this, the connection is “upgraded” to a WebSocket connection.

This process is known as the handshake, because the two parties, client and server, have now welcomed each other.

Once the connection is established, it remains open. In other words, it is a persistent connection.

As mentioned previously, either the client or the server can send messages through this connection until one side closes it.

WebSocket messages are structured in frames, allowing for efficient data transmission.

These frames can carry different types of data, including text or binary data.

By maintaining a persistent connection and allowing bi-directional communication, WebSockets enable the sort of fast low-latency interactions that are important for real-time applications.

Why Use Go for Real-time Applications?

Go is a programming language that I use a lot, as you can see from all the blog posts I’ve written about it, so I don’t need much convincing, but, even so, it is true that the language possesses several features that make it particularly well suited for working with RTAs.

Excellent Support for Concurrent Programming

One of the primary reasons for choosing Go is the language’s approach to concurrency.

Go’s lightweight concurrency model, which makes use of goroutines, allows developers to handle thousands of WebSocket connections simultaneously without incurring the performance penalties that can be typically associated with traditional threading models.

Goroutines are extremely lightweight, requiring minimal memory to manage, making it feasible to scale real-time applications in ways that would be costly or inefficient with other programming languages.

This is especially important for applications like chat services, real-time dashboards or live data streaming, where multiple concurrent connections are needed.

Fast Runtime Speeds

Performance is another key factor that makes Go well suited for real-time applications.

Since Go is a compiled language, it offers much faster execution times compared to interpreted languages like Python or Ruby.

Additionally, Go’s efficient memory management, garbage collection and optimized execution engine all work to reduce latency, ensuring that real-time applications can handle high-throughput scenarios while still maintaining low response times.

This performance edge is critical when milliseconds matter, whether that be in financial services, on gaming platforms or for live monitoring systems.

Reliable Standard Library and External Packages

Go’s standard library further simplifies the development of real-time applications.

The "net/http" package provides a robust and easy-to-use framework for building web servers, including native support for WebSockets.

Although WebSocket support is enhanced via Gorilla’s popular "gorilla/websocket" package (which we will use in the next section when we introduce some code examples), Go’s strong HTTP handling makes integrating real-time communication over WebSockets straightforward.

This allows developers to focus more on building the application logic rather than dealing with the complex intricacies of networking, helping to reduce the overall development effort.

Moreover, the "gorilla/websocket" package, of course, offers seamless integration with Go’s concurrency primitives, further simplifying the high-performance handling of real-time bi-directional communication between servers and clients.

Creating a WebSockets Server in Go

Now that we understand the fundamentals of the WebSockets protocol, let’s dive straight into the task of building a WebSockets server in Go.

We can start by creating a new directory for our project, using the following Linux commands (or you can just do it with File Explorer, if you’re on Windows and prefer the point-and-click way):

mkdir websocket-project
cd websocket-project
go mod init websocket-project

Then, inside this directory, let’s create a file called main.go:

touch main.go

Now let’s add some code to that file, beginning by referencing the packages that we’re going to use later in the project:

package main

import (
    "fmt"
    "net/http"
    "github.com/gorilla/websocket"
)

We will use "net/http" for setting up the HTTP server and "gorilla/websocket" for managing the WebSocket connections.

Of course, "fmt" will be used to print messages to the screen.

Upgrading HTTP to WebSockets

As we discussed in an earlier section, WebSockets connections begin as HTTP connections, which then need to be “upgraded” to WebSockets.

The "gorilla/websocket" package provides an easy way to do this using the Upgrader struct.

So let’s create a global variable to store a customized version of this struct:

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    CheckOrigin: func(r *http.Request) bool {
        return true
    },
}

Note that the CheckOrigin function is set up here to allow connections from any origin — but, in production, this should be restricted for security reasons, permitting only connections coming from trusted sources.

Handling WebSocket Connections

Now we are ready to create the important function that actually handles our incoming WebSockets connections

func handleConnections(w http.ResponseWriter, r *http.Request) {
    ws, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer ws.Close()

    for {
        _, msg, err := ws.ReadMessage()
        if err != nil {
            fmt.Println("Error reading message:", err)
            break
        }
        fmt.Printf("Received message: %s\n", msg)
    }
}

In the function above, we begin by upgrading the HTTP connection to WebSockets and then we continuously listen for messages from the client within an infinite loop.

The ws.ReadMessage method waits for and reads the next incoming message, while ws.Close ensures that the connection is properly closed when the function exits and it is no longer needed.

Setting Up the HTTP Server

Now that we have our connection handler, we need to tie it to a running HTTP server.

This server will listen on a specific endpoint and handle any incoming WebSockets connections.

This is initialized within the main function below:

func main() {
    http.HandleFunc("/ws", handleConnections)

    fmt.Println("Server starting on :8080")

    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        fmt.Println("Server failed to start:", err)
    }
}

The main function sets up a basic HTTP server that listens on port 8080 and routes requests to the "/ws" endpoint through the handleConnections function.

Now that everything is set up, you should be able to run the server with the following command:

go run main.go

You should see the message that we wrote, "Server starting on :8080", output to the screen, which lets us know that the server is now ready to accept WebSocket connections and reminds us of the port that it’s running on.

Creating a WebSocket Client

Now that our server is up and running, we need to create a client, so that we can test the connection.

We’ll use a simple HTML file with some JavaScript that will connect to our WebSocket server.

Create a new file named index.html and add the following markup:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebSocket Client</title>
</head>
<body>
    <h1>WebSocket Client</h1>
    <input id="messageInput" type="text" placeholder="Enter a message" />
    <button onclick="sendMessage()">Send</button>

    <script>
        const ws = new WebSocket("ws://localhost:8080/ws");

        ws.onopen = function () {
            console.log("Connected to server");
        };

        ws.onmessage = function (event) {
            console.log("Message from server:", event.data);
        };

        function sendMessage() {
            const input = document.getElementById("messageInput").value;
            ws.send(input);
        }
    </script>
</body>
</html>

In the simple HTML client above, we establish a WebSocket connection to "ws://localhost:8080/ws" when the page loads.

Note that we explicitly use the "ws" protocol, instead of the ordinary "http" or "https" protocols.

The sendMessage function, as the name suggests, allows us to send messages to the server whenever the HTML button is clicked. It sends whatever text is in the HTML input element at the time the button is pressed.

So let’s try it out…

Open the HTML file in a web browser, and you should see the simple interface where you can type a message into the input box and send it to the server by pressing the button.

When it receives the message, the server will print it to the console.

Broadcasting Messages to Multiple Clients

In many real-time applications, such as chat rooms, we need to broadcast messages from the server to multiple clients.

To achieve this, we will need some way to keep track of all connected WebSocket clients on the server.

So let’s create a global variable that can store our connected clients and a channel to queue messages for broadcasting:

var clients = make(map[*websocket.Conn]bool)
var broadcast = make(chan []byte)

The clients map stores all of our connected clients, and the broadcast channel queues the messages that will be sent to all clients.

We should probably use a sync.Map to hold the clients, instead of the native map type, since we shall be accessing and potentially modifying the map across multiple goroutines — but since this is just a lightweight example, our approach should work fine. However, that is something you could fix if you were building a production-ready system.

Handling New Connections

Now we just need to update the handleConnections function we created, so that it adds every new connection to the clients map:

func handleConnections(w http.ResponseWriter, r *http.Request) {
    ws, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer ws.Close()

    clients[ws] = true

    for {
        _, msg, err := ws.ReadMessage()
        if err != nil {
            fmt.Println("Error reading message:", err)
            delete(clients, ws)
            break
        }
        broadcast <- msg
    }
}

Most of the code is the same as before. However, when a new client connects, it is now added to the clients map.

And whenever the client sends a message, it is forwarded to the broadcast channel, rather than being handled directly.

Broadcasting Messages to All Clients

Next, we need to create another function that will listen on the broadcast channel and send any messages that it receives to all connected clients:

func handleMessages() {
    for {
        msg := <-broadcast

        for client := range clients {
            err := client.WriteMessage(websocket.TextMessage, msg)
            if err != nil {
                fmt.Println("Error sending message:", err)
                client.Close()
                delete(clients, client)
            }
        }
    }
}

In the handleMessages function above, we listen for incoming messages on the broadcast channel within the main loop, and when a message is received, we iterate through all of the connected clients, sending the message to each one of them.

If an error occurs while sending, we assume that the client's connection is no longer valid, so we close the connection and remove it from the clients map.

Running the Message Handler in a Separate Goroutine

To ensure that our server is able to broadcast messages to the clients while still handling new connections concurrently, we need to run the handleMessages function as a separate goroutine.

So let's update our main function to do just that:

func main() {
    http.HandleFunc("/ws", handleConnections)

    go handleMessages()

    fmt.Println("Server starting on :8080...")
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        fmt.Println("Server failed to start:", err)
    }
}

By running handleMessages within its own goroutine, the program can perform both of its tasks at the same time, continuously listening for new messages while allowing the main HTTP server to keep accepting new connections.

Informally Testing the Application

Now that we've updated our code, the WebSocket server will broadcast any messages it receives to all of the connected clients.

To test that this works correctly, follow these steps:

  • run the Go server using go run main.go from within the websocket-project directory;
  • open multiple instances of the index.html file in different browser tabs or windows;
  • and when you send a message from one client, and you should see that message broadcast to all of the clients in real-time.

If it passes the test, you should now have your very own basic real-time chat application that uses WebSockets and Go!

Enhancing the WebSockets Server

Our current WebSockets server is effective, but we can enhance it in several ways to handle other real-world requirements like accepting various message types and improving scalability.

Handling Different Message Types

In a real-world application, we may want to send many different types of messages (e.g. notifications, chat messages, system updates, etc.) rather than just basic text content.

To implement this, we can structure messages as JSON objects and use a Header field to distinguish between different kinds of messages, as in the example below:

type Message struct {
    Identifier string `json:"id"`
    Content    string `json:"content"`
}

func handleConnections(w http.ResponseWriter, r *http.Request) {
    ws, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer ws.Close()

    clients[ws] = true

    for {
        var msg Message
        err := ws.ReadJSON(&msg)
        if err != nil {
            fmt.Println("Error reading message:", err)
            delete(clients, ws)
            break
        }
        broadcast <- []byte(fmt.Sprintf("%s: %s", msg.Identifier, msg.Content))
    }
}

In the code above, we define a Message struct that contains an id for each message as well as its content. By using the ws.ReadJSON function from the Gorilla library, we can easily decode incoming JSON messages.

Scaling the WebSocket Server

For a multi-user large-scale application with thousands of connections, our simple server may struggle to handle the load.

In order to improve scalability, consider some of the following ideas.

Use a reverse proxy like NGINX to distribute WebSockets connections across multiple instances of your Go server.

If there's a risk that you may encounter resource exhaustion, you may wish to monitor and limit the number of active goroutines,

You may also wish to think about using a message queue system (like Redis Pub/Sub or Kafka) to decouple message broadcasting from the WebSockets server itself, making it easier to scale horizontally.

Some Final Thoughts About Using WebSockets With Go

WebSockets should always run over a secure connection (using the wss:// protocol) in production environments.

So make sure that you use SSL/TLS certificates in order to secure your WebSockets connections.

The Let’s Encrypt project offers a free, automated service for obtaining these certificates, making it even easier to enhance your security.

You may also need to ensure that your firewall allows traffic on the open WebSockets port (usually 443 for secure WebSockets connections and 80 for unsecured connections, just as for ordinary websites running on HTTPS or HTTP).

Now Build on My Work to Create a More Complex Real-Time Application

Feel free to experiment with the code I've provided in this blog post. Make sure you understand exactly how it works and then try to expand it into a bigger RTA project of your own.

Let me know what interesting things you create!

Leave a Reply

Your email address will not be published. Required fields are marked *