This episode builds realtime communication: WebSocket with gorilla/websocket in Gin handlers, Server-Sent Events with SSEvent, streaming uploads and downloads, and notification and chat use cases.

Up to episode 15, our communication pattern has always been request-response: the client asks, the server answers. This episode 16 opens a new direction: realtime. You'll dissect WebSocket with gorilla/websocket inside Gin handlers, Server-Sent Events (SSE) for one-way notifications, streaming uploads and downloads, and real-world use cases like chat and progress bars.
Why does it matter? Modern applications demand instant updates: notifications appear without refresh, chat messages are sent in milliseconds, and upload progress visibly moves. WebSocket provides a two-way channel, SSE provides a simpler one-way channel, and both run smoothly on top of Gin.
# dua arah, latensi rendah, duplex
websocket: chat, game, kolaborasi
# satu arah, sederhana, auto-reconnect
sse: notifikasi, feed harga, progressFor most notifications, SSE is enough and avoids the complexity of managing WebSocket connections on the client side.
Start by installing gorilla/websocket:
go get github.com/gorilla/websocketvar upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func echoHandler(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
break
}
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
break
}
}
}
r.GET("/ws", echoHandler)upgrader.Upgrade(c.Writer, c.Request, nil) turns an HTTP request into a WebSocket connection. This handler echoes back every message it receives. Note the CheckOrigin — in production, return false for unknown domains to prevent cross-site connections.
To spread messages to many clients, use a broadcast pattern via a channel:
var broadcast = make(chan []byte, 256)
func reader(conn *websocket.Conn) {
defer conn.Close()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
break
}
broadcast <- msg
}
}
func writer(conn *websocket.Conn) {
for msg := range broadcast {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}broadcast <- msg sends the message to a channel read by all writers. Each connection has its own reader and writer goroutines; writers receive messages from the shared channel. In production, add a client registry and per-room filtering, and always set read/write deadlines to drop idle connections.
Gin provides c.SSEvent to write a single event:
func sseHandler(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Stream(func(w io.Writer) bool {
c.SSEvent("message", "tick pada " + time.Now().Format("15:04:05"))
time.Sleep(1 * time.Second)
return true
})
}
r.GET("/events", sseHandler)c.SSEvent("message", data) writes an SSE event named message. c.Stream runs the function repeatedly as long as it returns true — the one-way streaming pattern briefly covered in episode 7. The text/event-stream header tells the browser the response will be long-lived.
Large files don't need to be loaded entirely into memory. Stream directly from the request body to a file:
func streamUploadHandler(c *gin.Context) {
src := c.Request.Body
defer src.Close()
dst, err := os.Create("./storage/besar.bin")
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
defer dst.Close()
n, err := io.Copy(dst, src)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"bytes": n})
}io.Copy(dst, src) moves data from the request to disk in small buffers rather than holding the entire body. Combine it with Content-Length to cap the size, and with the rate limit middleware from episode 14 so uploads don't hog bandwidth.
The most common SSE example is notifications: the server sends an event every time a status changes. The server doesn't wait for the client to ask — the event is pushed directly when the status changes, for example after an order is created or a payment is verified. The implementation is simply an SSE endpoint reading a status channel, exactly the broadcast pattern above.
For chat, choose WebSocket because it needs two-way, low-latency communication. Every incoming message is broadcast to all connections in the same room. Because this model runs across many goroutines, apply the discipline from episode 12: use channels for sharing data, mutexes for the client registry, and always close goroutines when a connection ends so they don't leak.
Key takeaways:
upgrader.Upgrade turns a Gin handler into a WebSocket connection.c.SSEvent and c.Stream build an SSE endpoint in a few lines.io.Copy from c.Request.Body enables streaming uploads.CheckOrigin, deadlines, and size limits for connection security.In the next episode, episode 17, we'll dissect testing & benchmark — unit testing handlers with httptest, gin.CreateTestContext, table-driven tests, mocking services, and benchmarking routes with testing.B to compare allocations and latency.