"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 usegithub.com/gorilla/websocket(adopted by the official Cookbook). Alternatives likecoder/websocketcan 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.
| 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().
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
WriteTimeoutin effect, SSE doesn't — the requirements conflict. Ideally, split the real-time server (port) from the ordinary API. If that's hard, setWriteTimeout=0as 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.
| 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, 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
- Choose by direction. One-way notifications are SSE, two-way is WebSocket. SSE is enough for many (YAGNI).
- SSE is
text/event-stream+http.NewResponseController(w).Flush(), detect disconnect withctx.Done(). - Long connections are
WriteTimeout=0(BeforeServeFunc). If possible, split the server for real-time. - Split WebSocket into reader/writer, monitor liveness with ping/pong. Prevent leaks with
defer conn.Close(). - Always implement
CheckOriginto prevent CSWSH. - WS auth is cookie or post-connection token (the browser can't attach headers).
- 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.