Streaming from a server without websockets
Server-sent events (SSE) offer a way to stream updates from a server to a client that is considerably simpler than websockets when you only need one-way communication. The protocol is built directly on HTTP, so it works with the standard request/response model — no upgrade dances, no message framing beyond plain text lines.
SSE are particularly well-suited to cases like a web service that provisions virtual machines, where the client wants to know when the VM is up. Polling works, but a server stream eliminates the repeated empty requests.
The wire protocol is plain HTTP
An SSE session begins with a normal GET request, e.g. https://yoursite.com/events. The client sets Connection: keep-alive to allow a long-lived connection. The server responds with Content-Type: text/event-stream and then writes events as text chunks:
event: status
data: one
The events can be spaced out over time, and the client reads them as they arrive. The payload in data: is arbitrary — JSON works fine, as in data: {'name': 'ahmed'}. Each event may carry optional id: and retry: fields alongside event: and data:. Because the format is this simple, no special server library is required.
A curl request against a stream produces output like this:
$ curl -N 'http://localhost:3000/sessions/15/stream'
event: panda
data: one
event: panda
data: two
event: panda
data: three
event: elephant
data: four
Client code is one API
On the browser side, the EventSource object handles the connection. You can register a single handler for all events or separate handlers per event type. The example below listens for events of type panda:
const evtSource = new EventSource("/sessions/15/stream", { withCredentials: true })
evtSource.addEventListener("panda", function(event) {
console.log("status", event)
});
One constraint to keep in mind: SSE are one-way. The client makes a single initial request and the server streams responses; there is no client-to-server messaging channel the way websockets provide.
Automatic reconnection is built in
A notable behavioral difference from ordinary HTTP is in the MDN documentation:
By default, if the connection between the client and server closes, the connection is restarted. The connection is terminated with the .close() method.
If the server closes the connection, the client will quietly open a new request after a short delay. That reconnection is designed to tolerate accidental drops mid-stream, but it also means you must call .close() on the EventSource when you genuinely want the stream to end — otherwise the client keeps retrying.
Reconnection also interacts with event IDs. If the server sets an id: on each event, the reconnecting client sends a Last-Event-ID header with the ID of the last event it received, letting the server resume from that point.
Two integration gotchas
Putting SSE behind a typical Rails + nginx stack surfaces two classic problems.
First event only, then silence
A common failure mode looks like this: the server writes one event, then sleeps, then writes a second — but the second event never reaches the client. The culprit is often nginx silently closing the connection after the first chunk. By default nginx proxies upstream requests over HTTP/1.0, which does not handle the chunked streaming response SSE depends on. The server and client race, and unless the second event is written before the connection drops, it is lost. Setting proxy_http_version 1.1 in the nginx config fixes it.
Events arrive all at once
The opposite problem: a handler that streams one event per second instead buffers the entire response and delivers ten events together at the end. This happens because the Rack ETag middleware computes a hash of the full response, which forces the whole body to be read into memory before anything is sent.
The Stack Overflow workaround of disabling the Rack ETag middleware globally is heavy-handed. A lighter fix that applies only to the streaming endpoint is to send a Last-Modified response header, which short-circuits the ETag middleware. That, plus disabling nginx's buffering via X-Accel-Buffering: no, keeps the stream flowing in real time.
Both issues are worth a moment of reflection rather than blind copy-paste. The HTTP/1.0 proxy default and the ETag buffering are each understandable if you trace the request path — and both were documented, explained problems that people had already solved in public threads.



