SSE is a minimal, zero-magic Server-Sent Events library for Go. It separates the protocol formatting from the HTTP transport, ensuring zero-allocation writes and explicit connection lifecycles. Built strictly on the standard library, it favors mechanical sympathy and composability over framework-like conveniences.
- Zero-allocation writes — formats directly into the HTTP buffer without intermediate strings
- Explicit concurrency — background heartbeats are context-aware and shut down cleanly
- Standard library native — composes directly with
io.Readerandhttp.ResponseWriter - Zero dependencies —
go get lowbit.dev/sse
An Event is a plain data struct:
type Event struct {
Name string
ID string
Retry time.Duration
Data string
Extensions map[string]string
}It holds pure data. The library does not assume your payload is JSON or any other format. If you need to send structured data, marshal it before assignment.
Wrap an http.ResponseWriter with an Emitter to safely manage concurrent writes and flushes. Pass the request context to the background heartbeat so it cleans up automatically when the client disconnects.
func StreamHandler(w http.ResponseWriter, r *http.Request) {
emitter, err := sse.NewEmitter(w)
if err != nil {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
// Keep the connection alive; automatically stops when r.Context() is canceled
go emitter.ServeHeartbeats(r.Context(), 15*time.Second)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return // Client disconnected
case <-ticker.C:
_, err := emitter.Emit(sse.Event{
Name: "ping",
Data: "pong",
})
if err != nil {
return // Socket dead
}
}
}
}Pass any io.Reader (like an http.Response.Body) to the Reader. The parsing loop is entirely in your control, allowing you to handle timeouts and cancellations via standard HTTP client contexts.
req, _ := http.NewRequestWithContext(ctx, "GET", "http://example.com/stream", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
reader := sse.NewReader(resp.Body)
for {
event, err := reader.Read()
if err != nil {
if errors.Is(err, io.EOF) {
// Clean disconnect
break
}
// Handle error
break
}
fmt.Printf("Event: %s, Data: %s\n", event.Name, event.Data)
}sse splits responsibilities to avoid massive heap allocations and to keep connection state explicitly separated from pure data.
| Type | Role |
|---|---|
Emitter |
Safely manages concurrent writes, flushes, and background heartbeats for an http.ResponseWriter. |
Writer |
Handles the zero-allocation protocol formatting to any underlying io.Writer. |
Reader |
Wraps an io.Reader for allocation-efficient event parsing. |
Event |
The raw data container mapping directly to the SSE specification. |
| Error | Effect |
|---|---|
ErrStreamingUnsupported |
Returned by NewEmitter if the provided http.ResponseWriter does not implement http.Flusher. |
io.EOF |
Returned by Reader.Read() when the server sends a clean termination (FIN packet). |
Both errors behave predictably. If Emitter.Emit() fails, it will return the standard library's network error (e.g., broken pipe), indicating a dead connection that should be dropped.