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.SSEvent produces data: 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.

The writer chain a stream passes through Stack of writers from the handler through gzip middleware, a logging wrapper and net/http's buffered writer to the socket, showing which layers hold bytes back. The writer chain a stream passes through Handler c.Writer / Response() writes frames Gzip middleware compressor holds bytes until a block fills Logging wrapper body capture may buffer or not forward Flush net/http writer bufio, 4 KB flushed by http.Flusher
Every wrapper between the handler and the socket must pass Flush through — or be removed from the stream route.

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
		}
	}
})
Streaming in Gin versus Echo Matrix comparing Gin and Echo on the streaming helper, flushing, gzip exclusion and disconnect signal. Streaming in Gin versus Echo Concern Gin Echo Streaming helper c.Stream(func) bool none, write loop Flush per event automatic res.Flush() Gzip exclusion separate route group Skipper func Disconnect signal Request.Context() Request().Context()
The two frameworks differ only in helpers. The underlying rules — flush per event, no gzip, select on the context — are identical.

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
Delay before the first event reaches the client Bar chart comparing first-event delay for a Gin stream with gzip middleware applied, without flushing, and with the correct setup. Delay before the first event reaches the client gzip middleware on route until buffer fills No Flush (Echo loop) until buffer fills No gzip + Flush per event 2 ms milliseconds from publish to client receipt of the first event
Both gzip and a missing flush hold the first small event until much more data follows. The correct setup delivers it immediately.

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.