Skip to content
gohacks.dev
APIs

WebSocket APIs in Go With Protocol Design and Reconnects

Design a WebSocket protocol in Go: a message envelope, request ids, sequence numbers for resuming after a dropped connection, close codes and versioning.

13 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

A WebSocket is a TCP connection with a handshake and a framing layer. It says nothing about what the messages mean. That is its strength and the reason so many WebSocket features are painful after the first release: every team invents a protocol in a hurry, discovers that connections drop, and bolts on resumption, versioning and error handling one incident at a time.

Tested with Go 1.25.6, coder/websocket v1.8.13, macOS on Apple Silicon (arm64)

This article designs the protocol up front. It builds a small server in Go that has a message envelope, request and reply correlation, resumable sessions, a size limit, and version negotiation, then drives it with a client through the cases that go wrong. For library-level basics (upgrading, reading, a chat hub) read the WebSocket introduction first. If one-way updates are enough, server-sent events are simpler and survive proxies better. The code uses github.com/coder/websocket and ran on Go 1.25.6.

The connection is disposable, the session is not

The central design decision: a WebSocket connection will drop. Phones change networks, laptops sleep, load balancers recycle, deployments restart servers. If your protocol treats the connection as the unit of state, every drop loses whatever was in flight. If it treats a session as the unit, with the connection as a temporary pipe to it, a drop becomes a pause.

That means three things in the protocol:

  1. The server assigns a session id in its first message.
  2. Server-originated events carry a sequence number.
  3. A reconnecting client presents the session id and the last sequence number it processed, and the server replays the rest.

Everything else in this article follows from taking that seriously.

The envelope

Every message in both directions is one JSON object with a fixed outline:

JSON
{"type":"echo","id":"r1","data":"hi"}

type names what the message is. id is chosen by the client on a request and copied onto the reply, so a client with many requests in flight can tell which answer belongs to which. seq is set by the server on events, so a client can resume. data is the payload, left as raw JSON for the handler that understands the type.

Why an envelope and not "whatever the feature needs"? Because the generic parts (correlation, ordering, errors) are written once and every message type inherits them. It is the same argument as a standard response structure for REST.

Use text frames with JSON until you have a measured reason not to. Binary frames with protobuf are fine later, and a subprotocol name is how you introduce them without breaking old clients.

Version negotiation at the handshake

The WebSocket handshake lets a client list subprotocols in Sec-WebSocket-Protocol and lets the server choose one. Use it for your API version: app.v1, app.v2. The client says what it speaks, the server picks or refuses, and both know what the messages mean before any are sent. Putting a version in the URL also works; the subprotocol has the advantage of being part of the protocol and not the routing.

The lab server accepts only app.v1. If a client connects without offering it, the server closes with StatusPolicyViolation and a reason. That is better than guessing: a client of a different protocol would otherwise get confusing errors three messages later.

The server

The server side is two parts: a session store with an outbox, and the HTTP handler that upgrades and runs the read loop.

server.goGo
package main

import (
	"context"
	"encoding/json"
	"net/http"
	"strconv"
	"sync"
	"time"

	"github.com/coder/websocket"
	"github.com/coder/websocket/wsjson"
)

// Msg is the envelope for everything on the socket, in both directions.
type Msg struct {
	Type string          `json:"type"`
	ID   string          `json:"id,omitempty"`  // set by the client on a request, echoed on its reply
	Seq  int             `json:"seq,omitempty"` // set by the server on events, so a client can resume
	Data json.RawMessage `json:"data,omitempty"`
}

type session struct {
	mu     sync.Mutex
	outbox []Msg // events kept for replay; bounded in a real system
	seq    int
	live   *websocket.Conn
}

type Server struct {
	mu       sync.Mutex
	sessions map[string]*session
}

func NewServer() *Server { return &Server{sessions: map[string]*session{}} }

// Publish stores an event for a session and sends it if the client is connected right now.
func (s *Server) Publish(id, typ string, data any) {
	s.mu.Lock()
	sess := s.sessions[id]
	s.mu.Unlock()
	raw, _ := json.Marshal(data)
	sess.mu.Lock()
	defer sess.mu.Unlock()
	sess.seq++
	m := Msg{Type: typ, Seq: sess.seq, Data: raw}
	sess.outbox = append(sess.outbox, m)
	if sess.live != nil {
		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
		defer cancel()
		wsjson.Write(ctx, sess.live, m)
	}
}

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	c, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{"app.v1"}})
	if err != nil {
		return
	}
	defer c.CloseNow()
	if c.Subprotocol() != "app.v1" { // the client did not ask for a version we speak
		c.Close(websocket.StatusPolicyViolation, "subprotocol app.v1 required")
		return
	}
	c.SetReadLimit(1024) // a text frame bigger than this closes the connection with 1009

	ctx := r.Context()
	id := r.URL.Query().Get("session")
	lastSeq, _ := strconv.Atoi(r.URL.Query().Get("last_seq"))

	s.mu.Lock()
	sess, resumed := s.sessions[id]
	if !resumed {
		id = "s" + strconv.Itoa(len(s.sessions)+1)
		sess = &session{}
		s.sessions[id] = sess
	}
	s.mu.Unlock()

	sess.mu.Lock()
	sess.live = c
	hello, _ := json.Marshal(map[string]any{"session": id, "resumed": resumed})
	wsjson.Write(ctx, c, Msg{Type: "hello", Data: hello})
	for _, m := range sess.outbox { // replay in the same lock that publishes, so nothing is sent twice or skipped
		if m.Seq > lastSeq {
			wsjson.Write(ctx, c, m)
		}
	}
	sess.mu.Unlock()
	defer func() {
		sess.mu.Lock()
		if sess.live == c {
			sess.live = nil
		}
		sess.mu.Unlock()
	}()

	for {
		var in Msg
		if err := wsjson.Read(ctx, c, &in); err != nil {
			return
		}
		switch in.Type {
		case "echo":
			wsjson.Write(ctx, c, Msg{Type: "reply", ID: in.ID, Data: in.Data})
		default:
			e, _ := json.Marshal(map[string]string{"code": "unknown_type", "message": "no handler for " + in.Type})
			wsjson.Write(ctx, c, Msg{Type: "error", ID: in.ID, Data: e}) // a bad message is an error reply, not a dropped socket
		}
	}
}

Things to notice:

  • Publish stores before it sends. The event goes into the outbox with its next sequence number whether or not a client is connected. If the write fails, the event is still there for the next connection.
  • The replay runs under the session lock. The handler holds the same lock Publish takes while it sends the missed events, then releases it. A concurrent Publish waits and then sends live. Without that, an event could be replayed and also sent live (twice), or fall between the two and be lost.
  • SetReadLimit(1024). The library default is 32 KiB. Set a limit that fits your messages, because an unbounded read is a free memory attack. Exceeding it closes the connection with 1009.
  • Unknown message types get an error reply. The connection is not dropped. A client bug that sends a bad message should produce a clear error that the client can log, not a reconnect loop.
  • Accept checks the Origin by default and rejects cross-origin browsers unless you list patterns in OriginPatterns. Keep that default, and only widen it deliberately.

The outbox here grows forever, which a real server cannot allow. Keep the last N events or the last few minutes, and when a client asks to resume from before the oldest event, send a reset message telling it to reload from the REST API, as in the SSE design.

Driving it

The program starts the server, connects, makes a request, causes an error, receives events, drops the connection, publishes more events while the client is away, reconnects with the session, then trips the two protocol limits.

main.goGo
package main

import (
	"context"
	"fmt"
	"net/http/httptest"
	"strings"
	"time"

	"github.com/coder/websocket"
	"github.com/coder/websocket/wsjson"
)

func dial(ctx context.Context, url string, protos ...string) *websocket.Conn {
	c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{Subprotocols: protos})
	if err != nil {
		panic(err)
	}
	return c
}

func read(ctx context.Context, c *websocket.Conn) string {
	var m Msg
	if err := wsjson.Read(ctx, c, &m); err != nil {
		return "closed: " + err.Error()
	}
	return fmt.Sprintf("type=%s id=%s seq=%d data=%s", m.Type, m.ID, m.Seq, m.Data)
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	app := NewServer()
	srv := httptest.NewServer(app)
	defer srv.Close()
	url := "ws" + strings.TrimPrefix(srv.URL, "http")

	c := dial(ctx, url, "app.v1")
	fmt.Println(read(ctx, c))
	wsjson.Write(ctx, c, Msg{Type: "echo", ID: "r1", Data: []byte(`"hi"`)})
	fmt.Println(read(ctx, c))
	wsjson.Write(ctx, c, Msg{Type: "launch", ID: "r2"})
	fmt.Println(read(ctx, c))

	for i := 1; i <= 3; i++ {
		app.Publish("s1", "tick", i)
	}
	var last Msg
	for i := 0; i < 3; i++ {
		fmt.Println(read(ctx, c))
		last.Seq = i + 1
	}

	c.CloseNow() // the network drops
	for i := 4; i <= 6; i++ {
		app.Publish("s1", "tick", i) // stored while nobody is listening
	}

	c = dial(ctx, fmt.Sprintf("%s?session=s1&last_seq=%d", url, last.Seq), "app.v1")
	fmt.Println("resumed from seq", last.Seq)
	for i := 0; i < 4; i++ {
		fmt.Println(read(ctx, c))
	}

	wsjson.Write(ctx, c, Msg{Type: "echo", ID: "big", Data: []byte(`"` + strings.Repeat("x", 4000) + `"`)})
	fmt.Println("oversize message:", read(ctx, c))

	bad := dial(ctx, url) // no subprotocol offered
	fmt.Println("no subprotocol:", read(ctx, bad))
}
Text
type=hello id= seq=0 data={"resumed":false,"session":"s1"}
type=reply id=r1 seq=0 data="hi"
type=error id=r2 seq=0 data={"code":"unknown_type","message":"no handler for launch"}
type=tick id= seq=1 data=1
type=tick id= seq=2 data=2
type=tick id= seq=3 data=3
resumed from seq 3
type=hello id= seq=0 data={"resumed":true,"session":"s1"}
type=tick id= seq=4 data=4
type=tick id= seq=5 data=5
type=tick id= seq=6 data=6
oversize message: closed: failed to read JSON message: failed to get reader: received close frame: status = StatusMessageTooBig and reason = "read limited at 1025 bytes"
no subprotocol: closed: failed to read JSON message: failed to get reader: received close frame: status = StatusPolicyViolation and reason = "subprotocol app.v1 required"

What the run shows

The first message is hello, carrying the session id s1 and resumed: false. A client stores the id.

The echo request with id r1 got a reply with the same id. The launch message, which the server has no handler for, got an error reply with code unknown_type on id r2, and the connection stayed open. Errors are replies.

Three tick events arrived with seq 1 to 3. The client then dropped the connection. The server published ticks 4 to 6 into the void.

The client reconnected with session=s1&last_seq=3. The server answered hello with resumed: true and then sent ticks 4, 5 and 6, in order. The client never knew the connection had been replaced, apart from the hello.

The 4000 byte message came back as StatusMessageTooBig with the reason read limited at 1025 bytes. A client without the subprotocol was closed with StatusPolicyViolation. Both are close codes from RFC 6455, and the client can act on a code, which beats a connection that just goes away.

Close codes worth using

CodeNameUse
1000Normal closureClean, expected end
1001Going awayServer shutting down or page navigating; clients should reconnect
1008Policy violationBad protocol version, failed authorisation
1009Message too bigOver the read limit
1011Internal errorUnexpected server fault
4000 to 4999ApplicationYour own, such as "token expired, refresh then reconnect"

On deployment, close connections with 1001 as part of graceful shutdown. Clients that see it reconnect immediately to a healthy instance, which reads better in your logs than a thousand abnormal closures.

Reconnecting on the client

The client owns reconnection. A good loop has four properties:

  • Exponential backoff starting around one second and capped at 30 to 60 seconds.
  • Jitter, a random fraction of the delay, so a thousand clients cut off by the same restart do not all return in the same second and knock the server over again.
  • A reset of the backoff after a connection has been healthy for a while.
  • The resume token: session id and last processed seq, sent as query parameters or in the first message.

A TypeScript client does this in thirty lines; a Go client is the same loop around websocket.Dial. Whatever the language, store lastSeq only after the event has been applied, not when it arrived, so a crash between the two does not skip an event.

Heartbeats

Idle connections get closed by proxies and NATs, and a client that lost its network may not find out for minutes because TCP is patient. Send WebSocket ping frames from one side and expect pongs. The library's Ping(ctx) method sends one and waits for the pong, so a loop with a short timeout detects a dead peer and closes. Pick an interval below the shortest idle timeout on the path, usually 20 to 30 seconds. Application level pings work too, and have the advantage of being visible to JavaScript, which cannot see protocol pings in browsers.

Authentication

Browsers cannot set an Authorization header on a WebSocket. Options, from most to least preferred:

  1. A session cookie on the same site, with the Origin check on. This is the least awkward for browser apps.
  2. A short-lived, single-use ticket that the client fetches from an authenticated REST endpoint and presents in the URL or the first message. It limits what a leaked URL can do.
  3. A token in the first message, closing the socket if it is not valid within a few seconds.

Avoid long-lived tokens in query strings, since they reach access logs. Re-check authorisation when a session resumes, and close with a 4xxx code when the token expires so the client knows to refresh. The general token trade-offs are in API authentication.

Scaling past one process

The lab keeps sessions in memory, which works for one instance. With several, a reconnect may reach a different process than the one holding the session. Choose between sticky routing (a load balancer pins by session id) and shared state (the outbox in Redis or a database, events fanned out through pub/sub). Sticky routing is simpler and loses sessions when an instance dies. Shared state survives restarts and costs a network hop per publish. Decide how much loss you can tolerate and say so in the protocol documentation.

Slow consumers and backpressure

A client on a bad link reads slowly, and a server that writes to it blocks. In the lab Publish writes inline with a one second timeout, which is fine for a demonstration and wrong for a busy system, because one stuck client delays every publisher that touches it.

The standard fix is a bounded queue per connection and a writer goroutine that drains it. Publish pushes into the queue without waiting. If the queue is full, the client is too far behind to catch up in real time, and you have two honest options: drop it and let it resume from its sequence number, or drop the oldest queued events and send a reset first. Either is better than letting memory grow, and since the outbox already holds the events, dropping the connection costs the client nothing it cannot recover.

Measure the queue depth and the number of forced disconnects. A rising count means your events are too large, your clients too slow or your fan-out too wide, and it is cheaper to see that on a graph than in an incident.

Fan-out and rooms

Most real WebSocket features are one-to-many: a chat room, a document, a dashboard. Keep a registry from room id to the set of sessions, and publish to the room by iterating the set. Take a snapshot of the set under a read lock and send outside it, so a slow write never holds the registry.

Give rooms the same sequence discipline as sessions. A per-room sequence lets a client resume with room=42&last_seq=1180 after any drop, and it makes every event in a room totally ordered, which clients need to apply edits correctly. If a client can be in several rooms, track the last sequence per room in the resume token.

Authorise at join time and again at publish time for rooms where permissions can change. A user removed from a private room should stop receiving its events at once, not at the next reconnect.

Shutdown without a thundering herd

During a deployment every connected client loses its socket within a few seconds. If all of them reconnect immediately, the new instance receives the whole fleet at once, which is the moment it is coldest. Two measures help. The server closes connections gradually, a few percent at a time over a minute, with code 1001. And the client adds jitter to its first reconnect delay, not only the later ones.

In Go, shut down by cancelling the base context given to the HTTP server and calling Close(websocket.StatusGoingAway, "restarting") on each connection, then waiting for the handlers to return. A handler loop that exits when the context ends takes care of the rest.

Testing

The pattern in main is already a test: start with httptest, dial, drive, assert on the messages. Turn each printed line into an assertion. Add cases for a client that never reads (does the server cut it off?), a client that reconnects with a last_seq newer than the server has, and shutdown with connected clients. Run with -race, because the interesting bugs here are between the publish path and the connection path. The WebSocket protocol is specified in RFC 6455, and the library documentation lists the options used above.

Also inNetworking
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.