Go + React: Realtime Streaming with SSE, Covering Backpressure, Reconnects and a Type-Safe Contract
Bài này có bản tiếng Việt. Đọc bản tiếng Việt →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
There’s a moment almost every fullstack team goes through: the dashboard needs realtime updates, so you type setInterval(fetch, 3000). Three months later the system has 400 users online, you see 8,000 requests a minute in the logs, and you wonder why that number carries almost no new information. That’s when the right question finally shows up. Not “WebSocket or SSE”, but which way my data actually flows.
The path isn’t short. The Go server emits an event, the React client receives it and renders, and between the two ends there’s a whole list of things ( and ) that nobody thinks about while writing the demo and everybody discovers in production.
One-way or two-way: choosing SSE or WebSocket
WebSocket’s strength is full duplex: two independent directions at the same time. What if your use case is only server push, with the client making an occasional REST call? You’re paying for a capability you don’t use: a second stateful connection, a hand-rolled ping/pong mechanism, a separate auth layer because the handshake doesn’t go through your normal HTTP middleware, and a pile of infrastructure to reconfigure from scratch.
Server-Sent Events is much smaller. It’s just an HTTP response that never ends: Content-Type: text/event-stream, a few headers telling proxies not to buffer, and a body of text frames separated by blank lines. No new protocol. No upgrade handshake. The trade-off is that it only goes one way, and paradoxically that limit is exactly what makes it easier to operate.
The rule of thumb boils down to this: if the client sends at high frequency on the same channel as server push, use WebSocket. If the client sends sporadically and the server pushes continuously, use SSE. If both directions are sparse, conditional polling with ETag is usually cheaper than any persistent channel.
The Go side: one handler, three easy-to-forget things
At the lowest level, an SSE endpoint is nothing mysterious. It sets headers, it writes, it flushes, and it lives as long as the request’s context lives.
func (s *Streamer) Handle(w http.ResponseWriter, r *http.Request) {
rc := http.NewResponseController(w)
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache, no-transform")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
ctx := r.Context()
sub := s.hub.Subscribe(ctx, r.PathValue("room"))
defer s.hub.Unsubscribe(sub)
// Start with a retry hint: the client applies it itself when it has to reconnect.
if _, err := io.WriteString(w, "retry: 3000\n\n"); err != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-ctx.Done():
return
case <-heartbeat.C:
// Comment frame: keeps the connection alive through proxies, the client ignores it.
if _, err := io.WriteString(w, ":hb\n\n"); err != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
case ev, ok := <-sub.C:
if !ok {
return
}
if _, err := writeEvent(w, ev); err != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
}
}
}
func writeEvent(w io.Writer, ev Event) error {
var b bytes.Buffer
fmt.Fprintf(&b, "id: %d\n", ev.Seq)
fmt.Fprintf(&b, "event: %s\n", ev.Type)
for _, line := range strings.Split(ev.Data, "\n") {
fmt.Fprintf(&b, "data: %s\n", line)
}
b.WriteByte('\n')
_, err := w.Write(b.Bytes())
return err
}
Those three things are scattered through the snippet above.
First, X-Accel-Buffering: no. nginx buffers responses by default and only hands them to the client when the buffer fills or the response ends. Your response never ends, so the client receives nothing even though the server flushed cleanly. Some CDNs have the same problem. This header is how you say “don’t touch this stream”.
Second, the heartbeat. A TCP connection that stays silent long enough gets cut somewhere along the way: the load balancer, a NAT, or the OS itself. A comment frame (a line starting with :) every 15 to 30 seconds is the cheapest way to keep the line alive, and per the spec clients must ignore them. Don’t send fake events for this. You’d have to teach every consumer to filter them out.
Third, http.NewResponseController. Since Go 1.20, this is the right way to get Flush without depending on a type assertion to http.Flusher, and it opens the door to SetWriteDeadline, which you’ll need in the next section.
There’s one more thing that isn’t in the code but matters just as much: don’t let this handler burn CPU on its own. Polling the database in a loop turns SSE into a dial-up machine. The source of truth has to be an in-process hub (channel fan-out) or an external pub/sub (Redis Pub/Sub, NATS, Postgres LISTEN/NOTIFY) where the handler just sits and waits.
Backpressure: where a slow client takes the server down
This is the part every demo skips and production always collects on. Picture a room with 2,000 subscribers. One user opens a tab on a laptop on hotel Wi-Fi, or leaves a tab asleep and reopens it four hours later. Their TCP window fills up, w.Write starts to block, and if you fan out through an unbounded channel, that block propagates back up to the publishing goroutine. One bad client slows the whole room.
The fix isn’t making the channel bigger. A bounded channel plus an explicit policy for when it’s full is the answer, and that policy depends on your data’s semantics.
type Subscription struct {
C chan Event
once sync.Once
done func()
}
func (s *Subscription) Close() {
s.once.Do(func() {
close(s.C)
s.done()
})
}
func (h *Hub) Publish(ev Event) {
h.mu.RLock()
defer h.mu.RUnlock()
for sub := range h.subs {
select {
case sub.C <- ev:
default:
// This client has fallen too far behind. Close it and let it reconnect
// and resume from its offset. That's much cheaper than keeping it alive
// by blocking the whole hub.
sub.Close()
h.metrics.SlowConsumerDropped.Add(1)
}
}
}
Cutting off a consumer that’s too slow is the right behavior, not a bad one. But it’s only right when the client can resume, and that’s why id: exists in every frame. It isn’t decoration. It’s a position pointer. Emit events with an increasing sequence number, keep a ring buffer of recent events, and a disconnect becomes a non-event: the client comes back holding Last-Event-ID, and the server replays from there.
That whole branch (a slow client gets cut off, then comes back asking for a replay) fits in one diagram:
sequenceDiagram
participant C as React client
participant S as Go SSE handler
participant H as Hub
C->>S: GET text/event-stream, with Last-Event-ID on reconnect
S-->>C: retry: 3000
loop Each new event
H->>S: publish into the bounded channel
S-->>C: id, event, data, then Flush
end
Note over S: 15-second heartbeat keeps the connection alive through proxies
S-->>C: comment frame :hb
H->>S: channel full, client has fallen too far behind
S->>C: close the subscription and record the SlowConsumerDropped metric
C->>S: reconnect after backoff, carrying Last-Event-ID
S->>H: read the gap from the recent-events ring buffer
H-->>S: the missing events
S-->>C: replay, then Flush
Buffer size is a function of publish rate and tolerable lag, so don’t guess. At 50 events/second, with a consumer allowed to lag by at most 5 seconds, a buffer of 256 is more than enough. Write that reasoning in a comment next to the number.
There’s one more variant: if your data is current state rather than an event sequence (stock prices, vehicle positions, online counts), the right logic is coalescing, not resume. Keep exactly one latest value per key and drop the intermediate ones. Misidentify the type of data and you build a whole resume layer that does nothing useful.
The React client: why EventSource is sometimes not enough
EventSource is the default API and it has one asset too big to give up: automatic reconnect with the Last-Event-ID header. You lose the network, you get it back, the browser reconnects by itself, and your server, if it has a ring buffer, replays from the right spot. Not one line of client code for any of that. For an internal dashboard, this is almost always the right choice and you should stop here.
There are exactly two situations where it blocks you: you need to send custom headers (Authorization, tenant id) because EventSource doesn’t let you set them, or you need to POST because the filter payload won’t fit in a query string. Both push you down to fetch + ReadableStream, and there you hand-write exactly what the browser just gave you for free: reconnect, backoff, resume.
type Status = "connecting" | "open" | "closed";
class StreamClient {
private controller = new AbortController();
private attempt = 0;
private lastId: string | null = null;
private status: Status = "closed";
constructor(
private url: string,
private onEvent: (ev: { id: string; type: string; data: string }) => void,
private onStatus: (s: Status) => void,
) {}
private setStatus(s: Status) {
this.status = s;
this.onStatus(s);
}
async start() {
this.controller = new AbortController();
this.setStatus("connecting");
const res = await fetch(this.url, {
signal: this.controller.signal,
headers: {
Accept: "text/event-stream",
...(this.lastId ? { "Last-Event-ID": this.lastId } : {}),
},
});
if (!res.ok || !res.body) {
this.setStatus("closed");
return this.scheduleRetry();
}
this.attempt = 0;
this.setStatus("open");
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
// Frames are separated by a blank line; keep the incomplete remainder.
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
const parsed = parseFrame(frame);
if (!parsed) continue;
if (parsed.id) this.lastId = parsed.id;
this.onEvent(parsed);
}
}
} finally {
reader.releaseLock();
}
this.setStatus("closed");
this.scheduleRetry();
}
private scheduleRetry() {
// Full jitter: avoid the whole fleet reconnecting in the same second after an incident.
const cap = Math.min(30_000, 1000 * 2 ** this.attempt++);
const delay = cap / 2 + Math.random() * (cap / 2);
setTimeout(() => void this.start(), delay);
}
stop() {
this.controller.abort();
this.setStatus("closed");
}
}
Three details in this snippet deserve a pause.
The buffer has to survive across read calls. An SSE frame can be cut in the middle between two TCP chunks. Parse each chunk independently and every so often you’ll get a garbage event with truncated data, rare enough that nobody can reproduce it until production. Keep buffer, only process up to the last blank line, and carry the remainder into the next read. It’s the same bug TCP parser authors have been hitting for thirty years, just wearing a JavaScript shirt now.
Full jitter, not a plain exponential backoff cap. If the server just restarted and 5,000 clients reconnect on a fixed formula, you’ve DoS’d yourself. Randomizing the wait within the allowed range is one line of code that buys you peace.
Track status, not just data. Users need to know when the data is stale. A yellow “reconnecting” label is more honest than a table of numbers that still looks right but has been frozen for ten minutes.
Wiring SSE into React without burning re-renders
The easiest trap once you have a client is to push everything into useState in one big component. At 50 events a second, you force React to re-render that subtree 50 times a second.
For data that lives outside React (a WebSocket, SSE, a hand-rolled store, an EventTarget), the exact API is useSyncExternalStore. It tells React how to read a snapshot and how to subscribe, and it handles the hard cases like tearing that a hand-written useEffect plus useState will get wrong.
const subscribe = (cb: () => void) => {
store.listeners.add(cb);
return () => store.listeners.delete(cb);
};
const getSnapshot = () => store.snapshot;
export function useMetrics(selector: (s: Snapshot) => number) {
// Return a number instead of an object so React compares with Object.is
// and only re-renders when the value actually changes.
const value = useSyncExternalStore(subscribe, () => selector(getSnapshot()));
return value;
}
The word that deserves the most attention is selector. If every subscriber receives the whole snapshot, an event that touches a single field makes every component re-render. Treat picking a store as an architecture decision, not an implementation detail: a small store (Zustand, Jotai, or your own 60 lines) plus granular selectors beats stuffing the whole stream into context by a wide margin.
For lists it’s the opposite: don’t append to an endless array. A bounded list, keeping the N most recent items, protects both the DOM and the tab’s memory. If you need history, that’s a REST query, not something for the stream to hold in RAM.
Contracts drift apart from the third month on
This problem doesn’t bite on day one. It bites in month three. The Go server has struct Event { Seq uint64; Type string; Data json.RawMessage }, the TypeScript client has a hand-written interface, and they drift apart when someone renames a field in Go. TypeScript doesn’t complain because it knows nothing about Go. Tests don’t catch it because the mocks are hand-written too. Only users notice, when a column on the dashboard suddenly goes blank.
The cheapest cure is to never hand-write both. Pick one source of truth and generate the rest: describe the schema in an IDL (Protobuf, or OpenAPI if you already have it), then generate both the Go structs and the TypeScript types from it. If you don’t want another build step, keep Go as the source of truth and write a small generator that prints a .d.ts file. Which tool matters less than the decision that both ends are generated, not typed.
Two things should come with it whichever direction you pick:
- Explicit versioning: put a
vfield in the envelope and reject events with an unknown version, turning an out-of-step deploy into a clear log line instead of a baffling crash. - A test that reads the schema, not the mock: generate JSON fixtures from the Go struct itself in CI and have the client tests parse those fixtures. Whoever changes Go and forgets the client gets a red build right in the pull request.
A few things that only show up in production
Serverless and SSE need a careful read of the terms. A streaming response needs an execution slot alive for the whole connection time. On FaaS, that runs into the function’s maximum run time, and every open connection ties up an instance. It works, but cost it out by concurrent connections and set an explicit ceiling. With heavy traffic and long-lived connections, a resident Go process behind a load balancer is almost always cheaper and less surprising.
Every open connection is a goroutine and a file descriptor. 10,000 users online means 10,000 goroutines (Go handles that comfortably) but also 10,000 FDs. Know your process’s ulimit before you reach that number, not after. Put a metric on subscriber count and alert when it hits 80 percent of the ceiling you calculated.
Idle timeouts at upper layers kill silent connections. Every load balancer has an idle_timeout, which defaults to 60 seconds in many places. The 15-second heartbeat is there to live with that. Connections dying regularly on a round-number cycle are almost certainly an infrastructure timeout, not a bug in your code.
Measure time to the client, not just server processing time. An event leaving the goroutine in 2ms and reaching the screen 400ms later is normal, and no server-side profile sees those 398ms. To get at the truth, attach a timestamp to the payload, measure on the client, and send that number back.
Nothing new to operate
This approach adds no new component to run: the Go server is one HTTP handler, and the React client is fetch plus a store. No broker. No new port to open.
All the difficulty is in the small things: an anti-buffering header, a heartbeat, a bounded channel, a buffer that survives across read calls, a selector narrow enough not to re-render the whole tree. All old stuff. No new technology here.
There’s one spot I haven’t measured: any room that publishes faster than 50 events/second needs the buffer figure of 256 recalculated from scratch.
Keep reading
Need a developer for a contract or freelance project?
I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.