Skip to main content
友田 陽大
Go & Echo in production
Go
Echo
リアルタイム
アーキテクチャ設計
セキュリティ
可観測性

Echo real-time implementation: choosing between WebSocket and SSE, production design (disconnect detection, auth, scaling)

A guide to implementing real-time features at production quality with Go Echo (v5). With real code, it explains: the implementation of SSE (text/event-stream + flush with http.NewResponseController) and WebSocket (upgrade with gorilla/websocket), choosing between them, disconnect detection, disabling WriteTimeout, Origin verification, WebSocket auth, and scaling across multiple instances (Redis Pub/Sub).

Published
Reading time
8 min read
Author
友田 陽大
Share

"I want to push notifications in real time," "I want to build chat," "I want to show processing progress live" — these are one-line requirements, but get the implementation choice (SSE or WebSocket) and the production traps (disconnect detection, timeout, auth, scaling) wrong, and it becomes a feature that doesn't connect, falls over, or gets hijacked.

This article is the real-time chapter of the Go Echo production-operations guide. It first clarifies the choice, then explains the implementation of SSE and WebSocket in Echo v5 and countermeasures for the four traps you always hit in production.

Rules for this article: Echo's API is based on the official documentation (v5, as of June 2026). Since WebSocket is compatible with standard net/http, I use github.com/gorilla/websocket (adopted by the official Cookbook). Alternatives like coder/websocket can be used similarly. For the big picture of architecture selection, also see the WebSocket/SSE architecture decision.


0. Decide first: SSE or WebSocket

Before implementation, choose by directionality. Get this wrong and you carry an unnecessarily complex implementation.

AspectSSE (Server-Sent Events)WebSocket
DirectionServer → client (one-way)Two-way
ProtocolOrdinary HTTP (GET)Dedicated (upgrade from HTTP)
Auto-reconnectThe browser does it by defaultNeeds your own implementation
Proxy/FW traversalHTTP, so it passes easilySometimes rejected depending on the environment
Implementation costLowSomewhat high
Suited useNotifications, progress, stock prices, live feedsChat, games, collaborative editing

Rule of thumb for the judgment: for one-way notifications from the server (notification badges, job progress, dashboard updates), SSE without hesitation. It's light to implement while staying HTTP, and reconnection can be left to the browser. For two-way where the client also sends frequently (chat, real-time collaboration), WebSocket. "WebSocket for now" is overkill for many notification systems (YAGNI).


1. SSE: the main option for one-way streaming

In Echo v5, c.Response() returns an http.ResponseWriter, so SSE can be written straightforwardly as standard HTTP streaming. The points are the text/event-stream header, immediate emission with http.NewResponseController(w).Flush(), and disconnect detection with c.Request().Context().Done().

func (h *Handler) Stream(c *echo.Context) error {
	w := c.Response()
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")

	rc := http.NewResponseController(w) // Go 1.20+ の標準。Flush を型アサーション無しで呼べる
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-c.Request().Context().Done():
			// クライアント切断・サーバー停止 → ループを抜ける(goroutine リーク防止)
			return nil
		case ev := <-h.events: // 配信したいイベント源(チャネル)
			// SSE の書式:data: <payload>\n\n
			if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Name, ev.JSON); err != nil {
				return err
			}
			if err := rc.Flush(); err != nil { // バッファを今すぐ送る
				return err
			}
		case <-ticker.C:
			// ハートビート(コメント行)。プロキシのアイドル切断を防ぐ
			fmt.Fprint(w, ": ping\n\n")
			_ = rc.Flush()
		}
	}
}

The client just receives with the standard EventSource (with auto-reconnect).

const es = new EventSource("/api/v1/stream");
es.addEventListener("progress", (e) => update(JSON.parse(e.data)));

1.1 SSE's biggest trap: killed by WriteTimeout

The WriteTimeout set in the deployment chapter cuts off long-connection SSE/WebSocket midway. On real-time paths, set the server's WriteTimeout to 0 (disabled). In Echo v5, set it in StartConfig.BeforeServeFunc.

sc := echo.StartConfig{
	Address: ":8080",
	BeforeServeFunc: func(s *http.Server) error {
		s.WriteTimeout = 0      // 長時間ストリーミングを切らない
		s.ReadHeaderTimeout = 5 * time.Second // ヘッダ系の保護は残す
		return nil
	},
}

Separating the design: ordinary APIs want WriteTimeout in effect, SSE doesn't — the requirements conflict. Ideally, split the real-time server (port) from the ordinary API. If that's hard, set WriteTimeout=0 as above and manage an idle timeout yourself on the SSE-handler side.


2. WebSocket: implementing two-way communication

WebSocket establishes by upgrading an HTTP connection. Being net/http-compatible, just pass c.Response() and c.Request() to gorilla/websocket's Upgrader.

import "github.com/gorilla/websocket"

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	CheckOrigin:     checkOrigin, // ← セキュリティ上、必ず実装(第4章)
}

func (h *Handler) WS(c *echo.Context) error {
	conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
	if err != nil {
		return err // アップグレード失敗(既にレスポンス書込済みのことが多い)
	}
	defer conn.Close()

	for {
		// 読み取り(クライアント → サーバー)
		mt, msg, err := conn.ReadMessage()
		if err != nil {
			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
				c.Logger().Error("ws read error", "error", err)
			}
			break // 切断 → ループを抜ける
		}
		// 書き込み(サーバー → クライアント):ここではエコー
		if err := conn.WriteMessage(mt, msg); err != nil {
			break
		}
	}
	return nil
}

2.1 Split read and write into separate goroutines, and monitor liveness with ping/pong

In production, split read and write into separate goroutines and detect disconnect with ping/pong (gorilla's standard). Use a configuration of 1 connection = 2 goroutines (reader/writer) + a send channel.

// writer:送信専用 goroutine。送信チャネルと定期 ping を一手に握る
func (cl *Client) writePump() {
	pingTicker := time.NewTicker(30 * time.Second)
	defer func() { pingTicker.Stop(); cl.conn.Close() }()
	for {
		select {
		case msg, ok := <-cl.send:
			_ = cl.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
			if !ok {
				_ = cl.conn.WriteMessage(websocket.CloseMessage, nil)
				return
			}
			if err := cl.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
				return
			}
		case <-pingTicker.C:
			_ = cl.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
			if err := cl.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
				return // pong が返らなければ相手は死んでいる
			}
		}
	}
}

With SetReadDeadline + SetPongHandler, extend the read deadline each time a pong arrives. This reclaims zombie connections (TCP is alive but the other side doesn't respond).


3. Production traps: disconnect detection and resource leaks

Most real-time accidents are goroutines remaining after the connection is cut. If several goroutines per connection leak, memory and goroutines are exhausted as connections repeat.

TrapCountermeasure
Not noticing client disconnect in SSESelect on c.Request().Context().Done() and always break the loop
WS zombie connectionMonitor liveness with ping/pong + ReadDeadline and cut the unresponsive
goroutine leakAlways close one connection's lifecycle (reader/writer) with defer conn.Close()
BackpressureMake the send channel buffered, and if it clogs, cut the slow client (don't drag the whole down)
Graceful stopOn SIGTERM, send close to all connections before terminating

To prevent "one slow client worsening the whole's latency," you need the judgment to cut a connection whose send is clogged. Real-time systems keep the whole healthier with "cut the unreachable" rather than "persist to deliver to everyone."


4. Security: Origin verification and WebSocket auth

4.1 CheckOrigin: prevent CSWSH

WebSocket isn't subject to the same-origin policy. Without implementing CheckOrigin, a malicious site can establish a WebSocket using the user's cookie — CSWSH (Cross-Site WebSocket Hijacking). Always verify the Origin with an allowlist.

var allowedOrigins = map[string]bool{"https://app.example.com": true}

func checkOrigin(r *http.Request) bool {
	return allowedOrigins[r.Header.Get("Origin")] // 既定の「常に true」は危険
}

gorilla/websocket's CheckOrigin defaults to "allow same host only," but it can fail to work correctly behind a reverse proxy, so implementing an explicit allowlist is safe.

4.2 WebSocket auth: the browser can't attach headers

The browser's WebSocket API can't attach custom headers (Authorization). So you can't directly reuse JWT auth. Realistic options:

  • Cookie (httpOnly): on the same site, the cookie is auto-sent. Combine with CSRF/Origin measures. The most straightforward.
  • Send the token in the first message after connecting: pass the upgrade itself, verify the JWT in the first frame, and close immediately if invalid.
  • Put the token in Sec-WebSocket-Protocol (subprotocol): spec-compliant but somewhat special to handle.

Passing cookie auth in Echo middleware before the upgrade and binding the Origin with CheckOrigin — these two stages are a configuration well-balanced in both implementation and robustness.


5. Scaling: with multiple instances, "an in-memory Hub" alone doesn't reach

This is the most important point of production. A WebSocket/SSE connection is pinned to the specific instance that established it. With two or more instances, events of instance B don't reach a user connected to instance A.

[インスタンスA] ── user1, user2  ←┐
                                   │ A 内のイベントは A の接続にしか届かない
[インスタンスB] ── user3, user4  ←┘ B のイベントは B の接続にしか届かない

The solution is to deliver via a message broker (Redis Pub/Sub, etc.). Each instance subscribes to the broker and distributes received messages to the connections it holds.

イベント発生 → [Redis Pub/Sub に publish]
                    ↓ 全インスタンスが購読
        [A]→A の接続へ   [B]→B の接続へ   全 user に届く
  • Single instance (small scale): an in-memory Hub (connection registry) is enough. KISS.
  • Multiple instances (production): bridge between instances with Redis Pub/Sub, NATS, Kafka, etc. Alternatively, there's the judgment to offload the real-time delivery itself to a managed service (API Gateway WebSocket / Ably / Pusher).

The design judgment of this "connection affinity" and "broker-mediated delivery" is detailed in the real-time architecture decision guide. Measure observability (connection count, delivery-latency metrics) with OpenTelemetry integration.


Conclusion: the 7 principles to make real-time production-quality

  1. Choose by direction. One-way notifications are SSE, two-way is WebSocket. SSE is enough for many (YAGNI).
  2. SSE is text/event-stream + http.NewResponseController(w).Flush(), detect disconnect with ctx.Done().
  3. Long connections are WriteTimeout=0 (BeforeServeFunc). If possible, split the server for real-time.
  4. Split WebSocket into reader/writer, monitor liveness with ping/pong. Prevent leaks with defer conn.Close().
  5. Always implement CheckOrigin to prevent CSWSH.
  6. WS auth is cookie or post-connection token (the browser can't attach headers).
  7. Bridge multiple instances with Redis Pub/Sub, etc. An in-memory Hub alone doesn't reach the whole.

Real-time is an area where "cutting, protecting, scaling" is harder than "connecting." First ask whether SSE can satisfy the requirements, and add only the necessary two-way-ness with WebSocket — this order is the shortcut to a maintainable real-time feature. For the big picture, go to the Go Echo production-operations guide.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

I build Go / Echo backends, from design to production

API design and migration to Echo v5, clean architecture (Controller/UseCase/Repository + DI), middleware and security, centralized error handling, graceful shutdown, and testing/CI. With experience building a clean-architecture backend in Go/Echo + google/wire, I implement APIs that don't fall over, are traceable, and are easy to change.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading