Server-Sent Events (SSE) in Go: Real-Time Streaming APIs
NestGo makes server-sent events in Go a one-function affair: create a channel, send events from a goroutine, and hand it to core.SSE(). You get a standards-compliant text/event-stream response that browsers consume natively with EventSource — no extra libraries, in any adapter (Gin or Fiber).
What are server-sent events?
SSE is a simple HTTP-based protocol for pushing events from server to client over a single long-lived response. Unlike WebSockets it is one-directional (server → client), works through ordinary HTTP infrastructure, and gets automatic reconnection for free in the browser.
The SSEEvent type
Each event you send is a core.SSEEvent. Its fields map directly onto the SSE wire format:
| Field | Type | Wire field | Purpose |
|---|---|---|---|
Event | string | event: | Optional event type — the client can listen per-type with addEventListener |
Data | string | data: | The event payload (often a JSON string) |
ID | string | id: | Optional event ID — the browser resends it as Last-Event-ID on reconnect |
Retry | int | retry: | Optional reconnection delay in milliseconds |
SSEEvent has a Format() method that renders the standard wire format (id, event, retry, then data, terminated by a blank line). You normally never call it yourself — SSE() does — but it’s handy in tests:
e := core.SSEEvent{ID: "1", Event: "tick", Data: `{"n":1}`}
fmt.Print(e.Format())
// id: 1
// event: tick
// data: {"n":1}
//
Format() also sanitizes fields per the SSE spec: newlines are stripped from ID and Event (so user-derived values cannot inject extra SSE fields or events), carriage returns are removed from Data, and multi-line Data is emitted as one data: line per line — the browser’s EventSource reassembles them into a single payload.
NewSSEStream and SSE()
SSEStream is a channel of events (chan SSEEvent). The pattern is always the same three steps:
- Create a stream with
core.NewSSEStream(bufferSize). - Start a goroutine that sends events into it and closes the channel when done — selecting on the request context so it also stops when the client goes away.
- Return
core.SSE(c, stream)from your handler.
SSE() sets the streaming headers — Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, and X-Accel-Buffering: no (so nginx doesn’t buffer the stream) — then blocks, writing and flushing each event to the client as it arrives (real-time delivery on both adapters), until the channel is closed or the request context is canceled (client disconnect, server shutdown).
How to stream server-sent events in Go: full example
A controller with an /events endpoint that emits a counter once per second:
package events
import (
"fmt"
"time"
"github.com/ashrafAli23/nestgo/core"
)
type EventsController struct{}
func NewEventsController() *EventsController { return &EventsController{} }
func (ctrl *EventsController) Prefix() string { return "/events" }
func (ctrl *EventsController) RegisterRoutes(r core.Router) {
r.GET("/", ctrl.Stream)
}
func (ctrl *EventsController) Stream(c core.Context) error {
ctx := c.RequestCtx() // canceled when the client disconnects
stream := core.NewSSEStream(10)
go func() {
defer close(stream) // closing the channel ends the response
for i := 0; i < 5; i++ {
select {
case stream <- core.SSEEvent{
Event: "message",
Data: fmt.Sprintf(`{"count": %d}`, i),
ID: fmt.Sprintf("%d", i),
}:
case <-ctx.Done():
return // client went away — stop producing
}
time.Sleep(time.Second)
}
}()
return core.SSE(c, stream)
}
Test it from the terminal:
curl -N http://localhost:8080/events
id: 0
event: message
data: {"count": 0}
id: 1
event: message
data: {"count": 1}
...
Client side: EventSource in JavaScript
Browsers speak SSE natively — no library needed:
const source = new EventSource("/events");
// Fires for events with `event: message` (and events with no event field)
source.onmessage = (e) => {
const payload = JSON.parse(e.data);
console.log("count:", payload.count, "id:", e.lastEventId);
};
// Listen for a custom event type, e.g. SSEEvent{Event: "tick"}
source.addEventListener("tick", (e) => console.log("tick:", e.data));
source.onerror = () => {
// The browser reconnects automatically (honoring any `retry:` value).
console.log("connection lost, retrying...");
};
// Stop listening when done:
// source.close();
On reconnect the browser sends the last received ID back as a Last-Event-ID header — read it with c.GetHeader("Last-Event-ID") if you want to resume the stream where the client left off.
Streaming real data
Because the stream is just a channel, wiring it to a message bus, ticker, or fan-out hub is plain Go:
func (ctrl *EventsController) Notifications(c core.Context) error {
ctx := c.RequestCtx()
stream := core.NewSSEStream(16)
sub := ctrl.hub.Subscribe() // your pub/sub source
go func() {
defer close(stream)
defer ctrl.hub.Unsubscribe(sub)
for msg := range sub {
select {
case stream <- core.SSEEvent{Event: "notification", Data: msg.JSON()}:
case <-ctx.Done():
return // client disconnected — unsubscribe and stop
}
}
}()
return core.SSE(c, stream)
}
SSE vs WebSocket in Go: which should you use?
| Server-Sent Events | WebSocket | |
|---|---|---|
| Direction | Server → client only | Bidirectional |
| Protocol | Plain HTTP response | Upgraded TCP connection (ws:// / wss://) |
| Browser API | EventSource (built in, auto-reconnect, Last-Event-ID) | WebSocket (built in, manual reconnect) |
| Proxies / infrastructure | Works through ordinary HTTP middleware, auth, and load balancers | Needs upgrade-aware proxies |
| Payloads | Text (UTF-8) | Text and binary |
| NestGo support | Built in — NewSSEStream + SSE() | Abstraction only — bring gorilla/websocket or fiber’s websocket via the adapter |
Use SSE for notifications, progress updates, live feeds, log tailing — anything where the server pushes and the client only listens. Use WebSocket when the client must also send messages over the same connection (chat, collaborative editing, games).
WebSockets in NestGo
NestGo deliberately does not bundle a WebSocket library — it provides the detection helpers and the Underlying() escape hatch to the adapter’s native context.
core.IsWebSocketRequest(c) reports whether the request is a WebSocket upgrade (it wraps c.IsWebSocket() and additionally verifies the Connection header is present):
func (ctrl *ChatController) HandleWS(c core.Context) error {
if !core.IsWebSocketRequest(c) {
return core.ErrBadRequest("expected websocket upgrade")
}
return ctrl.upgradeWebSocket(c)
}
Gin adapter + gorilla/websocket
Use c.Underlying() to reach the native *gin.Context and upgrade with gorilla/websocket:
func (ctrl *ChatController) upgradeWebSocket(c core.Context) error {
ginCtx := c.Underlying().(*gin.Context)
conn, err := upgrader.Upgrade(ginCtx.Writer, ginCtx.Request, nil)
if err != nil {
return core.ErrInternalServer("websocket upgrade failed")
}
defer conn.Close()
// ... read/write messages on conn
return nil
}
Fiber adapter + fiber websocket
With Fiber, register the route directly on the native app via the server’s Underlying():
import "github.com/gofiber/contrib/websocket"
fiberApp := server.Underlying().(*fiber.App)
fiberApp.Get("/ws", websocket.New(func(c *websocket.Conn) {
// ... handle messages
}))
Next steps
- API Versioning — version your streaming endpoints alongside the rest of your API
- Validation & Error Handling — the
HTTPErrorvalues returned by the WebSocket examples above - Browse the full source on GitHub