The go buffered channels backpressure sse pattern is the difference between a streaming endpoint that survives a slow mobile client and one that silently exhausts goroutines under load. When you stream Server-Sent Events from a Go HTTP handler, the writer is the client’s socket, and if that socket stalls, your producer goroutine stalls with it unless you decouple the two. This guide walks through a concrete implementation, buffer sizing tradeoffs, and the disconnect handling that most toy examples omit.
Why SSE needs backpressure in Go
SSE looks trivial: set Content-Type, loop, write data: ...\n\n, flush. But the underlying TCP connection is subject to network jitter, browser throttling, and proxy buffering. Without backpressure, the goroutine generating events blocks on w.Write, holding stack and any locked resources. Multiply by thousands of concurrent clients and you have a denial-of-service vector born from your own code.
Go’s idiom for decoupling concurrent producers and consumers is the channel. An unbuffered channel makes the producer wait for the consumer; a buffered channel gives you a fixed-size queue that absorbs bursts and lets you decide what happens at capacity.
The naive unbuffered implementation
A common first cut:
func naiveHandler(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "no flush", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
ch := make(chan string) // unbuffered
go func() {
for i := 0; i < 1000; i++ {
ch <- fmt.Sprintf("event %d", i) // blocks until consumer reads
}
close(ch)
}()
for msg := range ch {
fmt.Fprintf(w, "data: %s\n\n", msg)
flusher.Flush()
}
}
If the client reads slowly, the producer goroutine parks on the send. That’s actually backpressure, but it’s uncontrolled: the producer can’t shed load, can’t heartbeat, and can’t react to client disconnect because it’s blocked on a channel send with no select.
Buffered channels as the decoupling buffer
Replace make(chan string) with make(chan string, 64). Now the producer can enqueue up to 64 messages without a waiting consumer. The consumer (the HTTP handler goroutine) drains at socket speed. When the buffer is full, the producer’s send blocks—unless you use a select with a default or a context timeout to drop or log.
This is the core go buffered channels backpressure sse strategy: bound memory, preserve ordering, and give the producer a chance to bail when the client is gone.
Implementing the streaming handler
Below is a minimal but production-shaped handler. It uses a buffered channel, respects request context, sends periodic comments as keep-alives, and stops producing when the client disconnects.
func streamHandler(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ctx := r.Context()
out := make(chan string, 128) // buffered backpressure point
// producer
go func() {
defer close(out)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for i := 0; ; i++ {
select {
case <-ctx.Done():
return
case <-ticker.C:
// heartbeat to keep proxy from closing idle
select {
case out <- ": keep-alive\n":
default:
// buffer full, skip heartbeat
}
default:
msg := fmt.Sprintf("data: tick %d\n\n", i)
select {
case out <- msg:
case <-ctx.Done():
return
default:
// buffer full: drop or metric
log.Printf("drop at %d", i)
}
}
}
}()
// consumer
for {
select {
case <-ctx.Done():
return
case msg, ok := <-out:
if !ok {
return
}
if _, err := w.Write([]byte(msg)); err != nil {
return
}
flusher.Flush()
}
}
}
Key points: the producer never blocks indefinitely; the default cases let it shed load when the buffered channel is full. The consumer loops on ctx.Done() and channel receives, so a client disconnect (which cancels the request context) terminates both sides.
Sizing the buffer: tradeoffs
Buffer size is a memory-latency knob. A size of 1 gives strict backpressure but no burst absorption. A size of 10,000 absorbs huge spikes but can heap-allocate that many strings if the client goes silent.
Rule of thumb: size for the expected inter-flush latency multiplied by peak event rate, plus a small margin. If you emit 100 events/sec and the client can stall for 1 second, 128 is comfortable. If you emit 10KB payloads, multiply by payload size to estimate MB of RAM per connection.
If the data is reproducible or non-critical (telemetry, logs), dropping on full buffer is fine. If every event matters (financial ticks), you should block or apply admission control upstream rather than silently drop.
Handling client disconnects
r.Context() is cancelled when the connection closes. Both goroutines must observe it. The producer checks ctx.Done() before every send. The consumer checks it in the outer select. Without this, a closed TCP connection leaves the producer spinning or blocked until the next write error—which may never come if you only read.
Also, always close(out) in the producer via defer. The consumer’s range or ok check then exits cleanly. Failing to close leaks the consumer loop.
Consuming upstream SSE with the same pattern
The go buffered channels backpressure sse approach is equally useful when you are the client of an upstream stream. Suppose you proxy token deltas from an LLM inference gateway. n4n.ai exposes an OpenAI-compatible SSE endpoint; if you forward its tokens to your own clients, put a buffered channel between the upstream http.Response.Body reader and your writer. That way a slow browser doesn’t stall the TCP read from the gateway, and you can apply timeouts or cancel the upstream request when the browser navigates away.
upstream, _ := http.Get("https://api.n4n.ai/v1/chat/completions") // SSE stream
defer upstream.Body.Close()
proxy := make(chan []byte, 256)
go func() {
defer close(proxy)
buf := make([]byte, 4096)
for {
n, err := upstream.Body.Read(buf)
if n > 0 {
select {
case proxy <- buf[:n]:
case <-ctx.Done():
return
default:
// drop or log backpressure
}
}
if err != nil {
return
}
}
}()
This keeps the gateway connection from blocking on a stalled downstream.
Common pitfalls
- Missing Flusher check. If
wisn’t anhttp.Flusher(e.g., behind certain middlewares), your writes buffer until handler return. Assert it. - No heartbeat. Many load balancers kill idle connections at 30–60s. Send
: ping\n\nperiodically even if your buffer is full (use a separate higher-priority path or accept drop). - Blocking send in producer. A plain
out <- msgwithoutselectreintroduces unbounded stall. Always pair withctx.Done()ordefault. - Buffer too large. It hides latency problems and invites OOM under many connections. Monitor channel length with a metric.
- Not setting headers.
Cache-Control: no-cacheprevents corporate proxies from caching your stream. - Ignoring write errors.
w.Writecan fail after disconnect; check the error and return.
Testing backpressure
Use httptest with a slow consumer:
func TestSlowClient(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(streamHandler))
defer srv.Close()
resp, _ := http.Get(srv.URL)
defer resp.Body.Close()
// read one byte then sleep
buf := make([]byte, 1)
resp.Body.Read(buf)
time.Sleep(3 * time.Second)
// assert producer didn't panic, metrics show drops
}
Combine with pprof to confirm goroutine counts stay flat when you hammer with wrk against a throttled reader.
Final checklist
- Use
make(chan T, N)with N derived from payload size and stall tolerance. - Producer uses
selectonctx.Done()anddefaultto avoid blocking. - Consumer loops on
ctx.Done()and channel, writes, flushes. - Close the channel in producer
defer. - Send heartbeats on a ticker, but don’t let them block your hot path.
- Check
http.Flusher, set SSE headers. - Metric the channel length and drop count.
The go buffered channels backpressure sse pattern is not exotic—it’s the disciplined application of Go’s concurrency primitives to a real network constraint. Get the buffer size wrong and you’ll learn about it at 3am; get it right and your streaming API just works.