Server Sent Events Architecture

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.

Establishing a Server Sent Events Stream

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.

Client-Side Resumption and Reconnection

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.

Server-Side Event Production

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.

Event Format and Field Semantics

All fields are optional and may be repeated. The following fields are recognized:

: 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"}

Handling Client Disconnections and Server Failures

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.

Common Pitfalls and Silent Failures

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.

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}.

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.

Monitoring and Debugging

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.

Event-Driven Architecture Integration

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.

Summary

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.