May 2025·7 min read

Server-Sent Events in Production: What They Get Right and Where They Break

Server-Sent Events are the right protocol for pushing real-time updates from a server to a browser. They are simpler than WebSockets, work over standard HTTP, reconnect automatically, and require almost no client-side setup. For streaming AI pipeline progress to a user — where data flows one way and the client does not need to send messages back — SSE is a better choice than WebSockets in most cases.

In production, however, SSE runs into problems that do not appear in local development. Most of them come from the infrastructure between the server and the browser rather than from SSE itself.

How SSE actually works

SSE is an HTTP connection that stays open. The server sets Content-Type to text/event-stream, writes lines in the format "data: {payload}\n\n", and flushes after each event. The browser's EventSource API reads these lines and fires onmessage handlers. The connection is unidirectional — only the server sends data.

The flush is the critical part. Without an explicit flush after each write, the data sits in the server's output buffer and the client receives nothing until the buffer fills or the connection closes. In Go, this means calling http.Flusher explicitly after each write — one of several reasons Go is well-suited for AI pipeline backends. In languages where the framework handles buffering transparently in most contexts, SSE requires opting out of that behavior.

The reverse proxy problem

The most common cause of SSE failures in production is a reverse proxy that buffers the response. Nginx, by default, collects the entire response before forwarding it to the client. For a standard HTTP request that completes quickly, this is invisible. For an SSE connection that stays open for minutes and sends events incrementally, buffering means the client receives nothing until the connection closes.

The fix is the X-Accel-Buffering: no header, which tells nginx to disable response buffering for that specific response. This needs to be set by the application server, not configured globally, because you typically only want to disable buffering for SSE endpoints. Other platforms have analogous settings — Cloudflare, Render, and most load balancers respect this header or have their own equivalent.

Connection timeouts and keepalives

Cloud platforms and load balancers impose idle connection timeouts. If no data is sent on an SSE connection for a certain period — typically 30 to 60 seconds depending on the platform — the infrastructure closes the connection. This is designed to clean up stale connections, but it also kills legitimate SSE connections during quiet periods in the pipeline.

The solution is to send SSE keepalive comments at intervals shorter than the platform's timeout. SSE comments are lines starting with a colon (": keepalive\n\n") that the browser ignores but that count as activity on the connection. Sending one every 20 seconds prevents the infrastructure from treating the connection as idle. This is a server-side fix and costs almost nothing.

The Vercel proxy problem specifically

Vercel's edge network does not pass SSE through cleanly when used as a reverse proxy. If your frontend is on Vercel and your API is on a separate backend, routing SSE through Vercel's rewrites adds a buffering layer that effectively breaks the streaming. Events are held until the connection closes, at which point they all arrive at once — the opposite of streaming.

The correct architecture is to connect the EventSource directly to the backend URL, bypassing Vercel's proxy entirely. This requires exposing the backend URL to the frontend, which has CORS implications. Configuring the backend to allow the Vercel frontend origin, setting the appropriate CORS headers on the SSE endpoint, and connecting directly is the reliable path. Alternatively, use polling as a fallback for environments where SSE is proxied.

The polling fallback pattern

A robust implementation does not rely on SSE working in every environment. The fallback pattern is: attempt SSE connection, wait a short period for the first message, and if no message arrives, close the SSE connection and switch to polling a separate endpoint that returns buffered state.

The polling endpoint needs to return all accumulated updates since the task started, not just the current status. This means the server must persist updates to a store as they arrive rather than only publishing them to the SSE channel. A Redis list that the pipeline appends to on each update, with the polling endpoint reading from the start of that list, gives you a complete event log that polling clients can catch up to regardless of when they connect.

Client-side reconnection

The EventSource API reconnects automatically when a connection drops, but the default reconnection does not carry any state about where in the event stream the client was. If the server does not implement event IDs and Last-Event-ID handling, a reconnecting client will miss all events that occurred during the disconnection.

For pipelines where missing intermediate events is acceptable — the user only needs to see the final status — this is not a problem. For pipelines where the log of intermediate steps is part of the value, either implement event IDs properly or use the polling fallback with a persisted event log. The polling approach with a Redis list is typically simpler to implement correctly than SSE event ID tracking and covers the reconnection case automatically.