{} The Go Reference

Apis · Web · Intermediate

WebSockets

Full-duplex, persistent connections over one TCP socket — the HTTP Upgrade handshake, frames, ping/pong keepalives, and when to choose them over SSE or polling.

Apis Intermediate ⏱ 6 min read Complete

☎️ Analogy

Plain HTTP is like sending a letter and waiting for a reply: one request, one response, then the line drops. A WebSocket is like keeping a phone call open. After a quick “can we switch to a call?” (the handshake), the line stays connected and either side can speak at any time — no redialing for every sentence. That open, two-way line is exactly what live chat, dashboards, and games need.

The upgrade handshake

A WebSocket doesn’t start as its own protocol — it borrows an HTTP request to bootstrap. The client sends an ordinary GET carrying Connection: Upgrade and Upgrade: websocket. If the server agrees, it replies 101 Switching Protocols, and from that instant the same TCP connection stops speaking HTTP and starts exchanging WebSocket frames in both directions.

sequenceDiagram
participant C as Browser / Client
participant S as Server
C->>S: GET /ws  (Upgrade: websocket)
S-->>C: 101 Switching Protocols
Note over C,S: same TCP socket, now full-duplex
C->>S: frame: "hello"
S-->>C: frame: "welcome"
S-->>C: frame: "live update"
C->>S: ping
S-->>C: pong

A server in Go

The standard library handles the HTTP side but not the WebSocket protocol itself, so you reach for a small third-party library (here nhooyr.io/websocket, also published as github.com/coder/websocket). The handler accepts the upgrade, then reads and writes typed messages in a loop. Messages travel as frames — each carries a small header plus a payload, and a frame is either text or binary.

package main

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

	"github.com/coder/websocket" // third-party — run locally, needs a network
)

func wsHandler(w http.ResponseWriter, r *http.Request) {
	// upgrade this HTTP request to a WebSocket
	conn, err := websocket.Accept(w, r, nil)
	if err != nil {
		return
	}
	defer conn.Close(websocket.StatusInternalError, "closing")

	ctx := r.Context()
	for {
		// Read blocks until the client sends a frame
		typ, data, err := conn.Read(ctx)
		if err != nil {
			return // client went away or the connection closed
		}
		log.Printf("got %d bytes (type %v)", len(data), typ)

		// echo it straight back — full-duplex: we can write any time
		if err := conn.Write(ctx, websocket.MessageText, data); err != nil {
			return
		}
	}
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/ws", wsHandler)
	srv := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  0, // a live socket may stay idle between messages
		WriteTimeout: 10 * time.Second,
	}
	log.Fatal(srv.ListenAndServe())
}

var _ = context.Background

ℹ️ Why this isn't a Playground

WebSockets need a real listening socket and a long-lived TCP connection, plus a third-party library — none of which run in the in-browser sandbox. The code here is display-only; drop it into a Go module locally to try it.

Keepalives: ping and pong

A connection can sit silent for minutes between messages, and idle TCP connections get reaped by proxies, load balancers, and NAT timeouts. WebSockets solve this with built-in ping/pong control frames: one side sends a ping, the other automatically answers with a pong. If a ping goes unanswered within a deadline, you know the peer is gone and can close.

// send a ping with a deadline; an unanswered ping means a dead peer
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := conn.Ping(ctx); err != nil {
	conn.Close(websocket.StatusPolicyViolation, "no pong")
	return
}

Pair this with a heartbeat (ping every N seconds) so dead connections are detected promptly instead of lingering and leaking goroutines.

The broadcast hub, in-process

A chat or live-dashboard server needs to push one message to every connected client. Since a connection can’t be written from two goroutines, the pattern is: each client has a buffered send channel drained by its own write-pump, and a central hub fans each broadcast onto every channel. That fan-out is pure goroutines and channels — no socket needed to run it:

hub.go — editable & runnable
package main

import (
"fmt"
"sort"
)

func main() {
// Each connected client gets its own buffered "send" channel —
// in a real server, a write-pump goroutine drains it to the socket.
names := []string{"alice", "bob", "cleo"}
send := map[string]chan string{}
for _, n := range names {
	send[n] = make(chan string, 8)
}

// The hub's job: fan one message out to every client's channel.
broadcast := func(msg string) {
	for _, ch := range send {
		select {
		case ch <- msg:
		default: // slow client: drop or disconnect instead of blocking the hub
		}
	}
}

broadcast("welcome")
broadcast("live update")

// Each client independently drains its queue (its write-pump would do this).
sort.Strings(names)
for _, n := range names {
	close(send[n])
	var got []string
	for m := range send[n] {
		got = append(got, m)
	}
	fmt.Printf("%-6s received: %v\n", n, got)
}
}

The key safety move is the default case: if one client’s buffer is full (a slow consumer), the hub doesn’t block the broadcast for everyone else — it drops or disconnects that client. A real hub runs this loop in a single goroutine that owns the client set (so no locks), with register/unregister channels for joins and leaves.

WebSocket vs SSE vs polling

Pick the lightest tool that fits the direction of your data:

NeedBest fit
Two-way, low-latency (chat, games, collab editing)WebSocket
Server → client only (notifications, live tickers)SSE (plain HTTP, auto-reconnect)
Occasional, infrequent updatesPolling (simple GET on a timer)

WebSockets are the most capable but also the most to operate (sticky sessions, custom proxy config, manual reconnection). Don’t reach for them when a one-way SSE stream or a periodic poll would do.

⚠️ Concurrency, origins, and timeouts will bite you

A WebSocket handler runs in its own goroutine and a connection isn’t safe for concurrent writers — funnel all writes through one goroutine or a channel, or you’ll corrupt frames. Always check the Origin header (or use the library’s origin option) so a malicious site can’t open a socket to your server on a logged-in user’s behalf. And since a live socket may stay idle for long stretches, disable or lengthen the server’s read timeout for the upgraded connection, and rely on ping/pong plus a per-message deadline to detect a dead peer instead.

See also

Next: the simpler one-way alternative — Server-Sent Events.

Check your understanding

Score: 0 / 5

1. How does a WebSocket connection begin?

A client sends a normal HTTP GET with Connection: Upgrade and Upgrade: websocket. The server replies 101 Switching Protocols, and the same TCP connection is then reused for bidirectional WebSocket frames — no new socket, no new port.

2. What is the key advantage of a WebSocket over repeated HTTP polling?

After the handshake the socket stays open, so either side can push a message instantly with only a small frame header — no new request/response, headers, or connection setup each time. That's far cheaper than polling for live updates.

3. When is Server-Sent Events (SSE) a better fit than a WebSocket?

SSE is one-way (server → client) over a normal HTTP response, with built-in reconnection and simpler infrastructure. Choose a WebSocket when you genuinely need both directions; choose SSE for one-way feeds and polling for occasional, infrequent checks.

4. A WebSocket connection is not safe for concurrent writes. How do you broadcast to many clients?

Two goroutines writing the same conn corrupt frames. The idiomatic design: one goroutine per connection owns its writes (a write-pump) and reads from a per-client buffered channel; a central hub goroutine owns the client set and pushes each broadcast onto every client's channel.

5. Why must a WebSocket server check the Origin header on the upgrade request?

The browser sends cookies on the upgrade request, but (unlike fetch) WebSockets aren't constrained by CORS. If you don't verify Origin, any website can script a connection to your server as the victim — CSWSH. Allow-list expected origins (or use your library's origin option).

Comments

Sign in with GitHub to join the discussion.