An SSE stream is an HTTP request that never ends. Every default you did not touch is working against it.
TL;DR: your SSE endpoint breaks twice before it reaches your logic. Once because the
Connectionheader is illegal in HTTP/2. Once because your Go server's default timeouts cut the stream at 30 seconds. And if you stay on HTTP/1.1, a permanent stream freezes the rest of your page. In August 2026, Go patched a flaw where a timeout was not applied to HTTP/2 connections. Same lesson: a timeout only protects what it covers.
This article is for Go developers shipping streaming to production. SSE, WebSocket, long-poll: anything that stays open.
The setup
SSE stands for Server-Sent Events. It is a one-way HTTP stream. The server pushes messages, the browser listens.
The format is simple. You open a text/event-stream response, you write lines, you flush. The browser receives them as they come.
I run two SSE endpoints in production. The first is a Go notification service, on Kubernetes, behind a reverse proxy. The second is an internal cockpit that refreshes its UI without a page reload.
Both broke. In different places, with the same symptom.
An SSE stream is a request that never ends
Here is the key to the whole article. To your server, an SSE stream is not a special case. It is a very slow request.
And every guardrail in an HTTP server targets the slow request. Write timeout, context timeout, idle timeout. They exist to kill whatever drags on.
Your legitimate stream looks exactly like what they are meant to kill. That is the whole problem.
The Connection header is illegal in HTTP/2
First incident. The endpoint answers 200, then the browser shows net::ERR_HTTP2_PROTOCOL_ERROR. The client reconnects in a loop.
The cause was one line. My handler set a Connection: keep-alive header. We all copy it from some old SSE tutorial.
Connection is a hop-by-hop header. A hop-by-hop header applies to one network hop only, never end to end. HTTP/2 forbids these headers (RFC 9113 §8.2.2).
The browser speaks HTTP/2 to your ingress. The ingress re-emits your response. The illegal header resets the stream right after the 200.
The silly part is that the header does nothing for you. HTTP/2 is multiplexed and persistent by design. And in HTTP/1.1, keep-alive is already the default.
Keep three headers. Not one more.
// The only headers an SSE stream needs
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // for nginx
w.WriteHeader(http.StatusOK)
flusher.Flush()
// Never here: Connection, Keep-Alive, Transfer-Encoding, Upgrade
Your default timeouts kill the stream at 30 seconds
The illegal header was the visible symptom. The real cause was somewhere else, and it came back a few days later.
My services share an in-house package that builds the HTTP server. It sets sane defaults for an API.
// Defaults from the shared package
ReadTimeout: 15 * time.Second
WriteTimeout: 30 * time.Second
IdleTimeout: 60 * time.Second
// plus a middleware that cancels the context after 30s
Two of those values kill an SSE stream. The WriteTimeout closes the connection while you are writing. The middleware cancels the request context after 30 seconds.
So the stream dies at 30 seconds. The browser shows the same HTTP/2 error, and the client loops again. The symptom blames the protocol. The culprit is your config.
The fix needs both settings. One alone is not enough, and it took me two rounds to learn that.
// You need both, not either
BypassTimeoutPaths: []string{"/api/v1/notifications/stream"},
WriteTimeoutOverride: map[string]time.Duration{
"/api/v1/notifications/stream": 0, // 0 = no write timeout
},
A word on the other two timeouts. ReadTimeout is harmless, because the client sends nothing after its request. IdleTimeout is harmless too, as long as you write more often than it fires. In my case: a heartbeat every 30 seconds, an IdleTimeout of 60.
On HTTP/1.1, a permanent stream freezes the rest of your page
Second incident, different project, different layer. I had shipped SSE on an internal cockpit. A few days later, I ripped it out.
The symptom: buttons spinning forever. Requests left and never came back.
The cause was not in my code. A browser caps its connections at about six per origin on HTTP/1.1. An SSE stream holds one of them open forever.
That leaves five slots for the rest of the page. Open a second tab and you are out.
HTTP/2 removes the problem. One tunnel carries every request in parallel, the stream included.
So I put SSE back, with a guard. The endpoint only answers if the request came through the HTTPS front.
// The proxy sets this header, the direct HTTP/1.1 origin does not
func servedOverHTTP2(r *http.Request) bool {
return r.Header.Get("X-Forwarded-Proto") == "https"
}
if !servedOverHTTP2(r) {
http.Error(w, "live updates unavailable", http.StatusNotFound)
return
}
A browser extension still talks to the direct origin, on HTTP/1.1. It gets a 404 on that endpoint and never opens a stream.
Removing SSE was the right call at the time. Putting it back behind an HTTP/2 front was the right call later. Both count.
August 2026: Go patched a timeout that did not apply on HTTP/2
This story just echoed inside the standard library.
On 13 August 2026, the Go team shipped 1.26.6 and 1.25.13. They fix ten security issues. One of them is GO-2026-6089, also known as CVE-2026-56853.
Its official title: "apply ReadHeaderTimeout when doing unencrypted HTTP/2 check". In plain words, ReadHeaderTimeout was not applied while detecting a cleartext HTTP/2 connection.
A client could hold connections open without ever paying the timeout. That is a denial of service through resource exhaustion.
The fix landed in go1.25.13, go1.26.6 and go1.27.0-rc.3. Go 1.27 shipped six days later, on 19 August.
Go check your go.mod files. Most of mine sit on go 1.25, so they were affected.
What I take from it is not the flaw itself. It is the pattern coming back: the timeout existed, it just did not cover the HTTP/2 path.
The checklist before you ship an SSE endpoint
Run this list before a stream goes to production. It would have saved me two incidents.
- [ ] No
Connection,Keep-Alive,Transfer-EncodingorUpgradeheader in the handler - [ ] The server
WriteTimeoutis disabled on that path - [ ] The timeout middleware is bypassed on that path
- [ ] A heartbeat fires more often than the
IdleTimeout - [ ] The stream is only served behind an HTTP/2 front
- [ ] The proxy does not buffer the response
- [ ] The client reconnects, and you count those reconnections
- [ ] Your Go version is current, standard library timeouts included
What to remember
A timeout only protects what it covers. That holds for your config, and it holds for the standard library.
When a stream breaks, do not start with your business code. Go down to the transport first. Headers, timeouts, and the protocol between the browser and your proxy.
And accept removing a feature that hurts. A disabled SSE beats a frozen page.
Shipping streaming to production and it breaks for no clear reason? Let's talk.
Sources: RFC 9113 §8.2.2 (connection-specific header fields in HTTP/2) · GO-2026-6089 / CVE-2026-56853 · Go 1.26.6 and 1.25.13 (13 August 2026) · Go 1.27 release notes · MDN, Using server-sent events
Top comments (0)