Build a Real-Time Chat Server in Go
Build a multi-user WebSocket chat server in Go using goroutines and channels.
Introduction
30 Second Summary
Every time you send a message in a group chat, your text appears on everyone's screen within milliseconds. That instant delivery relies on a persistent connection between your device and the server.
In this project, you will build a real-time chat server in Go that handles multiple users simultaneously. You will use goroutines and channels to safely coordinate messages between connections.
What You'll Build
A multi-user chat application with a Go WebSocket server and a browser-based frontend that delivers messages instantly.
By the end of this project, you'll have:
- A Go WebSocket server using coder/websocket that accepts multiple concurrent connections.
- A Hub that coordinates client registration and message broadcasting through channels.
- A browser frontend that connects over WebSockets and renders messages in real time.
- Secret Mission: Add usernames and join/leave system notifications to your chat server.
Are there any prerequisites?
This project assumes basic Go experience. You should be comfortable with structs, goroutines, channels, and HTTP handlers.
Before We Start
Before We Start...
Set Up the Project
Every Go project starts with a module. A module defines your project's name, tracks its dependencies, and ensures reproducible builds.
In this step, you'll create the project directory, initialize a Go module, and install the WebSocket library your chat server will use for real-time communication.
In this step, get ready to:
- Create the go-chat project directory and initialize a Go module.
- Install the coder/websocket library for WebSocket support.
- Set up the file structure for the server and frontend.
Create the project directory and initialize the module
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir go-chat
cd go-chat
- Confirm the folder was created by running:
pwd
- You should see a path ending in /go-chat.
Now you'll turn this folder into a Go module. A Go module is the standard way to manage dependencies in Go projects.
- Initialize the Go module by running:
go mod init go-chat
What does this command do?
go mod init go-chat creates a go.mod file that identifies your project as a Go module named go-chat.
The go.mod file tracks all external packages your project depends on. Think of it like a shopping list for your code's ingredients.
- Confirm the module was created by running:
ls
- You should see go.mod listed in the output.
Seeing 'go: command not found'?
- Make sure Go is installed on your machine. Run go version to check.
- If Go is installed but the command isn't found, your PATH may not include the Go binary directory. Revisit the Go installation instructions for your platform.
Help me troubleshoot why go is not found on my system.
Install coder/websocket
Your chat server uses WebSockets for real-time bidirectional communication between clients and the server. The coder/websocket library handles the WebSocket protocol in Go.
- Install coder/websocket version v1.8.15 by running:
go get github.com/coder/websocket@v1.8.15
What does this command do?
- go get downloads the package and adds it to your go.mod as a dependency.
- The @v1.8.15 suffix pins the exact version so your project stays reproducible across machines.
- Verify the dependency was added by running:
cat go.mod
- You should see require github.com/coder/websocket v1.8.15 in the output.
Seeing a network error?
- Make sure you're connected to the internet.
- If you see a timeout, try running the command again.
- Check that you typed the package path exactly as shown, including the @v1.8.15 at the end.
Help me troubleshoot why go get is failing.
Create the project file structure
Your server needs two things: a main.go file for the Go code and a static/ directory to serve the HTML frontend from.
- Create the server entry point and the frontend directory by running these commands:
touch main.go
mkdir static
- Confirm both were created by running:
ls
- You should see go.mod, go.sum, main.go, and static in the output.
✔️ Awesome, I've got everything!
Great. Your project structure is ready to go.
ⓧ I'd like to double check the full code
Your go.mod should look like this:
module go-chat
go 1.26
require github.com/coder/websocket v1.8.15
main.go should be empty at this point. The static/ directory should exist but be empty.
Your project is structured and ready. Next up, you'll build the Hub, the brain of your chat server that coordinates all connected clients through channels.
Build the Hub
Every chat server faces a core problem: multiple connections happening at once, all needing access to the same list of connected users. If two goroutines try to add or remove users from the same map simultaneously, you get a race condition.
The Hub pattern solves this by giving a single goroutine exclusive ownership of the shared state. Other goroutines communicate with it through channels, never touching the map directly. In this step, you will define the data structures and the event loop that make this pattern work.
In this step, get ready to:
- Define the Client and Hub structs that model connections and state.
- Write a constructor that initializes the Hub with its channels.
- Implement the Hub's event loop using a select statement.
Define the Client and Hub structs
The Hub needs to know which clients are connected, and each client needs a way to receive messages. These two types reference each other, forming the backbone of the chat server.
- Open main.go in Neovim and replace any empty content with the following code:
package main
import (
"github.com/coder/websocket"
)
// Client represents a single WebSocket connection.
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
}
// Hub manages all connected clients and broadcasts messages.
type Hub struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
}
func newHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
broadcast: make(chan []byte),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
What does this code do?
The Client struct represents one connected user. It holds a pointer back to the Hub, the WebSocket connection itself, and a send channel where outbound messages queue up.
The Hub struct is the central coordinator. Its clients map tracks who is connected. The three channels (register, unregister, broadcast) are how other goroutines request changes to the shared state.
The newHub() constructor uses make to initialize the map and all three channels so they are ready to send and receive.
- Save main.go.
- You won't see a verifiable change yet. The next substep adds the Hub's event loop, then the full file will compile.
Implement the Hub's run method
The Hub's run() method is an infinite loop that listens on all three channels using a select statement. Only one case fires at a time, so there is never a race condition on the clients map.
- Add the following method below newHub() in main.go:
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 msg := <-h.broadcast:
for client := range h.clients {
select {
case client.send <- msg:
default:
close(client.send)
delete(h.clients, client)
}
}
}
}
}
What does this code do?
- register case: a new client connected, so add it to the map.
- unregister case: a client disconnected. Remove it from the map and close its send channel to signal that no more messages will arrive.
- broadcast case: a message came in. Loop over every connected client and push the message into their send channel. If a client's channel is full (it is too slow), the default branch drops it to avoid blocking the entire broadcast.
This single select loop is the heart of the Hub pattern. No mutexes are needed because only this one goroutine ever reads or writes the clients map.
- Save main.go.
- Verify the code compiles by running:
go build ./...
If the command exits silently with no output, your code compiled successfully.
Seeing a compile error?
- Check that the import block at the top of main.go lists "github.com/coder/websocket" with straight double quotes, not curly quotes.
- Make sure the Client and Hub struct names start with an uppercase letter. Go uses capitalisation to mark exported types.
- Confirm you are running the command from inside the go-chat directory where go.mod lives.
Help me debug a Go compile error in my Hub code.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go before moving on.
ⓧ I'd like to double check the full code
package main
import (
"github.com/coder/websocket"
)
// Client represents a single WebSocket connection.
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
}
// Hub manages all connected clients and broadcasts messages.
type Hub struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
}
func newHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
broadcast: make(chan []byte),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
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 msg := <-h.broadcast:
for client := range h.clients {
select {
case client.send <- msg:
default:
close(client.send)
delete(h.clients, client)
}
}
}
}
}
Your Hub is ready to coordinate connections. Next up, you will wire up the HTTP server, upgrade incoming requests to WebSocket connections, and spawn the read and write loops that connect each client to the Hub.
Handle WebSocket Connections
The Hub is built and knows how to manage clients and broadcast messages. But nothing actually connects a real browser to it yet.
In this step, you'll write the bridge between incoming HTTP requests and the Hub. Each browser connection gets upgraded to a WebSocket, and then two goroutines handle reading and writing independently.
In this step, get ready to:
- Write an HTTP handler that upgrades connections to WebSocket.
- Implement a read loop that forwards messages to the Hub.
- Implement a write loop that sends messages back to the client.
Write the serveWs handler
When a browser wants a persistent connection, it starts as a normal HTTP request. The server "upgrades" it to a WebSocket connection that stays open for bidirectional messaging.
- In main.go, find the import block at the top of the file:
import (
"github.com/coder/websocket"
)
- Replace it with these imports to bring in the packages you'll need for this step:
import (
"context"
"log"
"net/http"
"github.com/coder/websocket"
)
What are these new imports?
- context provides the background context that the WebSocket read/write methods require.
- log lets you print error messages to the terminal.
- net/http gives you the HTTP server primitives for handling requests and upgrading connections.
- Add the serveWs function at the bottom of main.go, below the run() method:
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
// Upgrade the HTTP connection to a WebSocket
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true,
})
if err != nil {
log.Printf("WebSocket accept error: %v", err)
return
}
// Create a new client and register it with the Hub
client := &Client{
hub: hub,
conn: conn,
send: make(chan []byte, 256),
}
hub.register <- client
// Launch the read and write goroutines for this client
go client.writePump()
go client.readPump()
}
What does this code do?
- websocket.Accept upgrades the plain HTTP connection to a persistent WebSocket connection.
- InsecureSkipVerify: true disables origin checking for local development. In production, you would restrict this to trusted origins.
- After creating the Client struct, it registers itself with the Hub via the register channel.
- Two goroutines launch per client. One reads incoming messages, the other writes outgoing messages.
You won't see a change yet. The code won't compile until readPump and writePump exist.
Implement the client's read loop
Each client needs a dedicated goroutine that continuously reads incoming messages from the WebSocket. When a message arrives, it forwards it to the Hub's broadcast channel. If the connection drops, it cleans up.
- Add the readPump method above serveWs in main.go:
func (c *Client) readPump() {
// When this function exits, unregister and close the connection
defer func() {
c.hub.unregister <- c
c.conn.CloseNow()
}()
// Continuously read messages from the WebSocket
for {
_, msg, err := c.conn.Read(context.Background())
if err != nil {
break
}
// Forward the message to the Hub for broadcasting
c.hub.broadcast <- msg
}
}
What does this code do?
- The defer block ensures cleanup always happens. When the loop exits (on any error), the client unregisters from the Hub and the connection closes immediately.
- c.conn.Read(context.Background()) blocks until a message arrives. The first return value (message type) is discarded with _ because we only care about the bytes.
- Any read error means the client disconnected. Breaking out of the loop triggers the deferred cleanup.
You won't see a change yet. One more function to go before the code compiles.
Implement the client's write loop
The write loop does the inverse of the read loop. It watches the client's send channel and writes each message out to the WebSocket.
- Add the writePump method between readPump and serveWs in main.go:
func (c *Client) writePump() {
// If this loop exits, close the connection
defer c.conn.CloseNow()
// Range over the send channel until it's closed
for msg := range c.send {
err := c.conn.Write(context.Background(), websocket.MessageText, msg)
if err != nil {
break
}
}
}
What does this code do?
- for msg := range c.send blocks until a message appears on the channel. When the Hub closes the channel (during unregister), the loop exits automatically.
- websocket.MessageText tells the browser to treat the data as a UTF-8 text message.
- If a write fails (client disconnected mid-send), the loop breaks and the deferred CloseNow() cleans up the connection.
- Save main.go.
- Verify the code compiles by running:
go build ./...
The command produces no output on success. If you see no errors, everything compiled correctly.
Seeing a compile error?
- If you see undefined: readPump or undefined: writePump, make sure both methods are defined before serveWs in the file. Go resolves names within the same package regardless of order, so this usually means a typo in the method name.
- If you see imported and not used, double check that your import block matches the one from the first substep. Each import is used by at least one of the three new functions.
If you're stuck, help me debug this compile error in my Go chat server.
Why two goroutines per client?
WebSocket connections are full-duplex. Messages can flow in both directions at the same time. By giving each direction its own goroutine, the read loop never blocks waiting for a write to finish and vice versa.
This pattern also keeps the Hub simple. The Hub only writes to each client's send channel. The writePump handles the actual WebSocket write, so the Hub never touches the connection directly.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.go and confirmed a clean go build ./... with no output.
ⓧ I'd like to double check the full code
Here's the full main.go as it should look at the end of this step:
package main
import (
"context"
"log"
"net/http"
"github.com/coder/websocket"
)
// Client represents a single WebSocket connection.
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
}
// Hub manages all connected clients and broadcasts messages.
type Hub struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
}
func newHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
broadcast: make(chan []byte),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
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 msg := <-h.broadcast:
for client := range h.clients {
select {
case client.send <- msg:
default:
close(client.send)
delete(h.clients, client)
}
}
}
}
}
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.CloseNow()
}()
for {
_, msg, err := c.conn.Read(context.Background())
if err != nil {
break
}
c.hub.broadcast <- msg
}
}
func (c *Client) writePump() {
defer c.conn.CloseNow()
for msg := range c.send {
err := c.conn.Write(context.Background(), websocket.MessageText, msg)
if err != nil {
break
}
}
}
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true,
})
if err != nil {
log.Printf("WebSocket accept error: %v", err)
return
}
client := &Client{
hub: hub,
conn: conn,
send: make(chan []byte, 256),
}
hub.register <- client
go client.writePump()
go client.readPump()
}
Your connection handling is wired up. Next, you'll build the browser frontend and the main() function that ties everything together into a running server.
Build the Frontend
Your WebSocket server can accept connections, read messages, and broadcast them to all connected clients. Now it needs a user interface.
In this step, you'll create a single HTML page that connects to your server over WebSockets using the browser's built-in API. You'll also add the main() function that ties your Hub, routes, and HTTP server together.
In this step, get ready to:
- Create an HTML/JS chat interface that connects via WebSocket.
- Add the main() function to wire up routing and start the server.
- Test real-time messaging between two browser tabs.
Create the chat page
The frontend is a single index.html file inside the static/ directory. It uses the browser's built-in WebSocket API to connect to your server. No npm packages or build tools needed.
- Open a new file at static/index.html in Neovim by running:
nvim static/index.html
- Add the complete chat interface by pasting the following code into the file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Go Chat</title>
<style>
/* Reset default browser spacing */
* { margin: 0; padding: 0; box-sizing: border-box; }
/* Full-height dark layout */
body { font-family: sans-serif; display: flex; flex-direction: column; height: 100vh; background: #1a1a2e; color: #eee; }
/* Scrollable message area fills available space */
#messages { flex: 1; overflow-y: auto; padding: 1rem; }
#messages div { margin-bottom: 0.5rem; padding: 0.5rem 0.75rem; background: #16213e; border-radius: 4px; }
/* Input bar pinned to the bottom */
#form { display: flex; padding: 0.75rem; background: #0f3460; }
#input { flex: 1; padding: 0.5rem; border: none; border-radius: 4px; font-size: 1rem; }
#send { margin-left: 0.5rem; padding: 0.5rem 1rem; background: #e94560; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
#send:hover { background: #c23152; }
</style>
</head>
<body>
<!-- Message display area -->
<div id="messages"></div>
<!-- Input form at the bottom -->
<div id="form">
<input id="input" type="text" placeholder="Type a message..." autocomplete="off" />
<button id="send">Send</button>
</div>
<script>
// Grab references to DOM elements
const messages = document.getElementById('messages');
const input = document.getElementById('input');
const sendBtn = document.getElementById('send');
// Connect to the WebSocket server using the correct protocol
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws`);
// When a message arrives, create a div and append it
ws.onmessage = function(event) {
const div = document.createElement('div');
div.textContent = event.data;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
};
// Show a disconnect notice if the connection drops
ws.onclose = function() {
const div = document.createElement('div');
div.textContent = '--- Connection closed ---';
div.style.color = '#e94560';
messages.appendChild(div);
};
// Send the input text over the WebSocket
function send() {
const text = input.value.trim();
if (text && ws.readyState === WebSocket.OPEN) {
ws.send(text);
input.value = '';
}
}
// Attach send to both the button click and the Enter key
sendBtn.addEventListener('click', send);
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') send();
});
</script>
</body>
</html>
What does this code do?
The HTML gives you a dark-themed chat layout with a scrollable message area and an input bar pinned to the bottom.
- The JavaScript opens a WebSocket connection to /ws on the same host that served the page.
- ws.onmessage fires every time the server broadcasts a message. It creates a new <div> and appends it to the message list.
- The send() function reads the input field, sends the text to the server, and clears the input.
- ws.onclose shows a red disconnect notice if the server goes down.
- Save and exit Neovim by pressing Esc, then typing :wq and pressing Enter.
- Confirm the file was created by running:
ls static/
You should see index.html in the output.
Don't see index.html?
- Make sure you ran nvim static/index.html from inside the go-chat directory. The static/ folder must already exist.
- If you see an empty static/ directory, you may have quit Neovim without saving. Reopen the file and make sure you use :wq to save.
Help me figure out why my index.html file wasn't created in the static directory.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Go Chat</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: sans-serif; display: flex; flex-direction: column; height: 100vh; background: #1a1a2e; color: #eee; }
#messages { flex: 1; overflow-y: auto; padding: 1rem; }
#messages div { margin-bottom: 0.5rem; padding: 0.5rem 0.75rem; background: #16213e; border-radius: 4px; }
#form { display: flex; padding: 0.75rem; background: #0f3460; }
#input { flex: 1; padding: 0.5rem; border: none; border-radius: 4px; font-size: 1rem; }
#send { margin-left: 0.5rem; padding: 0.5rem 1rem; background: #e94560; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
#send:hover { background: #c23152; }
</style>
</head>
<body>
<div id="messages"></div>
<div id="form">
<input id="input" type="text" placeholder="Type a message..." autocomplete="off" />
<button id="send">Send</button>
</div>
<script>
const messages = document.getElementById('messages');
const input = document.getElementById('input');
const sendBtn = document.getElementById('send');
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onmessage = function(event) {
const div = document.createElement('div');
div.textContent = event.data;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
};
ws.onclose = function() {
const div = document.createElement('div');
div.textContent = '--- Connection closed ---';
div.style.color = '#e94560';
messages.appendChild(div);
};
function send() {
const text = input.value.trim();
if (text && ws.readyState === WebSocket.OPEN) {
ws.send(text);
input.value = '';
}
}
sendBtn.addEventListener('click', send);
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') send();
});
</script>
</body>
</html>
Add the main function
The main() function is the entry point that ties your Hub, routes, and HTTP server together. It creates the Hub, launches it as a goroutine, registers your route handlers, and starts listening for connections.
- Open main.go in Neovim by running:
nvim main.go
- Scroll to the bottom of the file (press G in Normal mode) and add the following main() function after serveWs:
func main() {
// Create the Hub and launch it in its own goroutine
hub := newHub()
go hub.run()
// Serve the static frontend files at the root URL
http.Handle("/", http.FileServer(http.Dir("./static")))
// Route WebSocket upgrade requests to the serveWs handler
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
serveWs(hub, w, r)
})
log.Println("Server started on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
What does this code do?
- newHub() creates the Hub, and go hub.run() starts its infinite select loop in a separate goroutine. This goroutine runs for the lifetime of the server.
- http.Handle("/", ...) serves everything in the ./static folder when someone visits http://localhost:8080.
- http.HandleFunc("/ws", ...) routes WebSocket upgrade requests to your serveWs handler.
- http.ListenAndServe(":8080", nil) starts the HTTP server on port 8080 and blocks forever (or until it errors).
- Save and exit Neovim by pressing Esc, then typing :wq and pressing Enter.
✔️ Awesome, I've got everything!
Great. Make sure you've saved main.go before moving on.
ⓧ I'd like to double check the full code
package main
import (
"context"
"log"
"net/http"
"github.com/coder/websocket"
)
// Client represents a single WebSocket connection.
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
}
// Hub manages all connected clients and broadcasts messages.
type Hub struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
}
func newHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
broadcast: make(chan []byte),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
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 msg := <-h.broadcast:
for client := range h.clients {
select {
case client.send <- msg:
default:
close(client.send)
delete(h.clients, client)
}
}
}
}
}
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.CloseNow()
}()
for {
_, msg, err := c.conn.Read(context.Background())
if err != nil {
break
}
c.hub.broadcast <- msg
}
}
func (c *Client) writePump() {
defer c.conn.CloseNow()
for msg := range c.send {
err := c.conn.Write(context.Background(), websocket.MessageText, msg)
if err != nil {
break
}
}
}
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true,
})
if err != nil {
log.Printf("WebSocket accept error: %v", err)
return
}
client := &Client{
hub: hub,
conn: conn,
send: make(chan []byte, 256),
}
hub.register <- client
go client.writePump()
go client.readPump()
}
func main() {
hub := newHub()
go hub.run()
http.Handle("/", http.FileServer(http.Dir("./static")))
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
serveWs(hub, w, r)
})
log.Println("Server started on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Run the server and test real-time chat
Everything is wired up. Time to start the server and see messages flow between browser tabs in real time.
- Start the chat server by running:
go run main.go
You should see Server started on :8080 printed in your terminal.
- Open your web browser and navigate to http://localhost:8080.
- Open a second browser tab and navigate to the same URL: http://localhost:8080.
- Type a message in one tab and click Send (or press Enter).
The message appears instantly in both tabs. That is your Hub broadcasting the message to every connected client via their write goroutines.
Don't see messages in both tabs?
- Check that both tabs are pointed at http://localhost:8080 (not https).
- Make sure you see Server started on :8080 in your terminal. If you see an error about the address already being in use, another process is using port 8080.
- Open DevTools (F12) and check the Console tab for WebSocket errors. A failed connection usually means the server is not running.
Help me troubleshoot why messages aren't appearing in both browser tabs.
Why does this work without a framework?
Go's standard net/http package handles both static file serving and HTTP routing. The coder/websocket library upgrades any standard HTTP handler to a WebSocket connection.
No web framework is needed because the standard library already provides everything this server requires.
Secret mission
Add Usernames and System Notifications
Right now, every message is anonymous. You don't know who sent what, and there's no indication when someone joins or leaves the chat. In this secret mission, you'll add usernames to messages and broadcast system notifications so the chat feels like a real multi-user conversation.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The go-chat project directory on your Desktop (contains main.go, static/index.html, and Go module files).
- The running Go server process (if still running on port :8080).
- Go module cache entry for coder/websocket.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the chat server.
- Your go-chat folder stays on your Desktop.
- If the server is still running, it continues listening on :8080.
- The Go module cache retains the downloaded coder/websocket package for faster future builds.
Pause - I'll come back to this later
Stop the running server process to free up the port. Your project files stay on disk for when you return.
- Switch to the terminal where the server is running.
- Press Ctrl+C to stop the server.
- Your go-chat folder remains on your Desktop. To resume later, navigate back to the folder and run go run main.go again.
Delete - I don't want to use this again
Remove all project files and stop any running processes. This cannot be undone.
- If the server is still running, switch to that terminal and press Ctrl+C to stop it.
- Delete the project directory by running the command for your operating system.
On macOS or Linux:
rm -rf ~/Desktop/go-chat
On Windows:
rmdir /s /q %USERPROFILE%\Desktop\go-chat
- Optionally, clear the Go module cache by running:
go clean -modcache
What does go clean -modcache do?
This removes all downloaded Go modules from your local cache, not just coder/websocket. If you have other Go projects, they will re-download their dependencies on the next build. Skip this if you want to keep cached modules for other projects.
Nice Work!
Nice Work!
Nice work! You just built a real-time multi-user chat server in Go, powered by WebSockets and Go's concurrency primitives.
You've learned how to:
- Build a real-time WebSocket chat server in Go using the coder/websocket library.
- Implement the Hub pattern to coordinate all shared state through channels, eliminating the need for mutexes.
- Use goroutines for per-client read and write loops, and serve a browser-based frontend that renders messages in real time.
- Secret Mission: Added structured JSON messages with usernames and join/leave system notifications.
Ready to quiz yourself?