Extra guides

WebSockets

Upgrade HTTP requests and exchange bidirectional browser messages with lazywebsocket.

By Guillermo Alvarez - Published Updated

Use WebSockets when the browser and server both need to send messages over one long-lived connection. For one-way server-to-browser updates, start with Server-Sent Events. For request-response HTML updates, start with Turbo Frames or Server-Rendered Updates.

Register an endpoint

WebSocket endpoints are plain HTTP handlers because the protocol starts as an HTTP request and then upgrades the connection:

package appinit

import (
    "net/http"

    "golazy.dev/lazyroutes"
    "myapp/app/realtime"
)

func Draw(router *lazyroutes.Scope) {
    router.HandleFunc(http.MethodGet, "/chat/socket", realtime.ChatSocket)
}

Keep the route separate from controller actions that render HTML. Once the connection is upgraded, normal response buffering, view rendering, and Turbo frame rendering no longer apply.

Upgrade the request

Use golazy.dev/lazywebsocket from the handler:

package realtime

import (
    "fmt"
    "net/http"

    "golazy.dev/lazycontroller"
    "golazy.dev/lazywebsocket"
)

var chatUpgrader = lazywebsocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
}

func ChatSocket(w http.ResponseWriter, r *http.Request) error {
    if !lazywebsocket.IsWebSocketUpgrade(r) {
        return lazycontroller.Error(
            http.StatusBadRequest,
            fmt.Errorf("websocket upgrade required"),
        )
    }

    conn, err := chatUpgrader.Upgrade(w, r, nil)
    if err != nil {
        return nil
    }
    defer conn.Close()

    for {
        messageType, payload, err := conn.ReadMessage()
        if err != nil {
            return nil
        }
        if messageType != lazywebsocket.TextMessage {
            continue
        }
        if err := conn.WriteMessage(lazywebsocket.TextMessage, payload); err != nil {
            return nil
        }
    }
}

Upgrade writes the HTTP handshake response. If it fails, return nil after the upgrader has handled the response.

Connect from the browser

Choose the WebSocket scheme from the current page:

const scheme = location.protocol === "https:" ? "wss:" : "ws:"
const socket = new WebSocket(`${scheme}//${location.host}/chat/socket`)

socket.addEventListener("message", (event) => {
  console.log("socket message", event.data)
})

socket.addEventListener("open", () => {
  socket.send("hello")
})

When the app runs behind a reverse proxy, the proxy must pass the Upgrade and Connection headers through to the GoLazy app process.

Guard the origin

Browsers can try to open WebSockets across origins. lazywebsocket.Upgrader uses a safe default when CheckOrigin is nil: if an Origin header is present, the origin host must match the request host. Set CheckOrigin only when the app intentionally allows a different browser origin:

var chatUpgrader = lazywebsocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        return r.Header.Get("Origin") == "https://admin.example.com"
    },
}

Avoid the deprecated package-level lazywebsocket.Upgrade helper for browser routes because it does not perform origin checking.

Keep reads and writes disciplined

A WebSocket connection supports one concurrent reader and one concurrent writer. If the app has independent inbound and outbound loops, keep all writes behind one goroutine or one channel-owned writer.

Read from the connection even when the app mostly sends messages. Reads process close, ping, and pong control frames. If an unexpected close matters for debugging, classify it explicitly:

if lazywebsocket.IsUnexpectedCloseError(
    err,
    lazywebsocket.CloseGoingAway,
    lazywebsocket.CloseNormalClosure,
) {
    return fmt.Errorf("read chat socket: %w", err)
}

Use application services for fanout, authorization, persistence, and replay. The WebSocket handler should own the connection lifecycle and delegate domain work to those services.