Server-Sent Events are almost too simple to have a name. data: hello, blank line, done. That is the wire format. The first draft of the spec is older than the iPhone, and browsers have shipped it natively for over a decade.
And yet every Go project that serves SSE hand-rolls the same four pieces: event serialization, connection lifecycle, subscriber fan-out, reconnection replay. I have written enough of them. More than once. This year.
I wanted a live UI without shipping a React bundle or a build step. DataStar does exactly that — the server pushes DOM patches over SSE, the browser applies them. But the official Go SDK makes a patch a method call on a live connection. Construct a patch without a connection? No. Broadcast one patch to every open tab? Not built in. Replay what a reconnecting client missed? Not built in.
So I did the sensible thing: extracted the transport everyone keeps rewriting into go-sse, then built the protocol layer I actually wanted on top — go-datastar.
go-sse: the transport everyone reinvents
broadcaster := sse.NewBroadcaster[sse.Event]()
mux.HandleFunc("GET /events", func(w http.ResponseWriter, r *http.Request) {
stream := sse.NewStream(w, r)
defer func() { _ = stream.Close() }()
ch := broadcaster.Subscribe()
defer broadcaster.Unsubscribe(ch)
for {
select {
case <-stream.Context().Done():
return
case evt, ok := <-ch:
if !ok || stream.Send(evt) != nil {
return
}
}
}
})
broadcaster.Broadcast(sse.Event{Event: "update", Data: "<div>new</div>"})
That is a complete live endpoint. Three details I am proud of:
Heartbeats. Nginx, Cloudflare, and AWS load balancers kill idle connections on sight. stream.Heartbeat(ctx, 15*time.Second) pokes the connection every 15 seconds so the bouncer forgets it exists.
Slow consumers get dropped. Every subscriber has a 64-event buffer. When it is full, events are dropped — for that subscriber only, silently. This sounds rude. It is rude. It is also the only correct design: one slow tab must never stall ten thousand live ones. The apology is built in — the browser reconnects, sends Last-Event-ID, and replay fills the gap. Pretend nothing happened.
Branded event IDs. EventID is a branded type. You cannot assign a user ID to it, because your compiler has opinions now.
The companion ssetest module parses the wire format, pinned against the official Web Platform Tests corpus. I did not write an SSE parser that only passes my own tests.
go-datastar: patches are nouns
In the official SDK, a patch is a verb. sse.PatchElements("<div>Update</div>") writes to the wire immediately. The patch exists only while the connection does — a speech act, gone the moment it is uttered.
In go-datastar, a patch is a noun. Every patch — elements, signals, scripts, custom events — is a value implementing one tiny interface:
patch := datastar.NewElementsPatch("<div>Update</div>",
datastar.WithSelectorID("feed"),
datastar.WithModePrepend(),
)
evt := patch.Event()
broadcaster.Broadcast(evt) // every connected tab, right now
store.Append(evt) // replay for whoever reconnects
A domain function can emit patches with no HTTP in sight. Fan them out, store them, filter them per subscriber, replay them after a drop. Same wire format as the official SDK, pinned byte-for-byte by golden tests. The difference is not what goes over the wire. The difference is what a patch is before it hits the network.
What I deliberately did not build
- WebSockets. SSE is a fire hose pointed at the client. If the client needs to talk back, that is what POST is for.
- Compression. The official SDK wins here, and I am fine losing: gzip belongs in the reverse proxy. A Go library re-implementing Brotli is how side projects die.
- Payload opinions. JSON, HTML fragments, plain strings — your feed, your format.
Where the official SDK wins
If you have one request and one response, use datastar-go. It ships built-in compression, works on boring Go versions without flags, and tracks client releases day one. Mine requires an experimental compiler flag, which is either a red flag or a personality test.
Choose go-datastar when patches are part of your application’s state: live feeds, dashboards, anything that outlives a single request.
Try it
Both repos ship runnable examples. go run ./example/datastar/, open two tabs, click a button in one, and watch the other update on its own. It is the most underwhelming magic trick you will ever demo — and it ships in one binary.
Read the code. The browser has known how to do this since before the iPhone 3G (Safari 5). Now your Go server does too.