Golang WebSocket Examples: Server and Client

Deepak Prasad
Tested on RHEL 10.2 with Go 1.27.0
Package go 1.27.0
github.com/gorilla/websocket v1.5.3
Applies to Any host with Go 1.20+ installed
Privilege Normal user
Scope Runnable Gorilla WebSocket echo server and Go client, text and JSON messages, close frames, ping/pong basics, and common errors. Does not cover chat hubs, Redis pub/sub, or reverse-proxy TLS setup.
Related guides Golang HTTP server
Golang TCP server and client
Golang context
Getting started with Go
Golang Gin

WebSocket keeps a connection open so a client and server can exchange messages without sending a new HTTP request for every update. That pattern fits chat, live dashboards, notifications, collaborative editing, and streaming server-pushed data. This tutorial builds a golang WebSocket server and a matching golang WebSocket client with Gorilla WebSocket (github.com/gorilla/websocket v1.5.3): connect, send a text message, echo it back, then cover JSON, closing, ping/pong, and the mistakes that show up next.

Gorilla WebSocket remains mature and widely used (pkg.go.dev lists tens of thousands of known importers; v1.5.3 is the current stable release). github.com/coder/websocket is another actively maintained library with a more modern, context-oriented API. This guide uses Gorilla for all examples.


Install Gorilla WebSocket

Create a working directory for the examples:

bash
mkdir websocket-example && cd websocket-example

Register a Go module for this folder:

bash
go mod init websocket-example
output
go: creating new go.mod: module websocket-example

Pull Gorilla WebSocket at the current stable release:

bash
go get github.com/gorilla/websocket@v1.5.3
output
go: added github.com/gorilla/websocket v1.5.3

Gorilla provides the pieces you need for a golang WebSocket example:

  • HTTP → WebSocket upgrade on the server (websocket.Upgrader)
  • client dialing (websocket.DefaultDialer)
  • reading and writing application messages (ReadMessage, WriteMessage, ReadJSON, WriteJSON)
  • control messages such as close, ping, and pong

Create a WebSocket server in Go

The server listens on HTTP, upgrades /ws to WebSocket, reads one message, and echoes it back.

Save this as server.go:

go
package main

import (
	"log"
	"net/http"

	"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{}

func handleWebSocket(w http.ResponseWriter, req *http.Request) {
	conn, err := upgrader.Upgrade(w, req, nil)
	if err != nil {
		log.Println("upgrade:", err)
		return
	}
	defer conn.Close()

	conn.SetReadLimit(1 << 20) // 1 MiB max incoming message

	log.Println("client connected")

	for {
		messageType, message, err := conn.ReadMessage()
		if err != nil {
			if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
				log.Println("read:", err)
			}
			return
		}
		log.Printf("received: %s", message)

		if err := conn.WriteMessage(messageType, message); err != nil {
			log.Println("write:", err)
			return
		}
	}
}

func main() {
	log.SetFlags(0)
	http.HandleFunc("/ws", handleWebSocket)
	log.Println("WebSocket server listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Run the WebSocket server

Leave this process running in Terminal 1:

bash
go run server.go
output
WebSocket server listening on :8080

The server blocks until a client connects; you run the client in a second terminal next.

How the WebSocket handler works

The flow for a websocket server golang handler is:

text
HTTP request to /ws
upgrader.Upgrade(w, req, nil)
WebSocket connection (conn)
conn.ReadMessage()
conn.WriteMessage()

The first request arrives as normal HTTP. Upgrade() completes the WebSocket handshake; after that, conn speaks the WebSocket protocol—not http.ResponseWriter body writes for application data.

Inside handleWebSocket:

  • var upgrader = websocket.Upgrader{} is enough for this echo server—zero buffer sizes let Gorilla use buffers from the HTTP server.
  • upgrader.Upgrade(w, req, nil) promotes the HTTP connection to WebSocket. Use websocket.Upgrader (not the deprecated package-level websocket.Upgrade).
  • defer conn.Close() releases the connection when the handler returns.
  • ReadMessage() returns the message type (TextMessage, BinaryMessage, etc.) and payload bytes.
  • WriteMessage(messageType, message) echoes the same type and payload.

conn.SetReadLimit(1 << 20) limits each incoming message to 1 MiB.

A read error normally ends the loop. websocket.IsUnexpectedCloseError logs only abnormal disconnects—not a peer that sent a normal close frame.


Create a WebSocket client in Go

The golang WebSocket client dials the server, sends one text message, and prints the echo.

Save this as client.go:

go
package main

import (
	"log"

	"github.com/gorilla/websocket"
)

func main() {
	log.SetFlags(0)

	url := "ws://localhost:8080/ws"
	conn, _, err := websocket.DefaultDialer.Dial(url, nil)
	if err != nil {
		log.Fatal("dial:", err)
	}
	defer conn.Close()

	log.Printf("connected to %s", url)

	message := []byte("Hello from client")
	if err := conn.WriteMessage(websocket.TextMessage, message); err != nil {
		log.Fatal("write:", err)
	}
	log.Printf("sent: %s", message)

	_, reply, err := conn.ReadMessage()
	if err != nil {
		log.Fatal("read:", err)
	}
	log.Printf("received: %s", reply)

	if err := conn.WriteMessage(
		websocket.CloseMessage,
		websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
	); err != nil {
		log.Fatal("close:", err)
	}
}

websocket.DefaultDialer.Dial() (or DialContext when you need cancellation) is the standard client entry point. The URL uses the ws:// scheme for local, non-TLS connections.

Run the client and test the connection

With the server still running in Terminal 1, open Terminal 2 in the same directory:

bash
go run client.go
output
connected to ws://localhost:8080/ws
sent: Hello from client
received: Hello from client

Back in Terminal 1, the server prints client connected and received: Hello from client with no error line—the client sends a normal close frame before exiting.

log.SetFlags(0) at the start of each main() strips the default date/time prefix from log output so the transcripts above match what you see when you copy the examples.

That end-to-end path—go run server.go plus go run client.go—is enough to verify your go WebSocket example.


Send text, binary, and JSON messages

ReadMessage and WriteMessage use a message type constant:

Message type Use
websocket.TextMessage UTF-8 text
websocket.BinaryMessage Raw binary data
websocket.CloseMessage Close control frame
websocket.PingMessage Ping control frame
websocket.PongMessage Pong response

You do not need separate programs for each type—pick TextMessage or BinaryMessage for application data.

Send and receive JSON

For APIs, JSON is the usual payload. Define a struct and use the connection methods WriteJSON and ReadJSON (not the deprecated package-level websocket.WriteJSON / websocket.ReadJSON):

go
type Message struct {
	User string `json:"user"`
	Text string `json:"text"`
}

msg := Message{User: "alice", Text: "hello"}
if err := conn.WriteJSON(msg); err != nil {
	log.Println("write json:", err)
}

var incoming Message
if err := conn.ReadJSON(&incoming); err != nil {
	log.Println("read json:", err)
}

WriteJSON sends a TextMessage frame with marshaled JSON. ReadJSON unmarshals the next JSON text message into your struct.


Close and keep WebSocket connections alive

Close the connection

Always release connections. defer conn.Close() closes the underlying network connection when the function exits.

To initiate a graceful WebSocket close, send a close control frame before closing the underlying connection:

go
if err := conn.WriteMessage(
	websocket.CloseMessage,
	websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
); err != nil {
	log.Println("close:", err)
}

A complete close handshake requires handling or waiting for the peer's corresponding close frame. Gorilla processes received close frames through its close handler and read methods.

Common status codes:

Code Meaning
1000 Normal closure
1001 Endpoint going away
1006 Abnormal closure reported locally; not sent as a close frame

Use websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) to separate expected disconnects from failures.

Ping and pong

Long-lived connections can die without a clean close frame. Deadlines plus ping/pong help detect silent peers. The following fragment assumes time and log are already imported. Periodically send a ping with WriteControl:

go
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetPongHandler(func(string) error {
	conn.SetReadDeadline(time.Now().Add(60 * time.Second))
	return nil
})

if err := conn.WriteControl(
	websocket.PingMessage,
	nil,
	time.Now().Add(10*time.Second),
); err != nil {
	log.Println("ping:", err)
}

Gorilla allows only one concurrent caller of its regular write methods such as WriteMessage and WriteJSON. WriteControl is an exception and may be called concurrently with other methods.

Gorilla processes ping, pong, and close control frames while the application reads from the connection. A connection that only writes still needs a read loop (or another reader) so control frames are handled.

ws:// versus wss://

Scheme Meaning
ws:// WebSocket without TLS
wss:// WebSocket over TLS

Local examples use ws://localhost:8080/ws. A page loaded over HTTPS normally needs a wss:// endpoint in production. This article does not walk through TLS certificate setup.


Common Gorilla WebSocket errors and mistakes

Problem Cause / fix
websocket: bad handshake The endpoint did not complete a valid WebSocket upgrade—check URL, route, and that the server returned 101 Switching Protocols
HTTP 403 during upgrade CheckOrigin rejected the browser Origin header
concurrent write to websocket connection More than one goroutine called WriteMessage / WriteJSON on the same connection
Connection closes unexpectedly Handle read errors and close frames; use ping/pong and read deadlines on long-lived sockets
ws:// works locally but fails in production HTTPS pages require wss:// for secure WebSocket
Client connects but receives nothing Keep reading the connection—WebSocket messages are not HTTP responses
Browser cannot connect but Go client works Check Origin handling and the WebSocket URL path
Large messages consume too much memory Set conn.SetReadLimit(n) when message size must be bounded

Concurrency: Gorilla documents one concurrent reader and one concurrent writer per connection for regular read/write methods. Only one goroutine may call WriteMessage or WriteJSON at a time; WriteControl and Close may run concurrently with other methods. Broadcasting or chat rooms still need a single writer goroutine (or a mutex) for application messages—not demonstrated here, but that rule is why concurrent write appears in production logs.

Origin handling: When CheckOrigin is nil, Gorilla rejects browser requests whose Origin host differs from the request Host. That is safer than blindly returning true in every tutorial. Set a custom CheckOrigin only when you intentionally allow cross-origin browser clients:

go
upgrader := websocket.Upgrader{
	CheckOrigin: func(req *http.Request) bool {
		return req.Header.Get("Origin") == "https://app.example.com"
	},
}

Summary

You now have a minimal golang WebSocket server on /ws and a golang WebSocket client that dial, send, and receive text over Gorilla WebSocket. The important shift from plain HTTP is the upgrade handshake: after Upgrade(), the same TCP connection carries WebSocket frames instead of request/response cycles, which is why persistent chat and live updates fit this model.

The pitfalls that appear right after a working echo are predictable: cross-origin browser upgrades, concurrent writes from multiple goroutines, and connections that stop responding without a close frame. Closing with CloseMessage, using read deadlines, and ping/pong address lifecycle issues; CheckOrigin and wss:// matter when you move from go run on localhost to browsers on HTTPS.

Use WebSockets when the client and server need ongoing bidirectional communication or server-pushed updates over one persistent channel—not when occasional REST calls are enough. For HTTP basics that underpin the upgrade handshake, see Golang HTTP server; for raw byte streams without WebSocket framing, see Golang TCP server and client.


References


Frequently Asked Questions

1. Which WebSocket library should I use in Go?

github.com/gorilla/websocket remains stable and widely used (v1.5.3). github.com/coder/websocket is another actively maintained option with a context-oriented API. This guide uses Gorilla for all examples.

2. Why does my browser get HTTP 403 on WebSocket upgrade?

Gorilla rejects cross-origin browser requests when the Origin host differs from the request Host unless you set CheckOrigin. A Go client dialing ws://localhost usually works because it does not send Origin the same way.

3. What causes concurrent write to websocket connection?

Gorilla allows one concurrent reader and one concurrent writer per connection. Two goroutines calling WriteMessage or WriteJSON at the same time trigger that error—use a single writer goroutine or a mutex around writes.

4. When should I use ws:// versus wss://?

ws:// is fine for local development. Pages loaded over HTTPS normally require wss:// (WebSocket over TLS) in production.
Tuan Nguyen

Data Scientist

Proficient in Golang, Python, Java, MongoDB, Selenium, Spring Boot, Kubernetes, Scrapy, API development, Docker, Data Scraping, PrimeFaces, Linux, Data Structures, and Data Mining. With expertise spanning these technologies, he develops robust solutions and implements efficient data processing and management strategies across various projects and platforms.

  • Go (programming language)
  • Python (programming language)
  • Java (programming language)
  • MongoDB
  • Kubernetes