Server-Sent Events carry server-to-client updates in a streaming HTTP response. They do not turn HTTP into a bidirectional socket, and automatic reconnection does not make delivery durable. Treat event framing, connection lifetime, retained history, and application acknowledgment as separate contracts.

A response contains framed text events

An SSE response uses text/event-stream and UTF-8. Fields are line-based, and a blank line ends a block. Multiple data lines within one event are joined with newline characters. A colon-prefixed comment is ignored as event data, so it can carry a heartbeat without triggering an application message.

The event field selects a named event type. Without it, the default type is message; an update event needs an update listener rather than only an onmessage handler. An unfinished event at EOF is not dispatched merely because the connection ended. Correct network delivery of bytes therefore does not prove correct event framing or listener registration.

The id field is a string, not a protocol-mandated monotonic integer. Applications may choose sequence numbers, but they must define the ordering and retention policy themselves. An empty id resets the retained ID; it is not an acknowledgment that all business work has completed.

Reconnection needs retained history

On reconnection, a browser can send its retained ID in Last-Event-ID. This is a cursor hint, not a transaction acknowledgment. The server needs an authorized, retained history and a defined way to resume after that cursor. Reopening a connection cannot reconstruct events that were never recorded or have already expired.

Define behavior when the cursor is no longer usable: obtain a fresh snapshot, reject the resume request, or follow another explicit application contract. Silently continuing from the latest event hides a gap. Consumers may also need deduplication; receiving an event does not prove that a downstream action completed exactly once.

EventSource reconnects after recoverable disconnections, while HTTP 204 explicitly stops reconnection. Other terminal failures exist, so not every error is retried forever. A retry field supplies a reconnection delay in milliseconds, but reconnection timing is not an application delivery deadline.

When it breaks

Handlers and intermediaries can buffer output even after the application has produced an event. Flush at the origin, then verify delivery across the actual proxy path. A heartbeat can help with some inactivity timers, but it does not override a fixed total request deadline. Bound subscriber queues and define what happens when a consumer cannot keep up.

Authentication and CORS still apply. Native EventSource exposes a credentials mode, not arbitrary request-header configuration. Do not assume it accepts the same authorization options as fetch. HTTP/1.x connection limits and HTTP/2 stream limits also matter when many tabs subscribe; multiplexing does not remove connection-level resource budgets.

Check it quickly

Save this as sse_probe.go and run go run sse_probe.go with Go 1.23 or newer. A local server flushes a comment plus a complete named event, then keeps the response open. The client checks the exact bytes before canceling that request. The server must observe the disconnect before subsequent checks proceed.

package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"runtime"
	"sync/atomic"
	"time"
)

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func check(ok bool, message string) {
	if !ok {
		panic(message)
	}
}

func main() {
	const firstFrame = ": heartbeat\n\nid: cursor-A\nevent: update\ndata: hello\ndata: world\n\n"
	const replayFrame = "id: cursor-B\nevent: update\ndata: resumed\n\n"
	disconnected, stop := make(chan struct{}), make(chan struct{})
	var handled atomic.Int64
	var firstEnded atomic.Bool
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		handled.Add(1)
		cursor := r.Header.Get("Last-Event-ID")
		w.Header().Set("X-Received-Cursor", cursor)
		switch cursor {
		case "cursor-B":
			w.WriteHeader(http.StatusNoContent)
			return
		case "", "cursor-A":
		default:
			http.Error(w, "cursor expired; fetch a fresh snapshot", http.StatusGone)
			return
		}
		w.Header().Set("Content-Type", "text/event-stream")
		w.Header().Set("Cache-Control", "no-cache")
		frame := replayFrame
		if cursor == "" {
			frame = firstFrame
		}
		_, err := io.WriteString(w, frame)
		must(err)
		must(http.NewResponseController(w).Flush())
		if cursor == "" {
			defer firstEnded.Store(true)
			select {
			case <-r.Context().Done():
				close(disconnected)
			case <-stop:
			}
		}
	}))
	defer server.Close()
	defer close(stop)
	transport := &http.Transport{}
	defer transport.CloseIdleConnections()
	client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
	open := func(ctx context.Context, cursor string) *http.Response {
		request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
		must(err)
		if cursor != "" {
			request.Header.Set("Last-Event-ID", cursor)
		}
		response, err := client.Do(request)
		must(err)
		check(response.Header.Get("X-Received-Cursor") == cursor, "server must receive the exact cursor")
		return response
	}
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	first := open(ctx, "")
	check(first.StatusCode == 200 && first.Header.Get("Content-Type") == "text/event-stream", "SSE response required")
	buffer := make([]byte, len(firstFrame))
	_, err := io.ReadFull(first.Body, buffer)
	must(err)
	check(string(buffer) == firstFrame && !firstEnded.Load(), "frame must arrive before handler completion")
	cancel()
	must(first.Body.Close())
	select {
	case <-disconnected:
	case <-time.After(2 * time.Second):
		panic("server did not observe disconnect")
	}
	fmt.Println("initial: comment and complete cursor-A frame arrive while response remains open")
	complete := func(cursor string, wantStatus int, wantBody string) {
		response := open(context.Background(), cursor)
		body, readError := io.ReadAll(response.Body)
		closeError := response.Body.Close()
		must(readError)
		must(closeError)
		check(response.StatusCode == wantStatus && string(body) == wantBody, "wrong replay/status response")
		if wantStatus == 200 {
			check(response.Header.Get("Content-Type") == "text/event-stream", "replay must keep the SSE media type")
		}
		fmt.Printf("cursor=%s status=%d\n", cursor, response.StatusCode)
	}
	complete("cursor-A", 200, replayFrame)
	complete("expired", 410, "cursor expired; fetch a fresh snapshot\n")
	complete("cursor-B", 204, "")
	check(handled.Load() == 4, "expected four HTTP requests")
	fmt.Println("explicit replay requests; no browser reconnection or durability claim")
	fmt.Println(runtime.Version())
}

The first event contains cursor-A, the update type, and two data lines: a conforming SSE client would dispatch one update carrying hello followed by a newline and world. The comment is not another event. The fixture verifies that these complete wire blocks arrive while the initial handler is still waiting, rather than only after EOF.

A new request explicitly sends Last-Event-ID: cursor-A and receives the cursor-B frame. The server echoes the received cursor so the test can check exact transmission. An expired cursor returns 410 with a snapshot instruction; that status is this application’s policy, not an SSE-mandated cursor-expiry response. A cursor-B request receives 204 and an empty body.

All four requests passed on macOS with Go 1.27.0. Each client request has a two-second timeout, and disconnect observation has its own bound. These short deadlines are test safeguards, not universal settings for long-lived production subscriptions. Bodies, waiting handlers, and local resources are cleaned up.

The Go program checks literal framing and makes replay requests itself; it is not a browser EventSource implementation. It does not test automatic browser reconnection, proxy buffering, authentication, CORS, durable retention, exactly-once effects, or slow-consumer load. Those need separate verification beyond a successful local stream.

Further reading

Sources