How to use WebSockets in Go for real-time connections
Learn how to build a high-performance concurrent server in Go using goroutines and the gorilla/websocket package to manage thousands of simultaneous clients.
Summary
- WebSockets enable continuous bidirectional communication between client and server without page reloads
- Go handles thousands of simultaneous connections efficiently using lightweight goroutines and channels
- The gorilla/websocket package simplifies upgrading standard HTTP requests to persistent connections
- State management requires careful concurrency handling to prevent data races in memory
- Maintaining active connections requires regular ping messages to detect abrupt network drops
What are WebSockets and why use them in Go?
Modern applications demand instant responses, whether in chat systems, financial dashboards, or collaborative team tools. The traditional web model operates on a request-and-response cycle where the browser must ask the server if there is anything new every few seconds. This wastes bandwidth and creates noticeable delays. The WebSocket protocol solves this by creating a persistent, bidirectional communication channel over a single TCP connection, allowing both client and server to push data at any moment. When we need to scale this architecture to handle tens of thousands of users connected simultaneously, the choice of backend technology makes all the difference. This is precisely where the Go language shines, offering a solid foundation for high-performance systems, very similar to discussions around optimizing database connection leaks in Node.js.
The advantage of goroutines in concurrency
Traditionally, servers spawned a new operating system thread for every connected client. Operating system threads consume a lot of memory and require heavy CPU effort just to switch between them. Go takes a completely different approach through goroutines, which are extremely lightweight execution routines managed by the language runtime itself. While a standard thread consumes megabytes of memory, a goroutine starts at just a few kilobytes and grows as needed. In practice, this means you can run one hundred thousand connections on a single machine without exhausting hardware resources. Each WebSocket connection can be isolated in its own read and write goroutine, keeping the code clean and easy to reason about without complex callback hell.
Setting the stage with the gorilla/websocket package
Go's standard library has excellent HTTP support, but handling the WebSocket handshake and frame protocols from scratch would be tedious and error-prone. That is why the community relies on the gorilla/websocket package, considered the industry standard for this task. To get started, we need to transform a regular HTTP request into a long-lived WebSocket connection. We do this using an 'Upgrader' function, which validates request headers, accepts the protocol, and returns a pointer to a connection object. This object provides direct methods to read and write structured messages in text or binary formats. If you ever need to handle telemetry data or hardware integration, it is worth checking similar continuous streaming strategies in how to use MQTT to integrate industrial sensors locally.
Structuring the concurrent server in practice
To build a robust server, accepting connections is not enough; we must manage each client's lifecycle and coordinate message distribution across all participants in a room or channel. The most efficient design pattern in Go involves creating a central structure called a 'Hub', which acts as the application conductor. The Hub maintains an internal list of active clients and uses Go channels to register new connections, remove disconnections, and broadcast messages safely. Below is a simplified example of how to structure the HTTP request handler to upgrade the protocol and register the client into the application's central Hub.
package main
import (
"log"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
}
func handleConnections(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("Error upgrading connection:", err)
return
}
defer conn.Close()
for {
messageType, message, err := conn.ReadMessage()
if err != nil {
log.Println("Error reading message:", err)
break
}
log.Printf("Received message: %s\n", message)
err = conn.WriteMessage(messageType, message)
if err != nil {
log.Println("Error writing message:", err)
break
}
}
}Preventing data races and memory bottlenecks
When multiple clients send and receive data simultaneously, the internal server state is accessed by several goroutines at once. If two goroutines attempt to modify the list of connected clients without proper control, the program will suffer a fatal data race error. To prevent this, Go offers native tools like mutexes from the 'sync' package or, preferably, channels to delegate state modification to a single controlling goroutine. Another critical point is message read limits: if a malicious client sends a massive payload, server memory can quickly become exhausted. Setting clear limits with methods like 'SetReadLimit' protects your application against accidental or intentional denial-of-service attacks.
Network stability is never guaranteed in the real world. A client might close the app abruptly, enter a subway tunnel, or lose Wi-Fi signal without sending a prior disconnection notice to the server. If left unchecked, the server will assume the connection is still alive, accumulating orphan goroutines and leaking memory over time. The industry-standard solution is to implement periodic heartbeats known as pings and pongs. The server sends an empty ping signal at regular intervals and expects a pong response from the client within a timeout window. If the client fails to respond, the connection is closed cleanly, immediately releasing all associated resources. To explore these architectural concepts further in production, see our related technical analysis on How to test optocouplers on board with a multimeter.
Final thoughts on scaling and resilience
Building real-time servers with Go and WebSockets combines syntactic simplicity with impressive processing power. By delegating heavy lifting to the language's goroutine scheduler and structuring code with the gorilla/websocket package, we can serve thousands of concurrent connections using a fraction of traditional hardware resources. The key to production success lies in continuous monitoring of memory limits, proper handling of abrupt disconnections, and protection against disordered concurrency. With these safeguards in place, your application will be ready to scale sustainably and deliver a truly instant experience to your users.