← Voltar para o blog

WebSocket em Go: coder/websocket vs Gorilla em Produção

Aprenda WebSocket em Go com coder/websocket e Gorilla: upgrade HTTP, leitura, escrita, ping, limites, autenticação, concorrência e escala em produção.

Para implementar WebSocket em Go em 2026, use uma biblioteca mantida sobre net/http, mantenha uma única goroutine de escrita por conexão e trate autenticação, limites e clientes lentos como requisitos desde o início. Para projeto novo, coder/websocket é uma escolha enxuta e integrada a context.Context; ela é a continuação da biblioteca antes chamada nhooyr/websocket. gorilla/websocket continua madura e faz sentido em sistemas que já usam sua API.

WebSocket vale a pena quando existe comunicação realmente bidirecional: chat, presença online, colaboração, dashboards interativos, jogos, terminais remotos ou notificações que também recebem comandos do cliente. Se o navegador só precisa receber eventos, Server-Sent Events (SSE) pode ser mais simples. Este guia mostra servidor, cliente, concorrência, ping, autenticação, backpressure, observabilidade e escala — o caminho entre um demo que funciona e um serviço confiável.

Se sua base HTTP ainda não está pronta, leia o tutorial de API REST em Go, o guia de middleware com net/http e Chi e o comparativo de frameworks HTTP em Go.

Resposta rápida: coder/websocket, Gorilla, SSE ou polling?

NecessidadeEscolha inicial
Projeto Go novo, API pequena e uso forte de context.Contextcoder/websocket
Sistema existente já padronizado em Gorillagorilla/websocket
Servidor envia eventos; navegador não envia mensagens pelo canalSSE
Atualização rara e tolerância a alguns segundosPolling HTTP
Comunicação bidirecional contínua e baixa latênciaWebSocket
Cliente não é navegador e o contrato é RPC internoAvalie gRPC streaming

A decisão não deve nascer de um benchmark de “echo server”. Em produção, o custo costuma estar em serialização, rede, fan-out, filas, autenticação, persistência e clientes lentos. A biblioteca importa, mas o desenho do ciclo de vida importa mais.

Como WebSocket se encaixa no net/http

Uma conexão começa como uma requisição HTTP normal. Cliente e servidor negociam um upgrade para WebSocket; depois do handshake, a conexão passa a transportar frames de texto, binários, ping, pong e close. Isso significa que sua rota de upgrade ainda participa das preocupações HTTP:

  • TLS (wss://) no proxy ou na aplicação;
  • cookies, sessão e cabeçalhos;
  • validação de Origin para clientes de navegador;
  • rate limit de novas conexões;
  • logs e tracing do handshake;
  • timeout antes do upgrade;
  • readiness e graceful shutdown do servidor.

Depois do upgrade, porém, não existe uma nova requisição HTTP para cada mensagem. Autorização, limites, deadline e telemetria por operação precisam ser modelados no protocolo da aplicação.

Servidor mínimo com coder/websocket

O exemplo abaixo cria uma rota de eco com limite de leitura, timeout por operação e fechamento explícito:

package main

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

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

type Message struct {
	Type string `json:"type"`
	Text string `json:"text"`
}

func websocketHandler(logger *slog.Logger) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
			// Em produção, mantenha a validação de Origin habilitada
			// e configure padrões explicitamente quando necessário.
		})
		if err != nil {
			logger.Warn("websocket upgrade recusado", "err", err)
			return
		}
		defer conn.Close(websocket.StatusNormalClosure, "conexão encerrada")

		conn.SetReadLimit(16 << 10) // 16 KiB por mensagem

		for {
			ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
			var incoming Message
			err := wsjson.Read(ctx, conn, &incoming)
			cancel()
			if err != nil {
				status := websocket.CloseStatus(err)
				logger.Info("websocket fechado", "status", status, "err", err)
				return
			}

			ctx, cancel = context.WithTimeout(r.Context(), 5*time.Second)
			err = wsjson.Write(ctx, conn, Message{
				Type: "echo",
				Text: incoming.Text,
			})
			cancel()
			if err != nil {
				logger.Warn("falha ao escrever websocket", "err", err)
				return
			}
		}
	}
}

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	mux := http.NewServeMux()
	mux.Handle("GET /ws", websocketHandler(logger))

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	logger.Info("servidor iniciado", "addr", srv.Addr)
	if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		logger.Error("servidor encerrado", "err", err)
		os.Exit(1)
	}
}

Instalação:

go get github.com/coder/websocket

O wsjson é conveniente para mensagens JSON pequenas. Para payload binário, streaming ou controle fino de alocação, use as operações de leitura e escrita da conexão diretamente. Não envie structs arbitrárias do domínio sem definir um contrato versionável.

Protocolo da aplicação: não mande “qualquer JSON”

WebSocket define transporte, não o formato das mensagens. Um contrato simples pode usar um envelope:

{
  "type": "chat.message.send",
  "request_id": "req_01J...",
  "version": 1,
  "payload": {
    "room_id": "room_123",
    "text": "Olá!"
  }
}

E a resposta:

{
  "type": "chat.message.accepted",
  "request_id": "req_01J...",
  "payload": {
    "message_id": "msg_456"
  }
}

Inclua pelo menos:

  1. type para roteamento explícito;
  2. versão quando o contrato precisará evoluir;
  3. correlation/request ID para resposta e observabilidade;
  4. payload limitado e validado;
  5. formato de erro previsível, sem stack trace para o cliente.

Aplique o mesmo rigor de uma API REST com contrato: documente tipos, tamanhos, autorização e compatibilidade. OpenAPI não descreve WebSocket diretamente tão bem quanto HTTP request/response, então um documento Markdown, JSON Schema, AsyncAPI ou tipos compartilhados podem fazer parte do contrato.

Concorrência: um reader e um writer por conexão

O erro mais comum em servidores WebSocket é permitir que várias goroutines escrevam diretamente na mesma conexão. Mesmo quando uma biblioteca oferece alguma proteção, a ordem de mensagens, backpressure e encerramento ficam difíceis de raciocinar.

Use uma estrutura de cliente com canal de saída limitado:

type Client struct {
	conn *websocket.Conn
	send chan Message
}

func (c *Client) writeLoop(ctx context.Context) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case msg, ok := <-c.send:
			if !ok {
				return nil
			}

			writeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
			err := wsjson.Write(writeCtx, c.conn, msg)
			cancel()
			if err != nil {
				return err
			}
		}
	}
}

Outras goroutines publicam com um select não bloqueante ou com timeout:

func (c *Client) enqueue(msg Message) bool {
	select {
	case c.send <- msg:
		return true
	default:
		return false // cliente lento; aplique a política definida
	}
}

Esse false exige uma decisão de produto:

  • descartar atualização substituível, como “digitando…”;
  • manter apenas o estado mais recente;
  • desconectar o cliente lento;
  • persistir o evento para recuperação posterior;
  • sinalizar ressincronização obrigatória.

Nunca deixe um cliente com rede ruim bloquear o broadcast de todos. O canal também não deve ser ilimitado: fila sem limite apenas transforma lentidão em consumo crescente de memória.

Para aprofundar o modelo, revise concorrência em Go, channels e goroutines em produção, worker pools e context com timeout e cancelamento.

Hub, salas e broadcast

Um chat ou dashboard normalmente mantém um hub com comandos de registro, remoção e publicação. Centralizar mutações em uma goroutine reduz locks e deixa a propriedade do estado clara:

type Hub struct {
	register   chan *Client
	unregister chan *Client
	broadcast  chan Message
	clients    map[*Client]struct{}
}

func (h *Hub) Run(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			return
		case client := <-h.register:
			h.clients[client] = struct{}{}
		case client := <-h.unregister:
			delete(h.clients, client)
			close(client.send)
		case msg := <-h.broadcast:
			for client := range h.clients {
				if !client.enqueue(msg) {
					delete(h.clients, client)
					close(client.send)
				}
			}
		}
	}
}

Em um sistema real, não faça broadcast global se as mensagens pertencem a salas, contas ou tenants. Indexe conexões por canal e confirme a autorização na inscrição e em cada mudança de sala. Identidade autenticada não significa permissão para observar qualquer recurso.

coder/websocket vs Gorilla WebSocket

As duas bibliotecas implementam o protocolo e funcionam com net/http, mas expõem modelos diferentes.

Critériocoder/websocketgorilla/websocket
HistóricoContinuação do antigo nhooyr/websocketProjeto tradicional do ecossistema Gorilla
ContextoAPI desenhada em torno de context.ContextDeadlines configuradas na conexão
EstiloAPI menor e idiomáticaAPI madura, explícita e muito conhecida
JSONPacote auxiliar wsjsonReadJSON e WriteJSON
Base existenteÓtima para adoção novaExcelente quando já padronizada
MigraçãoExige avaliar imports e comportamentoNão há razão para trocar só por moda

Um servidor equivalente com Gorilla começa assim:

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	CheckOrigin: func(r *http.Request) bool {
		return r.Header.Get("Origin") == "https://app.exemplo.com.br"
	},
}

func handler(w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		return
	}
	defer conn.Close()

	conn.SetReadLimit(16 << 10)
	for {
		var msg Message
		if err := conn.ReadJSON(&msg); err != nil {
			return
		}
		if err := conn.WriteJSON(msg); err != nil {
			return
		}
	}
}

Não copie esse eco como arquitetura final: adicione deadline, ping/pong, writer único, autenticação, logs e shutdown. Na Gorilla, leia com atenção a documentação sobre concorrência: a aplicação deve respeitar o número permitido de readers e writers concorrentes.

Recomendação prática: não migre uma base estável de Gorilla apenas para modernizar o nome da dependência. Para projeto novo, faça um spike pequeno com coder/websocket e valide proxy, cliente, limite, close codes e carga. Escolha a API que o time consegue operar corretamente.

Ping, pong e conexões zumbis

TCP aberto não garante que o cliente esteja utilizável. Celular troca de rede, notebook suspende, proxy encerra conexão ociosa e processo morre sem um close limpo. Você precisa detectar conexões zumbis.

A estratégia depende da biblioteca, mas o princípio é:

  1. envie ping em intervalo menor que o timeout do proxy;
  2. limite o tempo para receber pong ou concluir a operação;
  3. encerre e remova a conexão quando o prazo vencer;
  4. adicione jitter para não disparar todos os pings no mesmo milissegundo.

Não use ping a cada segundo sem necessidade. Além de tráfego, milhares de conexões sincronizadas criam picos de CPU. Documente os tempos considerando Cloudflare, load balancer, ingress Kubernetes e qualquer proxy entre cliente e aplicação.

O cliente também precisa de reconexão com exponential backoff + jitter. Reconectar imediatamente em loop durante um incidente produz uma tempestade que impede a recuperação. Se mensagens não podem ser perdidas, WebSocket sozinho não oferece durabilidade: use IDs, confirmação, replay ou uma fonte persistente.

Autenticação, Origin e segurança

WebSocket não remove as regras de segurança da web.

Autentique no handshake

Para navegador, prefira cookie de sessão HttpOnly, Secure e SameSite adequado, ou um ticket de conexão curto obtido previamente por HTTPS. Token em query string pode parar em access logs, histórico e ferramentas de observabilidade. Se não houver alternativa, use ticket descartável com expiração de segundos, não o token principal da conta.

Valide Origin

Um site malicioso aberto no navegador da vítima pode tentar iniciar uma conexão para seu domínio usando cookies existentes. Valide Origin contra uma allowlist. Não configure InsecureSkipVerify, CheckOrigin: return true ou equivalente apenas para “fazer funcionar” em produção.

Autorize cada ação

O usuário autenticado pode enviar subscribe, send_message ou admin.command. Valide o tipo, o payload e a permissão. Nunca confie em user_id enviado pelo cliente; derive a identidade da sessão associada à conexão.

Limite recursos

  • tamanho máximo de mensagem;
  • taxa de mensagens por conexão e por conta;
  • número de conexões por IP/usuário;
  • quantidade de inscrições em salas;
  • tempo máximo de escrita;
  • comprimento e profundidade do JSON.

Combine com rate limiting em Go, autenticação e autorização e o tutorial de segurança em aplicações Go.

Graceful shutdown sem derrubar tudo de uma vez

No deploy, o servidor deve parar de aceitar conexões novas, sinalizar encerramento para as existentes e aguardar um prazo limitado. Um fluxo saudável:

  1. readiness passa a falhar;
  2. load balancer remove a instância;
  3. novas conexões deixam de entrar;
  4. servidor envia close com motivo de reinício;
  5. writers terminam mensagens em andamento;
  6. conexões restantes são canceladas no deadline;
  7. processo fecha recursos e sai.

O cliente deve interpretar o fechamento e reconectar a outra instância com backoff. Evite depender de uma mensagem final obrigatória: a rede pode cair antes de entregá-la. Veja o guia completo de graceful shutdown em Go e os padrões de health checks.

Escala horizontal e múltiplas instâncias

Enquanto existe uma instância, um map em memória resolve salas pequenas. Com várias réplicas, clientes da mesma sala podem cair em pods diferentes. Você precisa de uma camada de distribuição:

  • Redis Pub/Sub para eventos efêmeros;
  • Redis Streams, NATS JetStream ou Kafka quando replay/durabilidade importam;
  • broker gerenciado para reduzir operação;
  • serviço dedicado de realtime quando o problema justificar.

Sticky session reduz mudanças de instância, mas não resolve broadcast entre pods, deploy, falha ou autoscaling. Também não transforme Redis Pub/Sub em garantia de entrega: se o consumidor estiver desconectado, o evento efêmero pode ser perdido. Para eventos importantes, use persistência e cursor.

Separe duas responsabilidades:

  1. conexão local: socket, fila limitada, ping, autenticação;
  2. distribuição: publicar eventos entre instâncias e recuperar estado.

O comparativo de Kafka, NATS, RabbitMQ e SQS ajuda a escolher; para replay com Redis, veja Redis Streams em Go.

Observabilidade que realmente ajuda

Não registre o payload inteiro de cada mensagem. Além de custo e ruído, isso pode vazar token, conversa privada e dados pessoais. Prefira métricas e logs estruturados:

Métricas:

  • conexões ativas por instância;
  • upgrades aceitos e recusados;
  • conexões por close code;
  • mensagens recebidas/enviadas por tipo;
  • bytes recebidos/enviados;
  • tamanho e ocupação das filas de saída;
  • clientes desconectados por lentidão;
  • latência de processamento;
  • erros do broker e reconexões.

Logs:

  • connection_id, user_id interno não sensível, tenant e instância;
  • motivo de rejeição do handshake;
  • close code e duração da conexão;
  • erro categorizado, não payload bruto;
  • request/correlation ID para comandos importantes.

Amostre logs repetitivos e exponha métricas no padrão do OpenTelemetry em Go. Para logging consistente, use slog e campos estruturados.

Testes: protocolo, corrida e cliente lento

Use httptest.Server para subir o handler e conectar um cliente real. Cubra:

  • handshake autorizado e não autorizado;
  • Origin permitido e bloqueado;
  • mensagem válida, inválida e grande demais;
  • close normal e inesperado;
  • timeout sem pong;
  • cliente que não lê;
  • reconexão e replay, se existir;
  • shutdown com conexões ativas.

Rode também:

go test -race ./...
go test -count=100 ./internal/realtime/...

O race detector encontra acesso concorrente ao hub, mapas e estado da conexão. Testes repetidos ajudam a expor flakes de timing, mas prefira sincronização explícita a time.Sleep arbitrário. O guia de testes de tabela em Go mostra como estruturar casos; para investigar travamentos, use runtime/trace e pprof.

Checklist de produção

  • Existe uma única goroutine de escrita por conexão?
  • A fila de saída é limitada e há política para cliente lento?
  • O tamanho de mensagem está limitado?
  • O handshake autentica e valida Origin?
  • Cada ação verifica autorização no servidor?
  • Pings, timeouts e reconexão usam intervalos documentados e jitter?
  • Close codes são tratados e medidos?
  • O deploy faz drain com deadline?
  • A arquitetura funciona com mais de uma réplica?
  • Eventos importantes têm persistência, confirmação ou replay?
  • Logs evitam payload e dados sensíveis?
  • Testes rodam com -race e simulam cliente lento?

Perguntas frequentes

Qual biblioteca WebSocket usar em Go em 2026?

Para projetos novos, coder/websocket é uma opção enxuta, idiomática e integrada a context.Context; ela é a continuação do projeto anteriormente conhecido como nhooyr/websocket. Gorilla WebSocket continua madura, amplamente usada e adequada para bases que já dependem da sua API. Considere compatibilidade, modelo de concorrência, manutenção e testes, não apenas benchmarks.

É possível criar WebSocket apenas com net/http?

O net/http fornece o servidor HTTP, mas não oferece uma API WebSocket de alto nível na biblioteca padrão. Use uma biblioteca mantida para validar o handshake, aplicar framing, controlar mensagens, close codes e limites, em vez de implementar o protocolo manualmente.

WebSocket permite várias goroutines escrevendo na mesma conexão?

Não presuma que sim. Trate cada conexão com uma única goroutine responsável por escrita e envie mensagens a ela por um canal limitado. Isso evita escritas concorrentes, preserva a ordem e permite desconectar clientes lentos sem bloquear o servidor inteiro. Consulte o contrato da biblioteca escolhida.

Quando usar WebSocket em vez de Server-Sent Events?

Use WebSocket quando cliente e servidor precisam trocar mensagens nos dois sentidos com baixa latência. Use SSE quando apenas o servidor envia atualizações ao navegador e simplicidade, reconexão HTTP e compatibilidade operacional pesam mais.

Como autenticar uma conexão WebSocket em Go?

Autentique durante o handshake usando sessão ou token de curta duração, valide Origin e associe a identidade à conexão. Evite token sensível em URL. Depois do upgrade, aplique autorização por ação ou canal, limites de mensagem, timeout e revogação quando necessário.

Conclusão

WebSocket em Go não é difícil porque o upgrade HTTP exige muitas linhas; é difícil porque conexões longas transformam concorrência, backpressure, autenticação, deploy e escala em parte do protocolo. Comece com coder/websocket em projetos novos ou mantenha Gorilla em bases estáveis, mas padronize um reader, um writer, fila limitada, deadlines e close explícito por conexão.

Antes de colocar em produção, teste cliente lento, reconexão e shutdown. Depois, meça conexões, filas e close codes. Continue com middleware em Go, context e timeouts, observabilidade e vagas Go no Brasil. Para fortalecer a base, siga também o roadmap Go 2026 e o curso gratuito de Go.