How to Build a Real-Time Chat Application in Go

Web
Build a real-time chat application in Go by leveraging the `net/http` package for HTTP/WebSocket upgrades and the `gorilla/websocket` library to manage persistent, full-duplex connections between clients and a central server.

The problem with waiting for answers

You build a chat interface. A user types a message and hits send. The browser posts the text to your server. The server saves it. The user waits. Nothing happens on screen until they manually refresh. Every other participant faces the same delay. Polling the server every two seconds fixes the delay but burns bandwidth and CPU cycles. Long polling improves it slightly but still treats a persistent conversation like a series of disconnected requests.

Real-time chat requires a persistent, bidirectional tunnel. The server needs to push messages to connected clients the moment they arrive. Go handles this naturally because it treats concurrent connections as cheap, lightweight units of work. The standard library gives you the HTTP server. A third-party package handles the protocol upgrade. Your job is to manage the lifecycle of those connections without blocking the entire application.

How WebSockets actually work

HTTP follows a strict request-response cycle. The client asks for something. The server answers. The connection closes. WebSockets start as a normal HTTP request but include a special header asking the server to upgrade the protocol. If the server agrees, it sends back a handshake response. The TCP connection stays open. Both sides can send data at any time without re-establishing the link.

Think of HTTP like sending postal mail. You drop a letter in a box. The postal service delivers it. You wait for a reply. WebSockets are like a walkie-talkie channel. You press a button, speak, release, and listen. The channel stays open. Messages flow both ways instantly.

Go servers handle each incoming HTTP request in its own goroutine. When that request upgrades to a WebSocket, the goroutine stays attached to that connection. It reads messages, processes them, and writes responses. If you attach one goroutine per connection, you need a way to share state between them. That state is usually a hub. The hub tracks active connections, routes incoming messages to the right recipients, and cleans up when clients disconnect.

Goroutines are cheap. Channels are not magic.

The minimal server

Here is the simplest working WebSocket server. It upgrades the HTTP connection, registers the client in a thread-safe map, reads messages in a loop, and broadcasts them to everyone else.

package main

import (
	"log"
	"net/http"
	"sync"

	"github.com/gorilla/websocket"
)

// upgrader configures the protocol switch from HTTP to WebSocket.
var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024, // buffers incoming frames to reduce allocations
	WriteBufferSize: 1024, // buffers outgoing frames so writes don't block reads
	CheckOrigin:     func(r *http.Request) bool { return true }, // allow all origins for dev
}

var (
	clients = make(map[*websocket.Conn]bool) // tracks active connections
	mu      sync.Mutex                       // protects the map from concurrent writes
)

// handleConnections upgrades the request and manages the client lifecycle.
func handleConnections(w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Println(err)
		return
	}
	defer conn.Close() // ensures the socket closes when the goroutine exits

	mu.Lock()
	clients[conn] = true // register the new connection
	mu.Unlock()

	defer func() {
		mu.Lock()
		delete(clients, conn) // remove on disconnect
		mu.Unlock()
	}()

	for {
		_, message, err := conn.ReadMessage()
		if err != nil {
			break // client disconnected or protocol error
		}
		broadcast(message)
	}
}

// broadcast sends a message to every registered client.
func broadcast(message []byte) {
	mu.Lock()
	defer mu.Unlock()
	for client := range clients {
		if err := client.WriteMessage(websocket.TextMessage, message); err != nil {
			client.Close() // drop broken connections immediately
		}
	}
}

The client side only needs a few lines of JavaScript to open the tunnel and listen for incoming text.

<script>
  const socket = new WebSocket("ws://localhost:8080/ws");
  // fires when the handshake completes and the tunnel is open
  socket.onopen = function() {
    socket.send("Hello from client!");
  };
  // fires for every text frame the server pushes down the pipe
  socket.onmessage = function(event) {
    console.log("Received: " + event.data);
  };
</script>

Run the server with go run server.go. Open a browser console, paste the script, and watch messages echo back. The mutex protects the map. The defer statements guarantee cleanup. The loop blocks on ReadMessage until data arrives or the connection drops.

Don't fight the type system. Wrap the value or change the design.

Walking through the connection lifecycle

The browser sends a GET request to /ws with an Upgrade: websocket header. The upgrader.Upgrade call inspects the headers, negotiates the protocol version, and returns a *websocket.Conn. The underlying HTTP response writer is consumed. You cannot write an HTTP response after the upgrade succeeds. If you forget to capture the error and try to write headers anyway, the compiler rejects the program with cannot use w as type http.ResponseWriter in argument to upgrader.Upgrade because the upgrade consumes the writer.

Once upgraded, the goroutine enters the read loop. ReadMessage blocks until the client sends a frame. When it returns, the server calls broadcast. The broadcast function locks the mutex, iterates over the map, and calls WriteMessage on each connection. If a write fails, the connection is closed. The loop continues until ReadMessage returns an error, which usually means the client closed the tab or the network dropped. The deferred cleanup removes the connection from the map and closes the socket.

This pattern works for a handful of users. It breaks under load. The mutex serializes every broadcast. If one client has a slow network, WriteMessage blocks the entire loop. Other clients wait. The server becomes a bottleneck. You need a design that isolates slow writers and routes messages without locking the whole registry.

Context is plumbing. Run it through every long-lived call site.

Scaling the pattern with a hub

The standard solution replaces the mutex-protected map with a hub that uses channels for registration, unregistration, and message routing. Each client gets two dedicated goroutines: one for reading, one for writing. The hub never blocks on a slow client.

package main

import (
	"log"
	"net/http"
	"sync"

	"github.com/gorilla/websocket"
)

// Hub maintains the set of active clients and routes messages.
type Hub struct {
	clients    map[*Client]bool // tracks connected clients
	register   chan *Client     // incoming registration requests
	unregister chan *Client     // incoming disconnect requests
	broadcast  chan []byte      // messages to forward to all clients
}

// NewHub creates a hub with buffered channels to prevent blocking.
func NewHub() *Hub {
	return &Hub{
		broadcast:  make(chan []byte, 256), // buffer absorbs bursts
		register:   make(chan *Client),
		unregister: make(chan *Client),
		clients:    make(map[*Client]bool),
	}
}

// Run starts the hub's main loop. It must be called in a goroutine.
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) // signal the write pump to exit
			}
		case message := <-h.broadcast:
			for client := range h.clients {
				select {
				case client.send <- message:
				default:
					close(client.send) // drop slow clients
					delete(h.clients, client)
				}
			}
		}
	}
}

The hub runs once in the background. It never holds a lock. It uses select to multiplex incoming channels. The default case in the broadcast loop prevents a slow client from blocking the entire hub. If a client's send channel is full, the hub drops the message and removes the client. This keeps the server responsive.

// Client wraps a WebSocket connection with a buffered send channel.
type Client struct {
	hub      *Hub
	conn     *websocket.Conn
	send     chan []byte // outgoing messages queued by the hub
}

// readPump reads messages from the WebSocket and forwards them to the hub.
func (c *Client) readPump() {
	defer func() {
		c.hub.unregister <- c
		c.conn.Close()
	}()
	c.conn.SetReadLimit(5120) // prevent memory exhaustion from huge frames
	for {
		_, message, err := c.conn.ReadMessage()
		if err != nil {
			break
		}
		c.hub.broadcast <- message
	}
}

The read pump runs in its own goroutine. It sets a read limit to stop malicious clients from sending megabyte-sized frames. It forwards valid messages to the hub's broadcast channel. When the connection drops, it unregisters itself and closes the socket.

// writePump reads from the client's send channel and writes to the WebSocket.
func (c *Client) writePump() {
	ticker := time.NewTicker(30 * time.Second)
	defer func() {
		ticker.Stop()
		c.conn.Close()
	}()
	for {
		select {
		case message, ok := <-c.send:
			if !ok {
				c.conn.WriteMessage(websocket.CloseMessage, []byte{})
				return
			}
			w, err := c.conn.NextWriter(websocket.TextMessage)
			if err != nil {
				return
			}
			w.Write(message)
			if err := w.Close(); err != nil {
				return
			}
		case <-ticker.C:
			if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
				return
			}
		}
	}
}

The write pump drains the send channel. It uses NextWriter instead of WriteMessage to batch multiple small messages into a single TCP packet. The ticker sends periodic pings to detect dead connections. If the send channel closes, the pump writes a close frame and exits. The receiver name is one letter matching the type. Go convention favors (c *Client) over (self *Client) or (this *Client).

Trust gofmt. Argue logic, not formatting.

Where things break in production

The minimal example hides three production realities. First, WriteMessage blocks until the operating system accepts the data. If a client's network stalls, the goroutine holding that connection blocks. The mutex-protected broadcast serializes every write. The server grinds to a halt. The hub pattern solves this by buffering writes in a channel and dropping slow clients.

Second, goroutine leaks happen when the read or write pump never exits. If you forget to close the send channel during unregistration, the write pump blocks forever on <-c.send. The connection stays in the map. Memory grows. Always have a cancellation path. A context.Context passed to the pump functions lets you cancel on timeout or server shutdown.

Third, panics in goroutines crash the entire process if unhandled. A nil pointer dereference inside readPump takes down the server. Wrap long-running goroutines with recover or use a worker pool that isolates failures. The community accepts verbose error handling because it makes the unhappy path visible. if err != nil { return err } is boilerplate by design. It forces you to acknowledge failure modes.

The worst goroutine bug is the one that never logs.

Choosing the right transport

Use HTTP polling when you need maximum compatibility with legacy proxies and firewalls that block non-HTTP traffic. Use Server-Sent Events when the server only needs to push data to the client and the client never sends real-time updates. Use WebSockets when you need full-duplex communication with low latency and bidirectional message flow. Use raw TCP when you are building a custom protocol and want to avoid HTTP overhead entirely.

Where to go next