Production-grade guide to server sent events architecture covering architecture patterns, implementation strategies, testing approaches, and operational best practices for enterprise engineering teams.
Server Sent Events (SSE) architecture enables a server to push real-time updates to clients over a single HTTP connection. It is ideal when a client needs to receive frequent, unidirectional updates—such as live notifications, stock tickers, or chat messages—without the overhead of polling or the complexity of WebSockets. Use SSE when clients are primarily consumers of events, and the server is the sole source of truth.
To initiate an SSE stream, a client sends an HTTP GET request with Accept: text/event-stream and receives a continuous response body. The server maintains the connection and writes event data in a standardized format.
GET /events HTTP/1.1
Host: api.example.com
Accept: text/event-stream
Connection: keep-alive
Cache-Control: no-cache
The server responds with a 200 OK and begins streaming data:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Connection: keep-alive
Cache-Control: no-cache
Each event is a sequence of lines, where each line is a key-value pair separated by a colon. The event body is terminated by a blank line (\n\n), and events can be separated by multiple blank lines.
event: message
data: New message from Alice
id: 123
retry: 5000
event: notification
data: User logged in
data: {"userId": 45, "timestamp": "2024-08-10T12:00:00Z"}
id: 456
The data field may span multiple lines by prefixing subsequent lines with a space or tab. The id field allows clients to resume from a specific event; the server can resume from the last id sent by the client.
Clients must handle disconnections and reconnects gracefully. On reconnect, the client sends the Last-Event-ID header with the last event ID it received:
GET /events HTTP/1.1
Host: api.example.com
Accept: text/event-stream
Last-Event-ID: 456
The server uses this ID to send only events that occurred after the last known event. If no new events have arrived, the server keeps the connection open and sends a ping event periodically.
Clients can also send a ping message to keep the connection alive. The server responds with an event: ping and data: ping to acknowledge:
event: ping
data: ping
The retry field in the response sets the interval (in milliseconds) for reconnection attempts. If the client loses connection, it waits for the specified retry interval before reconnecting.
Servers generate events by writing to the HTTP response stream. Each event must be properly formatted with at least one data field. The event field names the type of event, which clients can listen for using addEventListener in JavaScript or equivalent logic in other languages.
A typical server implementation in Go:
func serveEvents(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// Send initial event
fmt.Fprintf(w, "event: initial\n")
fmt.Fprintf(w, "data: Server started\n")
fmt.Fprintf(w, "\n")
w.(http.Flusher).Flush()
// Main loop
for {
select {
case event := <-eventChannel:
fmt.Fprintf(w, "event: %s\n", event.Type)
fmt.Fprintf(w, "data: %s\n", event.Payload)
if event.ID != "" {
fmt.Fprintf(w, "id: %s\n", event.ID)
}
if event.Retry > 0 {
fmt.Fprintf(w, "retry: %d\n", event.Retry)
}
fmt.Fprintf(w, "\n")
w.(http.Flusher).Flush()
case <-r.Context().Done():
return // Client disconnected
}
}
}
The Flusher interface is essential: without calling Flush, the server may buffer responses and delay event delivery. A common mistake is forgetting to flush after writing event data, leading to delayed or batched events.
All fields are optional and may be repeated. The following fields are recognized:
event: The event type (e.g., message, user.created). Clients can register handlers for specific event types.data: The main payload. Can span multiple lines.id: A unique identifier for the event. Used for resumption and deduplication.retry: The time (in milliseconds) to wait before reconnecting. Default is 30000.comment: Lines starting with : are comments. Ignored by clients but useful for diagnostics.: This is a comment line
: Timestamp: 2024-08-10T12:00:00Z
event: user.created
id: 100
data: {"name": "Bob", "email": "[email protected]"}
data: {"role": "admin"}
The data field can be split across multiple lines. Each line starting with a space or tab is appended to the current data value:
event: chat.message
data: {"sender": "Alice"}
data: {"message": "Hello, world!"}
data: {"timestamp": "2024-08-10T12:00:00Z"}
This produces:
{"sender": "Alice"}{"message": "Hello, world!"}{"timestamp": "2024-08-10T12:00:00Z"}
To avoid concatenation, use data: on each line with a space prefix:
event: chat.message
data: {"sender": "Alice"}
data: {"message": "Hello, world!"}
data: {"timestamp": "2024-08-10T12:00:00Z"}
When a client disconnects, the server must detect the loss and resume event delivery. The server keeps the connection open and writes events as they occur.
A common failure occurs when the server process crashes or restarts. The client reconnects but misses events that occurred during the downtime. To mitigate this, the server can store a log of recent events and send them on reconnect.
For example, a Redis-backed event store can be used:
type Event struct {
ID string
Type string
Payload string
Retry int
}
func (s *Server) sendEventsToClient(w http.ResponseWriter, clientID string) {
// Read from Redis stream starting from last known ID
lastID, err := s.redis.Get(clientID + ":last-id")
if err != nil {
lastID = "0"
}
entries, err := s.redis.XRead(10*time.Second, lastID, "events")
for _, entry := range entries {
event := Event{
ID: entry.ID,
Type: entry.Fields["type"].(string),
Payload: entry.Fields["data"].(string),
}
if retry, ok := entry.Fields["retry"].(string); ok {
event.Retry, _ = strconv.Atoi(retry)
}
fmt.Fprintf(w, "event: %s\n", event.Type)
fmt.Fprintf(w, "data: %s\n", event.Payload)
if event.Retry > 0 {
fmt.Fprintf(w, "retry: %d\n", event.Retry)
}
fmt.Fprintf(w, "id: %s\n", event.ID)
fmt.Fprintf(w, "\n")
w.(http.Flusher).Flush()
}
// Subscribe to new events
pubsub := s.redis.Subscribe("events")
for msg := range pubsub.Channel() {
event := parseEvent(msg.Payload)
fmt.Fprintf(w, "event: %s\n", event.Type)
fmt.Fprintf(w, "data: %s\n", event.Payload)
if event.Retry > 0 {
fmt.Fprintf(w, "retry: %d\n", event.Retry)
}
fmt.Fprintf(w, "id: %s\n", event.ID)
fmt.Fprintf(w, "\n")
w.(http.Flusher).Flush()
}
}
This ensures that reconnecting clients receive all events from the moment they last connected.
The server writes data to the io.Writer but does not call Flush. Events are delayed by buffering and may arrive in batches. Always call Flusher.Flush() after writing each event.
Content-Type header The server sets Content-Type: text/event-stream, but clients may expect text/plain. A mismatch causes clients to skip the first event or parse the stream incorrectly.
The id field is used for resumption, but clients often forget to send Last-Event-ID on reconnect. Without it, clients start from the beginning.
When data spans multiple lines, the client receives a single concatenated string. If the payload is JSON, clients must handle data: {"a":1}{"b":2} instead of {"a":1,"b":2}.
id on the first event The client expects to resume from the first event, but the server omits the id field. The client may treat the first event as a new event rather than the first in a sequence.
The server sends retry: 3000, but the client does not use it. Instead, it defaults to 30 seconds. This leads to inconsistent reconnect timing.
The server sends event: ping but clients do not handle the ping event. This results in an unacknowledged heartbeat, and clients may miss reconnection opportunities.
Use curl to test a live SSE stream:
curl -v -H "Accept: text/event-stream" http://api.example.com/events
The output shows the raw stream:
> GET /events HTTP/1.1
> Host: api.example.com
> Accept: text/event-stream
>
< HTTP/1.1 200 OK
< Content-Type: text/event-stream
< Connection: keep-alive
< Cache-Control: no-cache
<
: Initial connection
event: message
data: Welcome to the real-time feed
id: 1
To simulate a client that reconnects, use curl with Last-Event-ID:
curl -v -H "Accept: text/event-stream" \
-H "Last-Event-ID: 456" \
http://api.example.com/events
Use ps aux | grep server to verify that the server process is not exiting. A common failure is a server that writes events but does not keep the connection open—often because it returns from the handler before the client disconnects.
SSE fits naturally into event-driven systems. Events can be generated by background jobs, message queues, or database triggers.
For example, a PostgreSQL trigger can write events to a table, and a service can stream them:
CREATE OR REPLACE FUNCTION notify_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO event_stream (event_type, data, event_id)
VALUES ('user.created', json_build_object('userId', NEW.id, 'email', NEW.email), NEW.id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_created
AFTER INSERT ON users
FOR EACH ROW EXECUTE FUNCTION notify_new_user();
A service then reads from event_stream and pushes to SSE clients:
func streamEventsFromDB(db *sql.DB) {
rows, _ := db.Query("SELECT id, event_type, data FROM event_stream ORDER BY id")
for rows.Next() {
var id, eventType, data string
rows.Scan(&id, &eventType, &data)
fmt.Fprintf(w, "event: %s\n", eventType)
fmt.Fprintf(w, "data: %s\n", data)
fmt.Fprintf(w, "id: %s\n", id)
fmt.Fprintf(w, "\n")
w.(http.Flusher).Flush()
}
}
This creates a real-time feed from any database change.
Server Sent Events architecture is a lightweight, efficient way to deliver real-time updates from server to client. It requires minimal infrastructure and is easy to implement. The key to success is correct formatting of the event stream: every data, event, id, and retry field must be written with a newline, and every event must be flushed. Clients must send Last-Event-ID on reconnect, and servers must handle client disconnections and resume from the correct event ID. When events are missed or delayed, the root cause is often a missing Flush or a misaligned id field. SSE is not a magic solution—it is a disciplined pattern that, when followed precisely, delivers real-time data with minimal overhead.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy