What Is Server-Sent Events?
Server-Sent Events (SSE) is a web technology that allows a server to continuously send updates to a client over a single HTTP connection. Instead of repeatedly requesting new information, the client opens a connection and receives events as they become available. SSE is commonly used for live notifications, dashboards, AI-generated text streaming, job progress updates, and activity feeds.
Unlike WebSockets, SSE provides one-way communication from server to client using ordinary HTTP responses. Browsers support it through the built-in EventSource API, including automatic reconnection and event IDs for resuming interrupted streams. The protocol is straightforward, but reliable production implementations require careful handling of buffering, connection limits, slow consumers, and event recovery.
Table of Contents
- How Server-Sent Events Work
- SSE Event Format and Protocol
- SSE vs. WebSockets, Long Polling, and Short Polling
- Implementing SSE with FastAPI
- Reconnection, Event IDs, and Delivery Guarantees
- Production Design Example
- Ready-to-Use Example
- Common Mistakes
- Production Checklist
- Frequently Asked Questions
- Conclusion
How Server-Sent Events Work
SSE uses a long-lived HTTP response with the content type text/event-stream. The client sends an ordinary HTTP request, and the server responds with a stream that remains open while events are delivered.
The server does not need to wait until all data is available. It writes an event, flushes the response, and continues waiting for additional events. The browser parses each event and dispatches it to the appropriate JavaScript listener.
SSE Request Lifecycle
Consider an application that displays notifications as they arrive.
- The browser creates an
EventSourceconnected to/events. - The server responds with HTTP 200 and
Content-Type: text/event-stream. - The connection remains open instead of completing after one response.
- When a notification is created, the server writes a formatted event into the response.
- The browser receives the event and executes its registered callback.
- If the connection is interrupted, the browser normally attempts to reconnect automatically.
Unlike long polling, the client does not need to open a new HTTP request after every notification. Multiple events travel through the same response stream.
The connection is still subject to network failures, proxy timeouts, server restarts, and browser lifecycle behavior. SSE should therefore be treated as a reconnectable stream rather than a permanently reliable connection.
Common SSE Use Cases
SSE works particularly well when the server produces updates and the client primarily consumes them.
- AI response streaming: Deliver generated text incrementally instead of waiting for the entire response.
- Notifications: Push new alerts, messages, or status changes to an open browser session.
- Job progress: Report progress for exports, imports, video processing, and background tasks.
- Dashboards: Stream monitoring metrics, deployment status, or operational events.
- Activity feeds: Display new events without repeatedly polling an API.
SSE is less appropriate when the client must continuously send low-latency messages over the same connection. WebSockets are generally a better fit for that requirement.
SSE Event Format and Protocol
An SSE response is a UTF-8 text stream. Each event consists of one or more fields, with a blank line marking the end of the event.
The most important fields are data, event, id, and retry. The protocol is intentionally simple enough to generate without a specialized serialization library.
Basic SSE Message
A server can send a named event containing JSON data:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
id: 101
event: notification
data: {"message":"Your report is ready"}
The blank line after data completes the event. The browser then dispatches it to a listener registered for the notification event type.
If the event field is omitted, the browser dispatches a standard message event.
JSON is common but not required. The data field contains text, and applications decide how to interpret it.
Multiple Data Lines and Event IDs
An event may contain multiple data lines. The browser combines their values using newline characters.
id: 102
event: message
data: First line
data: Second line
: heartbeat
retry: 3000
The first block produces one event whose data contains two lines. The line beginning with a colon is a comment and does not dispatch an application event. It is commonly used as a heartbeat to keep idle connections active.
The retry field specifies a reconnection delay in milliseconds. In this example, the browser is instructed to wait approximately three seconds before reconnecting after a failure.
The id field updates the browser's last-event ID. On a subsequent reconnection, the browser can send that value in the Last-Event-ID request header.
Event IDs are useful for recovery, but they do not automatically make events durable. The server must retain or reconstruct events if it promises replay after reconnection.
Important HTTP Headers
SSE responses should prevent intermediary caching and use an appropriate streaming content type.
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no
The X-Accel-Buffering: no header can disable response buffering in compatible NGINX configurations. It is not a universal HTTP standard and does not control every reverse proxy or CDN.
For cross-origin browser connections, the server must also configure CORS correctly. When cookies are involved, credentialed CORS requires an explicit allowed origin rather than a wildcard.
SSE vs. WebSockets, Long Polling, and Short Polling
SSE, WebSockets, and polling can all deliver updates to clients, but they differ in communication direction, connection management, and infrastructure requirements.
| Feature | SSE | WebSockets | Long Polling | Short Polling |
|---|---|---|---|---|
| Direction | Server to client | Bidirectional | Server response to client request | Client requests updates |
| Connection | Persistent HTTP response | Persistent WebSocket | Held until event or timeout | Repeated short requests |
| Browser API | EventSource | WebSocket | Fetch | Fetch |
| Automatic reconnection | Built into EventSource | Application-managed | Application-managed | Application-managed |
| Binary messages | No native binary frames | Yes | Possible via HTTP | Possible via HTTP |
| Typical use | Live feeds and AI streaming | Interactive applications | Occasional updates | Simple status checks |
When to Choose SSE
Choose SSE when updates flow primarily from the server to the client, events are text-based, and the application benefits from automatic browser reconnection.
For example, a background report-generation service can stream progress updates while the browser sends cancellation requests through an ordinary REST endpoint.
Choose WebSockets when both sides must send frequent messages over the same connection, such as in collaborative editing or multiplayer applications.
Polling remains reasonable for infrequent updates where maintaining persistent connections would introduce unnecessary operational complexity.
These trade-offs also relate to the broader design choices discussed in Synchronous vs Asynchronous Communication.
Implementing SSE with FastAPI
FastAPI can stream SSE using Starlette's StreamingResponse. An asynchronous generator produces formatted events and yields them as data becomes available.
The generator must avoid blocking operations because blocking the event loop can delay unrelated requests and other active streams.
Basic FastAPI SSE Endpoint
The following endpoint sends five progress events at one-second intervals and then closes the stream.
import asyncio
import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
async def progress_stream():
for progress in range(0, 101, 25):
payload = json.dumps({
"progress": progress,
})
yield (
"event: progress\n"
f"data: {payload}\n\n"
)
await asyncio.sleep(1)
yield "event: complete\ndata: done\n\n"
@app.get("/events")
async def events():
return StreamingResponse(
progress_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
Each yielded block ends with two newline characters, completing an SSE event. The generator uses asyncio.sleep() rather than time.sleep() so that other requests can execute while the stream waits.
Because this example produces a finite sequence, the server closes the response after the completion event. Browser EventSource normally attempts to reconnect when a stream closes, so the client should call close() when it receives the terminal event.
Consuming SSE with EventSource
Browsers expose SSE through the EventSource interface. The client creates a connection and registers listeners for named events.
const source = new EventSource("/events");
source.addEventListener("progress", (event) => {
const payload = JSON.parse(event.data);
console.log(`Progress: ${payload.progress}%`);
});
source.addEventListener("complete", () => {
console.log("Operation completed");
source.close();
});
source.onerror = () => {
console.warn("SSE connection interrupted");
};
When the connection fails unexpectedly, the browser generally attempts to reconnect. The application does not need to create a new EventSource instance after every network interruption.
However, EventSource does not provide an interface for setting arbitrary HTTP request headers. Applications requiring bearer-token authorization headers may use cookie-based authentication or a streaming fetch() implementation instead.
Do not place long-lived API secrets in query parameters simply to work around this limitation. Query parameters may be exposed through logs, browser history, or monitoring systems.
Detecting Client Disconnections
A production stream should stop consuming resources when the client disconnects.
Starlette normally cancels streaming tasks when the response connection closes. Application code should also handle cancellation and release resources such as Redis subscriptions, database cursors, and internal queues.
import asyncio
from contextlib import suppress
async def stream_with_cleanup(queue: asyncio.Queue):
try:
while True:
event = await queue.get()
yield (
"event: update\n"
f"data: {event}\n\n"
)
except asyncio.CancelledError:
raise
finally:
# Release per-connection resources here.
pass
Cancellation must propagate rather than being swallowed by a broad exception handler. Otherwise, abandoned streams can continue running after clients disconnect.
Reconnection, Event IDs, and Delivery Guarantees
Automatic reconnection is one of SSE's most useful features, but reconnecting a transport connection is not the same as recovering missed application events.
If a server stores events only in memory, a restart may erase the history needed to resume a stream. If a load balancer routes the reconnecting client to another instance, that instance may not know which events were previously delivered.
Using Last-Event-ID
When an SSE event contains an id field, the browser tracks the last received event ID. On reconnection, it can include the ID in the Last-Event-ID request header.
The server can then query a durable event store for events after that cursor.
A good event ID should represent an ordered position in the relevant event stream. Random UUIDs identify events but do not automatically provide a sortable sequence.
If the server cannot replay an event because it has expired from retention, the application should define an explicit recovery strategy, such as requesting a fresh snapshot through a REST API.
Duplicates and At-Least-Once Processing
Even with event IDs, the application may process the same event more than once. For example, the browser may receive an event and update its UI before losing the connection. Depending on cursor handling and recovery behavior, the event may be replayed after reconnection.
Clients should make event handling idempotent when duplicate processing would cause problems.
For UI notifications, this might mean storing the latest processed event ID and ignoring previously applied events.
For financial or state-changing operations, SSE should not be treated as an authoritative command-processing channel. Business effects should be protected by durable transactions and idempotency mechanisms.
For more on duplicate handling and delivery semantics, see Message Delivery Guarantees: At-Most-Once vs At-Least-Once vs Exactly-Once.
Production Design Example
Consider a SaaS platform that streams background job progress to connected browsers. Users start report-generation jobs through a REST API and then subscribe to progress updates using SSE.
The application runs across multiple FastAPI instances behind a load balancer. Job workers execute independently, and users may reconnect to a different application instance at any time.
Architecture and Data Flow
A practical architecture uses PostgreSQL for durable job state and Redis Pub/Sub for low-latency event notification.
- The client starts a job using
POST /reports. - The API stores the job record and schedules background processing.
- The browser opens
GET /reports/{job_id}/events. - A worker updates durable job progress and publishes a notification after committing the update.
- The SSE service receives the notification and streams the latest progress to the browser.
- If the connection drops, the browser reconnects and the service retrieves the latest durable state.
For progress reporting, the latest state may be sufficient. The client often does not need every intermediate percentage update.
For audit events, payment changes, or other workflows requiring complete event history, a durable append-only event store is more appropriate than storing only the latest state.
Redis Pub/Sub should not be the only storage mechanism for critical events because subscribers can miss messages while disconnected.
Scaling SSE Connections
Persistent SSE connections consume file descriptors, memory, and request state even when no events are being sent.
For example, 30,000 connected clients distributed across six application instances produce approximately 5,000 concurrent streams per instance if traffic is evenly balanced.
That number is not automatically excessive for an asynchronous server, but actual capacity depends on memory usage, proxy configuration, operating system limits, and event fan-out behavior.
HTTP/1.1 browsers also commonly enforce a small per-origin connection limit, which can become problematic when an application opens multiple SSE connections across tabs. HTTP/2 multiplexing generally provides better connection utilization, although negotiated concurrent stream limits still apply.
Load balancers must support long-lived responses without buffering them. Deployment procedures should also drain connections gracefully, allowing clients to reconnect without overwhelming newly started instances.
See What Is a Load Balancer? for the underlying routing and connection-management considerations.
Backpressure and Slow Consumers
A slow client can receive events more slowly than the server produces them. If the application buffers every event in memory, queues may grow indefinitely.
For state updates such as progress percentages, coalescing events is often acceptable. If progress changes from 20% to 25% to 30% before the client catches up, sending only the latest 30% state may be sufficient.
For ordered event logs, dropping intermediate events may violate the application contract. In that case, use bounded buffers and disconnect or resynchronize clients that fall too far behind.
Backpressure policies should be explicit. Unbounded per-client queues can turn a temporary slow connection into a memory exhaustion problem.
Ready-to-Use Example
The following implementation provides a working FastAPI SSE service backed by Redis Streams. It supports multiple application instances, event IDs, replay after reconnection, and periodic heartbeats.
Redis Streams provide ordered event IDs and retained event history. This example uses Redis blocking reads instead of Pub/Sub, simplifying the implementation while still allowing efficient waiting for new events.
Project Setup
Install the required Python packages:
pip install fastapi uvicorn redis
Start Redis locally:
docker run --rm -d \
--name sse-redis \
-p 127.0.0.1:6379:6379 \
redis:7-alpine
The service will use a Redis Stream named sse:events. Events remain available for replay until removed by the configured retention policy.
FastAPI SSE Server
Save the following as app.py. The implementation accepts a last-event cursor, streams new entries, and sends heartbeat comments during idle periods.
import asyncio
import json
import os
import re
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from redis.asyncio import Redis
from redis.exceptions import RedisError
REDIS_URL = os.getenv(
"REDIS_URL",
"redis://localhost:6379/0",
)
STREAM_KEY = "sse:events"
HEARTBEAT_MS = 15000
MAX_BATCH = 100
CURSOR_PATTERN = re.compile(r"^\d+-\d+$")
@asynccontextmanager
async def lifespan(app: FastAPI):
redis = Redis.from_url(
REDIS_URL,
decode_responses=True,
socket_connect_timeout=2.0,
socket_timeout=20.0,
)
app.state.redis = redis
try:
yield
finally:
await redis.aclose()
app = FastAPI(lifespan=lifespan)
class PublishEvent(BaseModel):
type: str
data: dict
def encode_sse(
event_id: str,
event_type: str,
data: dict,
) -> str:
payload = json.dumps(
data,
separators=(",", ":"),
)
return (
f"id: {event_id}\n"
f"event: {event_type}\n"
f"data: {payload}\n\n"
)
@app.get("/events")
async def subscribe(request: Request):
redis = request.app.state.redis
cursor = request.headers.get(
"Last-Event-ID",
"0-0",
)
if not CURSOR_PATTERN.fullmatch(cursor):
raise HTTPException(
status_code=400,
detail="Invalid event cursor",
)
async def event_stream():
nonlocal cursor
try:
while True:
if await request.is_disconnected():
break
result = await redis.xread(
streams={STREAM_KEY: cursor},
count=MAX_BATCH,
block=HEARTBEAT_MS,
)
if not result:
yield ": heartbeat\n\n"
continue
for _, entries in result:
for event_id, fields in entries:
cursor = event_id
yield encode_sse(
event_id=event_id,
event_type=fields["type"],
data=json.loads(fields["data"]),
)
except asyncio.CancelledError:
raise
except RedisError:
# End the stream so EventSource can reconnect.
return
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@app.post("/events")
async def publish(
event: PublishEvent,
request: Request,
):
redis = request.app.state.redis
if "\n" in event.type or "\r" in event.type:
raise HTTPException(
status_code=400,
detail="Invalid event type",
)
try:
event_id = await redis.xadd(
STREAM_KEY,
{
"type": event.type,
"data": json.dumps(event.data),
},
maxlen=10000,
approximate=True,
)
except RedisError:
raise HTTPException(
status_code=503,
detail="Event store unavailable",
)
return {"event_id": event_id}
The server uses Redis XREAD with a blocking timeout. When no new events arrive within 15 seconds, it emits an SSE comment to keep the connection active.
Redis Stream IDs provide ordered cursors, allowing clients to reconnect using the last event ID they received.
The example limits retained events using approximate stream trimming. If a client disconnects long enough for older events to be removed, replay may be incomplete. A production service should detect expired cursors and provide a resynchronization mechanism.
Because every connected client performs its own blocking read, Redis connection-pool capacity must be configured for the expected number of concurrent SSE streams. Large deployments may instead use shared subscription workers and bounded per-client queues.
Browser Client
The following JavaScript code connects to the SSE endpoint and listens for named events.
const source = new EventSource(
"http://127.0.0.1:8000/events"
);
source.addEventListener("notification", (event) => {
const data = JSON.parse(event.data);
console.log("Notification:", data);
console.log("Event ID:", event.lastEventId);
});
source.addEventListener("progress", (event) => {
const data = JSON.parse(event.data);
console.log(`Progress: ${data.percent}%`);
});
source.onerror = () => {
console.warn("SSE disconnected; browser will retry");
};
// Call source.close() when updates are no longer needed.
If the page and API use different origins, configure CORS on the FastAPI application. For the simplest local test, serve the browser page from the same origin as the API.
The browser automatically remembers the most recent SSE event ID for reconnection within the lifetime of the EventSource object.
Running and Testing
Start the FastAPI server:
uvicorn app:app --host 127.0.0.1 --port 8000
Open a terminal and subscribe using curl. The -N option disables curl's output buffering so events appear as they arrive.
curl -N http://127.0.0.1:8000/events
In another terminal, publish an event:
curl -X POST \
http://127.0.0.1:8000/events \
-H "Content-Type: application/json" \
-d '{
"type": "notification",
"data": {
"message": "Report generated successfully"
}
}'
The subscriber should immediately receive an event similar to:
id: 1791590000000-0
event: notification
data: {"message":"Report generated successfully"}
The event ID shown here is illustrative; Redis generates the actual value.
To test replay, disconnect curl and reconnect with the ID of a previously received event:
curl -N \
-H "Last-Event-ID: 1791590000000-0" \
http://127.0.0.1:8000/events
Replace the example ID with an actual ID returned by Redis. The server will stream events newer than that cursor, subject to stream retention.
To test multiple application instances, start another FastAPI process on port 8001 using the same Redis instance. Publish through one process and subscribe through the other. Both processes share the same ordered event stream.
Common Mistakes
SSE implementations often work correctly in local development but fail when deployed behind proxies, load balancers, or multiple application instances. Most problems involve buffering, event recovery, or resource management.
Allowing Reverse Proxies to Buffer Events
Some proxies buffer response data before forwarding it to clients. This can cause SSE events to arrive in large batches rather than immediately.
Disable response buffering for SSE endpoints where supported and test actual delivery latency through the complete production request path.
Not Sending Heartbeats
Idle connections may be closed by proxies or network infrastructure. Periodic SSE comments can keep traffic flowing without generating application events.
Choose a heartbeat interval shorter than the relevant idle timeout, and verify that intermediaries forward heartbeat bytes promptly.
Keeping Event History Only in Application Memory
When application instances restart or clients reconnect to another instance, process-local event history may be unavailable.
Use a shared durable event store when clients need replay or reliable recovery after disconnections.
Using Unbounded Per-Client Queues
Slow clients can accumulate pending events faster than they consume them. Without queue limits, memory usage may grow until the application becomes unstable.
Use bounded queues, coalescing for replaceable state updates, or controlled disconnection and resynchronization for clients that fall too far behind.
Ignoring Connection and Stream Limits
Every SSE subscription consumes server and network resources. HTTP/1.1 connection limits and HTTP/2 concurrent-stream settings can also affect browser behavior.
Measure concurrent connections, file descriptors, memory usage, and event delivery latency under realistic load.
Production Checklist
Before deploying SSE, verify that streaming works through the actual infrastructure and that clients can recover safely after interruptions.
- Streaming: Return
text/event-streamand prevent unwanted response buffering. - Heartbeats: Send periodic comments to keep idle connections active.
- Reconnection: Support event IDs and a defined replay or resynchronization strategy.
- Durability: Store important events outside individual application processes.
- Backpressure: Bound per-client buffering and define behavior for slow consumers.
- Security: Authenticate subscriptions and enforce user or tenant event isolation.
- Capacity: Monitor active streams, connection-pool usage, memory, and file descriptors.
- Deployment: Test proxy timeouts, graceful shutdown, reconnect storms, and rolling updates.
Frequently Asked Questions
SSE is relatively simple to implement, but protocol behavior and deployment constraints can influence whether it is the right choice for an application.
Is SSE Better Than WebSockets?
Neither is universally better. SSE is often simpler for server-to-client updates, while WebSockets support bidirectional communication and binary frames.
Choose based on communication requirements rather than assuming one protocol is always faster or more scalable.
Does SSE Work with HTTP/2 and HTTP/3?
Yes. SSE is based on HTTP response streaming and can operate over HTTP/1.1, HTTP/2, or HTTP/3 when supported by the deployment infrastructure.
HTTP/2 multiplexing can reduce the limitations associated with multiple HTTP/1.1 connections, but concurrent stream limits still apply.
Can SSE Use Authentication?
Yes. SSE endpoints can use session cookies, authenticated reverse proxies, or other HTTP authentication mechanisms.
The browser's native EventSource API does not support arbitrary custom request headers. Applications that require bearer tokens in headers may use a streaming Fetch implementation instead.
Can SSE Run on AWS Lambda?
It depends on the invocation path and response-streaming support. AWS Lambda supports response streaming through specific integrations, but not every API Gateway or proxy configuration forwards streamed chunks to clients.
Lambda execution duration, concurrency, and billing also matter because a stream may remain active for an extended period.
For long-lived, high-concurrency SSE connections, container-based services or dedicated streaming infrastructure may be more economical and predictable.
Can SSE Lose Messages?
Yes. SSE provides transport-level streaming and automatic reconnection, but it does not guarantee durable event delivery.
Reliable recovery requires stable event IDs, retained event history, and a server implementation that can replay events after the client's last acknowledged position.
Conclusion
Server-Sent Events provide a straightforward way to stream server-generated updates to browsers and other HTTP clients. They work well for notifications, progress tracking, monitoring dashboards, and AI response streaming where communication primarily flows from server to client.
The protocol is simple, but production reliability depends on connection management, proxy buffering, event IDs, replay support, and backpressure handling. SSE should be treated as a streaming transport rather than a complete messaging system.
Key Takeaway: SSE is an excellent choice for one-way real-time updates over HTTP. Use WebSockets when bidirectional communication is required, and add durable event storage and replay when updates must survive disconnections or application restarts.
More Articles to Read
These related articles explore HTTP behavior, event-driven communication, and scalable notification systems.
Comments (0)