Skip to main content
  1. Languages/
  2. Golang Guides/

Mastering Real-Time Go: Building Scalable WebSockets with Gorilla

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

Introduction
#

In the fast-paced landscape of 2025, “refreshing the page” is a relic of the past. Whether you are building a crypto trading dashboard, a live collaborative editing tool, or a simple customer support chat, your users expect data to flow instantly. They expect real-time interaction.

While Go’s standard library net/http is a masterpiece of engineering, it doesn’t support WebSockets out of the box in a high-level, feature-rich way. That is where the Gorilla WebSocket package comes in. Despite the evolving landscape of Go frameworks, Gorilla remains the battle-tested, de facto standard for handling WebSockets in Go due to its stability, compliance with RFC 6455, and widespread community adoption.

In this guide, we aren’t just writing “Hello World.” We are going to build a production-grade Hub-based Broadcast Server. We will tackle the hard stuff: handling concurrency safely, managing connection lifecycles, and implementing proper Ping/Pong heartbeats to keep connections alive.

By the end of this article, you will have a solid foundation to build scalable real-time systems.


Prerequisites & Environment Setup
#

Before we write a single line of code, let’s ensure your environment is ready. We assume you are comfortable with basic Go syntax and HTTP concepts.

Tools Required
#

  • Go Version: Go 1.22 or higher (we are targeting the 2025/2026 ecosystem).
  • IDE: VS Code (with the official Go extension) or GoLand.
  • Terminal: Any standard shell.
  • API Client: Postman or a simple browser console for testing.

Project Initialization
#

Let’s create a directory and initialize our module.

mkdir go-realtime-chat
cd go-realtime-chat
go mod init github.com/yourusername/go-realtime-chat

Now, let’s install the star of the show:

go get github.com/gorilla/websocket

This updates your go.mod file to include the latest version of the Gorilla WebSocket library.


Understanding the WebSocket Protocol
#

Before implementing, it is crucial to understand how WebSockets differ from standard HTTP requests.

HTTP vs. WebSocket
#

In a standard REST API, the client requests data, the server responds, and the connection closes (stateless). In a WebSocket, the connection stays open.

Feature Standard HTTP (REST) HTTP Long-Polling Server-Sent Events (SSE) WebSockets
Communication Unidirectional (Req -> Res) Unidirectional (Delayed Res) Unidirectional (Server -> Client) Bidirectional (Full Duplex)
Latency High (New TCP handshake) Medium/High Low Lowest
Overhead Heavy (Headers per req) Heavy Low Extremely Low
Use Case CRUD Operations Legacy Real-time News feeds, Stock tickers Chat, Gaming, Collaboration

The Handshake Flow
#

The magic happens via an “Upgrade” request. The client sends a standard HTTP GET request with a Upgrade: websocket header. If the server approves, it switches protocols from HTTP to WebSocket.

Here is the flow we are going to implement:

sequenceDiagram participant C as Client (Browser) participant S as Go Server (Gorilla) participant H as Hub (Go Struct) C->>S: GET /ws (Header: Upgrade: websocket) Note over S: Check Origin & Headers S-->>C: 101 Switching Protocols Note over C,S: Connection Established (TCP) C->>S: Send Message (JSON) S->>H: Broadcast to Hub H->>S: Distribute to all Clients S-->>C: Message Received loop Heartbeat S->>C: Ping C-->>S: Pong end

Step 1: The Basic “Echo” Server
#

Let’s start by establishing the handshake. We need an Upgrader. This struct holds the configuration for upgrading the HTTP connection.

Create a file named main.go.

package main

import (
	"log"
	"net/http"

	"github.com/gorilla/websocket"
)

// Configure the upgrader
var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	// IN PRODUCTION: You must validate the origin to prevent CSRF.
	// For this tutorial, we allow all origins.
	CheckOrigin: func(r *http.Request) bool {
		return true
	},
}

func echoHandler(w http.ResponseWriter, r *http.Request) {
	// Upgrade the HTTP server connection to the WebSocket protocol
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Println("Upgrade error:", err)
		return
	}
	defer conn.Close()

	log.Println("Client connected")

	for {
		// Read message from browser
		messageType, message, err := conn.ReadMessage()
		if err != nil {
			log.Println("Read error:", err)
			break
		}

		log.Printf("Received: %s", message)

		// Write message back to browser (Echo)
		err = conn.WriteMessage(messageType, message)
		if err != nil {
			log.Println("Write error:", err)
			break
		}
	}
}

func main() {
	http.HandleFunc("/echo", echoHandler)
	log.Println("Server started on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Key Takeaway: The conn.ReadMessage() blocks until a message arrives. This simple loop keeps the connection alive until an error occurs (like the client closing the tab).


Step 2: Implementing the Hub Pattern (Best Practice)
#

The Echo server is fine for testing, but in the real world (e.g., a chat app), Client A needs to talk to Client B. Since WebSocket connections are concurrent, we need a centralized way to manage them.

We will use the Hub Pattern.

  1. Hub: Maintains a registry of active clients and broadcasts messages.
  2. Client: Handles the specific WebSocket connection (reading/writing).

The Hub
#

Create a new file hub.go.

package main

// Hub maintains the set of active clients and broadcasts messages to the
// clients.
type Hub struct {
	// Registered clients.
	clients map[*Client]bool

	// Inbound messages from the clients.
	broadcast chan []byte

	// Register requests from the clients.
	register chan *Client

	// Unregister requests from clients.
	unregister chan *Client
}

func newHub() *Hub {
	return &Hub{
		broadcast:  make(chan []byte),
		register:   make(chan *Client),
		unregister: make(chan *Client),
		clients:    make(map[*Client]bool),
	}
}

func (h *Hub) run() {
	for {
		select {
		case client := <-h.register:
			h.clients[client] = true
		case client := <-h.unregister:
			if _, ok := h.clients[client]; ok {
				delete(h.clients, client)
				close(client.send)
			}
		case message := <-h.broadcast:
			for client := range h.clients {
				select {
				case client.send <- message:
				default:
					// If the client's send buffer is blocked, assume they hung up
					close(client.send)
					delete(h.clients, client)
				}
			}
		}
	}
}

Analysis: The Hub uses Go’s select statement to synchronize access to the clients map. This prevents race conditions without using explicit Mutexes, adhering to the Go philosophy: “Do not communicate by sharing memory; instead, share memory by communicating.”

The Client
#

This is where things get tricky. A WebSocket connection supports only one concurrent reader and one concurrent writer. However, we might want to write to the socket because of an incoming HTTP request or because another user sent a message.

To solve this, we decouple reading and writing into two goroutines: readPump and writePump.

Create client.go:

package main

import (
	"bytes"
	"log"
	"net/http"
	"time"

	"github.com/gorilla/websocket"
)

const (
	// Time allowed to write a message to the peer.
	writeWait = 10 * time.Second

	// Time allowed to read the next pong message from the peer.
	pongWait = 60 * time.Second

	// Send pings to peer with this period. Must be less than pongWait.
	pingPeriod = (pongWait * 9) / 10

	// Maximum message size allowed from peer.
	maxMessageSize = 512
)

var (
	newline = []byte{'\n'}
	space   = []byte{' '}
)

// Client is a middleman between the websocket connection and the hub.
type Client struct {
	hub *Hub

	// The websocket connection.
	conn *websocket.Conn

	// Buffered channel of outbound messages.
	send chan []byte
}

// readPump pumps messages from the websocket connection to the hub.
// The application runs readPump in a per-connection goroutine.
func (c *Client) readPump() {
	defer func() {
		c.hub.unregister <- c
		c.conn.Close()
	}()
	
	// Config settings
	c.conn.SetReadLimit(maxMessageSize)
	c.conn.SetReadDeadline(time.Now().Add(pongWait))
	
	// Heartbeat: Reset deadline when a Pong is received
	c.conn.SetPongHandler(func(string) error { 
        c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil 
    })

	for {
		_, message, err := c.conn.ReadMessage()
		if err != nil {
			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
				log.Printf("error: %v", err)
			}
			break
		}
		// Formatting: Trim newlines and spaces
		message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1))
		
		// Send to hub
		c.hub.broadcast <- message
	}
}

// writePump pumps messages from the hub to the websocket connection.
func (c *Client) writePump() {
	ticker := time.NewTicker(pingPeriod)
	defer func() {
		ticker.Stop()
		c.conn.Close()
	}()

	for {
		select {
		case message, ok := <-c.send:
			c.conn.SetWriteDeadline(time.Now().Add(writeWait))
			if !ok {
				// The hub closed the channel.
				c.conn.WriteMessage(websocket.CloseMessage, []byte{})
				return
			}

			w, err := c.conn.NextWriter(websocket.TextMessage)
			if err != nil {
				return
			}
			w.Write(message)

			// Add queued chat messages to the current websocket message.
			n := len(c.send)
			for i := 0; i < n; i++ {
				w.Write(newline)
				w.Write(<-c.send)
			}

			if err := w.Close(); err != nil {
				return
			}

		case <-ticker.C:
			// Heartbeat: Send Ping
			c.conn.SetWriteDeadline(time.Now().Add(writeWait))
			if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
				return
			}
		}
	}
}

// serveWs handles websocket requests from the peer.
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Println(err)
		return
	}
	client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
	client.hub.register <- client

	// Allow collection of memory by doing this in a goroutine
	go client.writePump()
	go client.readPump()
}

Updating Main
#

Finally, update main.go to use the Hub:

package main

import (
	"log"
	"net/http"
)

func main() {
	hub := newHub()
	go hub.run()

	http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
		serveWs(hub, w, r)
	})
    
    // Serve a simple HTML file (created below)
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		http.ServeFile(w, r, "home.html")
	})

	log.Println("Chat Server started on :8080")
	err := http.ListenAndServe(":8080", nil)
	if err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}

Step 3: The Frontend (Verification)
#

To test this, we need a simple HTML client. Create home.html in the same directory:

<!DOCTYPE html>
<html lang="en">
<head>
<title>Golang Chat</title>
<script type="text/javascript">
window.onload = function () {
    var conn;
    var msg = document.getElementById("msg");
    var log = document.getElementById("log");

    function appendLog(item) {
        var doScroll = log.scrollTop > log.scrollHeight - log.clientHeight - 1;
        log.appendChild(item);
        if (doScroll) {
            log.scrollTop = log.scrollHeight - log.clientHeight;
        }
    }

    if (window["WebSocket"]) {
        conn = new WebSocket("ws://" + document.location.host + "/ws");
        conn.onclose = function (evt) {
            var item = document.createElement("div");
            item.innerHTML = "<b>Connection closed.</b>";
            appendLog(item);
        };
        conn.onmessage = function (evt) {
            var messages = evt.data.split('\n');
            for (var i = 0; i < messages.length; i++) {
                var item = document.createElement("div");
                item.innerText = messages[i];
                appendLog(item);
            }
        };
    } else {
        var item = document.createElement("div");
        item.innerHTML = "<b>Your browser does not support WebSockets.</b>";
        appendLog(item);
    }

    document.getElementById("form").onsubmit = function () {
        if (!conn) {
            return false;
        }
        if (!msg.value) {
            return false;
        }
        conn.send(msg.value);
        msg.value = "";
        return false;
    };
};
</script>
<style type="text/css">
html { overflow: hidden; }
body { overflow: hidden; padding: 0; margin: 0; width: 100%; height: 100%; background: gray; }
#log { background: white; margin: 0; padding: 0.5em 0.5em 0.5em 0.5em; position: absolute; top: 0.5em; left: 0.5em; right: 0.5em; bottom: 3em; overflow: auto; }
#form { padding: 0 0.5em 0 0.5em; margin: 0; position: absolute; bottom: 1em; left: 0px; width: 100%; overflow: hidden; }
</style>
</head>
<body>
<div id="log"></div>
<form id="form">
    <input type="submit" value="Send" />
    <input type="text" id="msg" size="64" autofocus />
</form>
</body>
</html>

Performance & Best Practices
#

Writing WebSocket servers is easy; writing stable ones is hard. Here are the specific optimizations used in the code above that make it production-ready.

1. Handling Dead Connections (Heartbeats)
#

Networks are unreliable. A TCP connection can be severed without the server knowing it (e.g., a user unplugging a router).

  • The Problem: The server keeps the goroutine open, leaking memory.
  • The Solution: We implemented Ping and Pong handlers.
    • Server: Sends a Ping every 54 seconds (via writePump).
    • Client: Browser automatically replies with Pong.
    • Logic: If the server doesn’t receive a Pong within the pongWait deadline, it kills the connection.

2. Concurrency Safety
#

A common panic in Go WebSocket implementations comes from trying to write to conn from multiple goroutines simultaneously.

  • The Fix: We funnel all writes through the send channel. Only the writePump goroutine is allowed to touch conn.WriteMessage.

3. Buffering
#

In our Hub, we use broadcast: make(chan []byte). However, in the Client struct, we use send: make(chan []byte, 256).

  • Why? This is a buffered channel. If a client has a slow internet connection, we can buffer up to 256 messages before we start blocking or dropping them. This prevents one slow user from lagging the entire chat server.

4. Payload Size Limits
#

We used c.conn.SetReadLimit(maxMessageSize).

  • Security: This prevents a malicious user from sending a 1GB text frame that crashes your server by exhausting memory.

Conclusion
#

You now have a fully functional, concurrent-safe WebSocket server using the Gorilla library. This architecture scales vertically remarkably well. You can handle thousands of concurrent connections on a modest VPS.

What’s Next?
#

If you plan to scale this to millions of users (horizontal scaling), a single Go instance won’t suffice. You will need to spin up multiple Go servers and use a Pub/Sub system like Redis or NATS to synchronize messages between the servers.

Further Reading:

Happy Coding!