SSE with Gin and Echo Permalink to this section
Part of Go Streaming Patterns, under Backend Stream Generation & Connection Management.
Gin and Echo both sit on top of Go’s net/http, so everything about Server-Sent Events in plain Go still applies: set the headers, write frames, flush, watch the request context. What the frameworks add are helpers — and middleware that can quietly break a stream. This guide shows the idiomatic streaming handler in each framework, the middleware to keep away from stream routes, and how to connect both to a shared hub.
Symptom & Developer Intent Permalink to this section
- Events are delivered in bursts, or only when the client disconnects.
- The stream closes after a fixed time that matches a timeout middleware setting.
- The gzip middleware compresses the stream, and events stop arriving promptly.
- The handler keeps running after the client leaves, holding a hub subscription.
- Gin’s
c.SSEventproducesdata:lines that do not match what the client parser expects for multi-line payloads.
The intent is a stream handler per framework that flushes each event, survives idle periods, exits when the client leaves, and coexists with the framework’s middleware stack.
Root Cause Analysis Permalink to this section
Buffering has three sources. Handlers that write without flushing leave bytes in net/http’s buffered writer. Gzip middleware wraps the writer in a compressor that emits bytes only when its block fills. And some response-logging or body-capturing middleware wraps the writer in a buffer to measure or record the body. Each must be avoided or flushed through.
Timeouts come from http.Server.WriteTimeout or timeout middleware, both of which end responses that take longer than a limit — which every stream does. Leaking handlers come from not selecting on the request context.
Step-by-Step Resolution Permalink to this section
Step 1 — Gin: stream with c.Stream Permalink to this section
r := gin.New()
r.Use(gin.Recovery()) // no gzip on this group
events := r.Group("/events")
events.GET("", func(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no")
sub := hub.Subscribe("user:" + c.GetString("userID"))
defer hub.Unsubscribe(sub)
hb := time.NewTicker(15 * time.Second)
defer hb.Stop()
c.Stream(func(w io.Writer) bool { // Gin flushes after each call returns
select {
case frame, ok := <-sub.C:
if !ok {
return false
}
_, err := w.Write(frame) // pre-formatted "id:/event:/data:" frame
return err == nil
case <-hb.C:
_, err := io.WriteString(w, ": hb\n\n")
return err == nil
case <-c.Request.Context().Done():
return false // client left
}
})
})
c.Stream calls the function repeatedly, flushing after each call, until it returns false or the client disconnects. Returning a boolean per event keeps the loop inside Gin’s control.
Gin also offers c.SSEvent(name, data), which writes an event through the gin-contrib/sse encoder. It is convenient for simple payloads; for ids, retry values and exact control over multi-line data, writing pre-formatted frames is clearer and lets you unit test the encoder directly.
Step 2 — Echo: write to the response and flush Permalink to this section
e := echo.New()
e.Use(middleware.Recover())
api := e.Group("/api")
api.Use(middleware.GzipWithConfig(middleware.GzipConfig{
Skipper: func(c echo.Context) bool { return c.Path() == "/api/events" }, // never gzip streams
}))
api.GET("/events", func(c echo.Context) error {
res := c.Response()
res.Header().Set(echo.HeaderContentType, "text/event-stream")
res.Header().Set(echo.HeaderCacheControl, "no-cache")
res.Header().Set("X-Accel-Buffering", "no")
res.WriteHeader(http.StatusOK)
sub := hub.Subscribe("user:" + c.Get("userID").(string))
defer hub.Unsubscribe(sub)
hb := time.NewTicker(15 * time.Second)
defer hb.Stop()
for {
select {
case frame, ok := <-sub.C:
if !ok {
return nil
}
if _, err := res.Write(frame); err != nil {
return nil
}
res.Flush() // echo.Response implements http.Flusher
case <-hb.C:
if _, err := res.Write([]byte(": hb\n\n")); err != nil {
return nil
}
res.Flush()
case <-c.Request().Context().Done():
return nil
}
}
})
Step 3 — Remove server-level write timeouts for stream routes Permalink to this section
http.Server.WriteTimeout applies to every response. Either leave it at zero and enforce timeouts per route for ordinary handlers, or clear the deadline inside the stream handler with http.ResponseController (Go 1.20+):
rc := http.NewResponseController(c.Writer) // or c.Response().Writer in Echo
_ = rc.SetWriteDeadline(time.Time{}) // no deadline for this long-lived response
ResponseController also exposes Flush on writers wrapped by middleware that implement Unwrap, which is the modern way to flush through wrappers.
Step 4 — Authenticate before streaming Permalink to this section
Run authentication middleware on the stream route as usual, and reject with 401 before writing headers. Once 200 OK and text/event-stream are sent, the only way to signal an error is to close the stream, and EventSource would reconnect.
In Gin, that means calling c.AbortWithStatus(http.StatusUnauthorized) in the auth middleware; in Echo, returning echo.NewHTTPError(http.StatusUnauthorized) from middleware. Both frameworks then skip the handler. Because EventSource cannot send an Authorization header, authenticate with a session cookie or a short-lived token in the query string, and make sure request logging middleware redacts that query parameter.
Step 5 — Replay missed events on reconnect Permalink to this section
Both frameworks expose the request headers normally, so resume works the same way in each: read Last-Event-ID, write the events after it from your store, then enter the live loop. Subscribe to the hub before querying the store and skip live frames whose id is not greater than the last replayed one, so nothing published during the query is lost or duplicated:
lastID := c.GetHeader("Last-Event-ID") // Echo: c.Request().Header.Get("Last-Event-ID")
sub := hub.Subscribe(topic) // 1. subscribe first
defer hub.Unsubscribe(sub)
high := lastID
for _, e := range store.After(topic, lastID) { // 2. replay
w.Write(e.Frame)
high = e.ID
}
flush()
// 3. in the live loop, drop frames with id <= high
Event ids must be comparable for step 3; a monotonic integer per topic, as described in generating monotonic event IDs, makes the comparison trivial.
Validation & Monitoring Permalink to this section
# Frames arrive one per event, not in bursts, even with Accept-Encoding: gzip.
curl -sN -H 'Accept-Encoding: gzip' localhost:8080/events -D - | head -8 # no Content-Encoding header
# Idle survival past WriteTimeout.
timeout 120 curl -sN localhost:8080/events | grep -c '^:' # ~8 heartbeats
Use pprof’s goroutine profile under load: each open stream should appear as one goroutine parked in the handler’s select. Goroutines parked in Write indicate slow clients; the hub’s eviction keeps them from affecting others.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Should I use Gin's c.SSEvent or write frames myself?
c.SSEvent is fine for simple named events. Writing frames yourself gives control over ids, retry and multi-line data, and lets the same pre-serialised bytes go to every subscriber.
Why does my stream end after exactly 30 seconds?
A write timeout — either http.Server.WriteTimeout or timeout middleware — is ending the response. Exclude the stream route or clear the write deadline with http.ResponseController.
Does Echo's Response support Flush through middleware?
echo.Response implements http.Flusher and forwards to the underlying writer. Middleware that wraps it must forward Flush too; if unsure, use http.ResponseController, which unwraps writers.
Can Fiber serve SSE the same way?
Fiber is built on fasthttp rather than net/http, so it streams through SetBodyStreamWriter with a bufio.Writer you flush yourself. The same rules apply, with a different API.