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:

  1. Create a stream with core.NewSSEStream(bufferSize).
  2. 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.
  3. 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