Skip to content
← Back to Blog

I wanted a live UI. I built two Go libraries.

·4 min read

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.