# 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: 2026-06-28
- Author: 友田 陽大
- Tags: Go, Echo, リアルタイム, アーキテクチャ設計, セキュリティ, 可観測性
- URL: https://tomodahinata.com/en/blog/go-echo-websocket-sse-realtime-streaming-guide
- Category: Go & Echo in production
- Pillar guide: https://tomodahinata.com/en/blog/go-echo-framework-production-guide

## Key points

- First, the choice. For one-way server→client notifications, SSE (HTTP, auto-reconnect, light to implement); for two-way dialogue, WebSocket. SSE is enough for many notification systems.
- SSE writes text/event-stream to c.Response() (in v5 an http.ResponseWriter) and emits with http.NewResponseController(w).Flush(). Detect disconnect with c.Request().Context().Done().
- Long connections are killed by WriteTimeout. For SSE/WS paths, set WriteTimeout=0 in StartConfig.BeforeServeFunc (or split into a dedicated server).
- WebSocket is upgrader.Upgrade(c.Response(), c.Request()). Always implement CheckOrigin to prevent CSWSH (cross-site WS hijacking).
- With multiple instances, a connection is pinned to an instance. Do fleet-wide delivery via a message broker like Redis Pub/Sub (an in-memory Hub alone doesn't reach).

---

"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](/blog/go-echo-framework-production-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](/blog/websocket-sse-realtime-architecture-decision-guide).

---

## 0. Decide first: SSE or WebSocket

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

| Aspect | **SSE (Server-Sent Events)** | **WebSocket** |
| --- | --- | --- |
| Direction | **Server → client (one-way)** | **Two-way** |
| Protocol | Ordinary HTTP (GET) | Dedicated (upgrade from HTTP) |
| Auto-reconnect | **The browser does it by default** | Needs your own implementation |
| Proxy/FW traversal | HTTP, so it passes easily | Sometimes rejected depending on the environment |
| Implementation cost | **Low** | Somewhat high |
| Suited use | Notifications, progress, stock prices, live feeds | Chat, 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()`.**

```go
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).

```js
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](/blog/go-echo-deployment-docker-distroless-ecs-cloud-run-graceful-shutdown-guide#3-サーバータイムアウトv5はstartconfigbeforeservefuncで設定する) **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`.

```go
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](#3-production-traps-disconnect-detection-and-resource-leaks) 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`.

```go
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.

```go
// 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.

| Trap | Countermeasure |
| --- | --- |
| Not noticing client disconnect in SSE | Select on **`c.Request().Context().Done()`** and always break the loop |
| WS zombie connection | Monitor liveness with **ping/pong + ReadDeadline** and cut the unresponsive |
| goroutine leak | Always close one connection's lifecycle (reader/writer) with **`defer conn.Close()`** |
| Backpressure | Make the send channel **buffered**, and if it clogs, **cut the slow client** (don't drag the whole down) |
| Graceful stop | On [SIGTERM](/blog/go-echo-deployment-docker-distroless-ecs-cloud-run-graceful-shutdown-guide#4-グレースフルシャットダウン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.**

```go
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](/blog/go-echo-jwt-authentication-authorization-rbac-refresh-token-guide). Realistic options:

- **Cookie (httpOnly)**: on the same site, the cookie is auto-sent. Combine with [CSRF/Origin measures](#41-checkorigin-prevent-cswsh). 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.**

```text
[インスタンス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.**

```text
イベント発生 → [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](/blog/websocket-sse-realtime-architecture-decision-guide). Measure observability (connection count, delivery-latency metrics) with [OpenTelemetry integration](/blog/go-echo-opentelemetry-distributed-tracing-metrics-observability-guide).

---

## 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](/blog/go-echo-framework-production-guide).
