Server-Sent Events (SSE) is a web standard for pushing data from a server to a browser over a single long-lived HTTP connection. The browser exposes this through the EventSource API—no WebSocket handshake, no custom protocol. If you only need server → client streaming, SSE is often the simplest option that still feels real-time.
How SSE works
SSE uses ordinary HTTP. The client sends a GET request; the server keeps the connection open and writes events in a text format with Content-Type: text/event-stream.
Each event is a block of UTF-8 text lines, terminated by a blank line:
event: priceUpdate
id: 42
retry: 3000
data: {"symbol":"AAPL","price":189.5}
Field reference:
data— payload (required). Multi-line values repeat thedata:prefix on each line.event— custom event name. Without it, the browser fires the defaultmessageevent.id— event ID. On reconnect, the browser sendsLast-Event-IDso the server can resume.retry— reconnection delay in milliseconds, sent once by the server.comments— lines starting with:(e.g.: keep-alive) are ignored by the client but useful for heartbeats.
Browser (EventSource) ←—— HTTP GET /events —— Server
←—— text/event-stream ——
←—— data: ... ——
HTML and JavaScript client
The EventSource constructor opens the stream and parses incoming frames automatically:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>SSE Demo</title>
</head>
<body>
<h1>Live prices</h1>
<pre id="log"></pre>
<script>
const log = document.getElementById("log");
const source = new EventSource("http://localhost:3000/events");
source.onopen = () => append("Connected");
// Listen for custom event "priceUpdate"
source.addEventListener("priceUpdate", (event) => {
const payload = JSON.parse(event.data);
append(`priceUpdate [${event.lastEventId}]: ${payload.symbol} → $${payload.price}`);
});
// Listen for other messages
source.onmessage = (event) => {
console.log("Received message:", event);
};
source.onerror = () => {
append("Connection error — browser will retry automatically");
};
function append(text) {
log.textContent += text + "\n";
}
</script>
</body>
</html>
Important client notes:
EventSourcedoes not support custom request headers. Pass auth tokens via query string or rely on session cookies.- The browser reconnects automatically after network failures, sending
Last-Event-IDwhen available. - Call
source.close()when the page unmounts to release the connection.
Both server examples below expose GET /events with the same event format, so this client works unchanged.
Server example in C#
Create a minimal ASP.NET Core project:
dotnet new web -n SseDemo
cd SseDemo
Add the SSE endpoint in Program.cs:
using System.Text.Json;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
var app = builder.Build();
app.UseCors("AllowAll");
app.MapGet("/events", async (HttpContext context) =>
{
context.Response.Headers.Append("Content-Type", "text/event-stream");
context.Response.Headers.Append("Cache-Control", "no-cache");
context.Response.Headers.Append("Connection", "keep-alive");
var random = new Random();
var id = 0;
while (!context.RequestAborted.IsCancellationRequested)
{
id++;
var price = Math.Round(180 + random.NextDouble() * 20, 2);
var payload = new Payload { Symbol = "AAPL", Price = price };
await context.Response.WriteAsync($"event: priceUpdate\n");
await context.Response.WriteAsync($"id: {id}\n");
await context.Response.WriteAsync($"data: {JsonSerializer.Serialize(payload, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })}\n\n");
await context.Response.Body.FlushAsync();
await context.Response.WriteAsync($"data: Just a message\n\n");
await context.Response.Body.FlushAsync();
await Task.Delay(1000, context.RequestAborted);
}
});
app.Run();
public class Payload
{
public required string Symbol { get; set; }
public required double Price { get; set; }
}
Run with dotnet run and open the HTML page against http://localhost:3000/events (adjust port as needed).
Server example in Node.js
The same endpoint in Express:
mkdir sse-demo && cd sse-demo
npm init -y
npm install express
npm install cors
Create server.js file with the following content:
const express = require("express");
const cors = require('cors');
const app = express();
app.use(cors());
const PORT = 3000;
app.get("/events", (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
let id = 0;
const interval = setInterval(() => {
id++;
const price = (180 + Math.random() * 20).toFixed(2);
const payload = JSON.stringify({ symbol: "AAPL", price: Number(price) });
res.write(`event: priceUpdate\n`);
res.write(`id: ${id}\n`);
res.write(`data: ${payload}\n\n`);
}, 1000);
const heartbeat = setInterval(() => {
res.write(": keep-alive\n\n");
}, 15000);
req.on("close", () => {
clearInterval(interval);
clearInterval(heartbeat);
});
});
app.listen(PORT, () => {
console.log(`SSE server listening on http://localhost:${PORT}/events`);
});
Save the file as server.js, then start it:
node server.js
You should see SSE server listening on http://localhost:3000/events.
Test the stream from another terminal:
curl -N http://localhost:3000/events
The -N flag disables curl buffering so events appear as they arrive. You should see priceUpdate frames every second and : keep-alive comments every 15 seconds.
To try the HTML client from earlier, serve the page locally (for example with npx serve .) and point EventSource at http://localhost:3000/events. CORS is already enabled in the example for cross-origin testing.
Native http.createServer works the same way—only the response API differs. Express keeps the example concise for teams already running Node APIs.
Advantages of SSE
- Simple HTTP — no upgrade handshake; works through most proxies and corporate firewalls
- Built-in reconnection — browsers retry automatically and send
Last-Event-ID - Lower complexity than WebSockets for one-way push scenarios
- Automatic parsing —
EventSourcehandles framing; you receiveMessageEventobjects - Efficient vs polling — one connection replaces repeated HTTP requests
- Load-balancer friendly — standard HTTP semantics; no sticky upgrade required
Limitations and when not to use SSE
- Unidirectional — client → server actions need separate HTTP requests (POST, fetch, etc.)
- Browser connection limits — roughly six concurrent connections per domain in most browsers
- Proxy buffering — some reverse proxies buffer responses; disable buffering or set
X-Accel-Buffering: noon nginx - Text only — payloads are UTF-8 strings, not raw binary frames
- Legacy IE — no native
EventSource; polyfills exist but WebSockets or polling may be simpler for very old targets
Choose WebSockets or another transport when you need bidirectional binary streams, sub-millisecond gaming latency, or peer-to-peer patterns.
Alternatives
WebSockets
Full-duplex, low-latency channel over a single TCP connection. Best for chat, collaborative editing, multiplayer games, and any scenario where the client sends frequent messages over the same socket.
Long polling
The client repeatedly opens a request and the server holds it until data arrives. Simpler on older infrastructure but higher overhead—each cycle may reopen connections and re-send headers.
Short polling
The client requests /updates on a fixed interval. Easiest to implement and cache-friendly, but wasteful when updates are rare or latency must stay low.
gRPC / HTTP/2 streaming
Strong fit for service-to-service streaming inside your backend. Browsers do not speak gRPC natively; you typically expose SSE or WebSockets at the edge and gRPC internally.
Real-world use cases
- Live dashboards — CI/CD build logs, deployment status, and infrastructure metrics streamed to an ops console without refreshing the page
- In-app notifications — order status, ticket updates, or admin alerts pushed to open browser sessions
- AI streaming — LLM token output delivered event-by-event for ChatGPT-style typing UX
- Finance and sports — stock tickers, auction bids, and live scores where the server publishes and many clients subscribe
- IoT and monitoring — sensor readings or alert thresholds pushed to a web UI from an edge gateway
- Social and content feeds — "new posts available" indicators; user actions still go through REST or GraphQL
Production considerations
- Heartbeats — send
: keep-alive\n\ncomments periodically so proxies and load balancers do not drop idle connections - Authentication — session cookies work with same-origin
EventSource; for cross-origin, use query tokens or a BFF that sets cookies - CORS — allow the origin explicitly;
EventSourcetriggers a CORS preflight only in some cross-origin setups—test with your CDN
Summary
SSE fits the common case of pushing server updates to browsers without WebSocket complexity. Pair a text/event-stream endpoint in C# or Node.js with the browser's EventSource API, and you get automatic reconnection, named events, and resume via Last-Event-ID.
Next steps
- Wire SSE into a live dashboard or notification panel in your product
- Add auth, heartbeats, and proxy tuning before production