How I Use Server-Sent Events in C# to Stream Data in Real Time
Server-Sent Events (SSE) is a simple, HTTP-based protocol that lets a server push data to the browser over a single, long-lived connection. Unlike WebSockets, SSE is one-directional (server to client), works over plain HTTP/1.1, and automatically reconnects when the connection drops. It is one of the most underused yet practical tools for real-time web applications in C#.
What are Server-Sent Events?
SSE uses a standard HTTP response with the content type text/event-stream. The server keeps the connection open and writes specially formatted text messages. The browser's built-in EventSource API handles parsing, reconnection, and event dispatching automatically.
The SSE wire format
Each message follows a simple text protocol:
id: 1
event: metric
data: {"cpu": 42, "memory": 68}
id: 2
event: metric
data: {"cpu": 55, "memory": 71}
id:— Optional event ID. The browser sends the last ID on reconnect via theLast-Event-IDheader.event:— Optional event name. Defaults tomessageif omitted.data:— The payload. Can span multiple lines (each prefixed withdata:).- Blank line — Signals the end of a message.
When should you use SSE?
SSE is the right choice when
- Live dashboards — CPU metrics, server health, analytics counters. Data flows one way: server to browser.
- Notifications — New messages, order updates, deployment status. The client just needs to listen.
- AI/LLM token streaming — Sending generated tokens to the browser as they are produced. This is how ChatGPT delivers responses.
- Live feeds — News tickers, stock prices, social media timelines. Continuous, append-only data.
- Progress updates — Long-running jobs like file processing, report generation, or CI/CD pipelines.
- Log tailing — Streaming application logs to a browser-based viewer in real time.
SSE is NOT the right choice when
- You need bidirectional communication — Chat apps, collaborative editing, multiplayer games. Use SignalR or WebSockets instead.
- You need binary data — SSE is text-only. Use WebSockets or gRPC for binary payloads.
- You need more than ~6 concurrent connections per domain — Browsers limit SSE connections per domain (HTTP/1.1). Use HTTP/2 or WebSockets if you need many parallel streams.
SSE vs. alternatives
| Feature | SSE | WebSockets | Long Polling |
|---|---|---|---|
| Direction | Server → Client | Bidirectional | Server → Client |
| Protocol | HTTP/1.1 or HTTP/2 | WS/WSS | HTTP |
| Auto-reconnect | Built-in | Manual | Manual |
| Browser support | All modern browsers | All modern browsers | All browsers |
| Binary data | No | Yes | Yes |
| Complexity | Low | Medium | Low |
| Connection limit | ~6 per domain (HTTP/1.1) | No practical limit | No practical limit |
Building an SSE endpoint in ASP.NET Core
Project setup
dotnet new webapi -n SseDemo
cd SseDemo
Basic SSE endpoint
The simplest SSE endpoint writes formatted text to the response stream and keeps the connection alive:
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/api/events/time", async (HttpContext context, CancellationToken ct) =>
{
context.Response.ContentType = "text/event-stream";
context.Response.Headers.Append("Cache-Control", "no-cache");
context.Response.Headers.Append("Connection", "keep-alive");
var id = 0;
while (!ct.IsCancellationRequested)
{
id++;
var data = JsonSerializer.Serialize(new
{
id,
time = DateTime.UtcNow.ToString("HH:mm:ss"),
timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
await context.Response.WriteAsync(quot;id: {id}\n", ct);
await context.Response.WriteAsync(quot;event: tick\n", ct);
await context.Response.WriteAsync(quot;data: {data}\n\n", ct);
await context.Response.Body.FlushAsync(ct);
await Task.Delay(1000, ct);
}
});
app.Run();
Controller-based SSE endpoint
For larger applications, use a controller:
[ApiController]
[Route("api/[controller]")]
public class MetricsController : ControllerBase
{
[HttpGet("stream")]
public async Task StreamMetrics(CancellationToken ct)
{
Response.ContentType = "text/event-stream";
Response.Headers.Append("Cache-Control", "no-cache");
Response.Headers.Append("Connection", "keep-alive");
var id = 0;
while (!ct.IsCancellationRequested)
{
var metric = new
{
id = ++id,
cpu = Random.Shared.Next(10, 95),
memory = Random.Shared.Next(40, 90),
requests = Random.Shared.Next(100, 5000),
timestamp = DateTime.UtcNow
};
var json = JsonSerializer.Serialize(metric);
await Response.WriteAsync(quot;id: {id}\n", ct);
await Response.WriteAsync(quot;event: metric\n", ct);
await Response.WriteAsync(quot;data: {json}\n\n", ct);
await Response.Body.FlushAsync(ct);
await Task.Delay(2000, ct);
}
}
}
SSE helper for cleaner code
Extract the SSE formatting into a reusable helper to keep your endpoints clean:
public static class SseWriter
{
public static async Task WriteEventAsync(
HttpResponse response,
string data,
string? eventType = null,
string? id = null,
CancellationToken ct = default)
{
if (id is not null)
await response.WriteAsync(quot;id: {id}\n", ct);
if (eventType is not null)
await response.WriteAsync(quot;event: {eventType}\n", ct);
// Handle multi-line data
foreach (var line in data.Split('\n'))
{
await response.WriteAsync(quot;data: {line}\n", ct);
}
await response.WriteAsync("\n", ct);
await response.Body.FlushAsync(ct);
}
public static void SetSseHeaders(HttpResponse response)
{
response.ContentType = "text/event-stream";
response.Headers.Append("Cache-Control", "no-cache");
response.Headers.Append("Connection", "keep-alive");
}
}
Now your endpoint becomes:
[HttpGet("stream")]
public async Task StreamMetrics(CancellationToken ct)
{
SseWriter.SetSseHeaders(Response);
var id = 0;
while (!ct.IsCancellationRequested)
{
var json = JsonSerializer.Serialize(new
{
cpu = Random.Shared.Next(10, 95),
memory = Random.Shared.Next(40, 90)
});
await SseWriter.WriteEventAsync(
Response, json, eventType: "metric", id: (++id).ToString(), ct: ct);
await Task.Delay(2000, ct);
}
}
Handling reconnection with Last-Event-ID
When a connection drops, EventSource automatically reconnects and sends the Last-Event-ID header. Your server can use this to resume from where the client left off:
[HttpGet("stream")]
public async Task StreamWithResume(CancellationToken ct)
{
SseWriter.SetSseHeaders(Response);
// Check if client is reconnecting
var lastId = 0;
if (Request.Headers.TryGetValue("Last-Event-ID", out var lastEventId))
{
int.TryParse(lastEventId, out lastId);
}
var id = lastId;
while (!ct.IsCancellationRequested)
{
id++;
// Fetch events that happened after lastId
var data = JsonSerializer.Serialize(new
{
id,
message = quot;Event #{id}",
resumedFrom = lastId > 0 ? lastId : (int?)null
});
await SseWriter.WriteEventAsync(
Response, data, eventType: "update", id: id.ToString(), ct: ct);
await Task.Delay(3000, ct);
}
}
Broadcasting to multiple clients
Real applications need to broadcast events to all connected clients. Use a background service with Channel<T>:
using System.Threading.Channels;
public class EventBroadcaster
{
private readonly List<Channel<string>> _clients = new();
private readonly Lock _lock = new();
public ChannelReader<string> Subscribe()
{
var channel = Channel.CreateBounded<string>(
new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.DropOldest
});
lock (_lock)
{
_clients.Add(channel);
}
return channel.Reader;
}
public void Unsubscribe(ChannelReader<string> reader)
{
lock (_lock)
{
_clients.RemoveAll(c => c.Reader == reader);
}
}
public async Task BroadcastAsync(string message)
{
List<Channel<string>> snapshot;
lock (_lock)
{
snapshot = new List<Channel<string>>(_clients);
}
foreach (var channel in snapshot)
{
await channel.Writer.WriteAsync(message);
}
}
}
Register it as a singleton and use it in the endpoint:
// Program.cs
builder.Services.AddSingleton<EventBroadcaster>();
// Endpoint
app.MapGet("/api/events/live", async (
HttpContext context,
EventBroadcaster broadcaster,
CancellationToken ct) =>
{
SseWriter.SetSseHeaders(context.Response);
var reader = broadcaster.Subscribe();
var id = 0;
try
{
await foreach (var message in reader.ReadAllAsync(ct))
{
await SseWriter.WriteEventAsync(
context.Response, message, eventType: "broadcast",
id: (++id).ToString(), ct: ct);
}
}
finally
{
broadcaster.Unsubscribe(reader);
}
});
Trigger a broadcast from any other endpoint or background service:
app.MapPost("/api/events/publish", async (
EventBroadcaster broadcaster,
PublishRequest request) =>
{
var json = JsonSerializer.Serialize(new
{
request.Message,
sentAt = DateTime.UtcNow
});
await broadcaster.BroadcastAsync(json);
return Results.Ok(new { status = "sent" });
});
public record PublishRequest(string Message);
Streaming AI/LLM responses with SSE
One of the most common modern use cases for SSE is streaming LLM responses token by token. Here is the simplest version:
app.MapPost("/api/chat", async (ChatRequest request, HttpContext context, CancellationToken ct) =>
{
context.Response.ContentType = "text/event-stream";
context.Response.Headers.Append("Cache-Control", "no-cache");
// Call your LLM here — this example uses a simple async enumerable
await foreach (var token in GetLlmTokensAsync(request.Prompt, ct))
{
await context.Response.WriteAsync(
quot;data: {JsonSerializer.Serialize(new { token })}\n\n", ct);
await context.Response.Body.FlushAsync(ct);
}
// Signal completion (same convention OpenAI uses)
await context.Response.WriteAsync("data: [DONE]\n\n", ct);
await context.Response.Body.FlushAsync(ct);
});
record ChatRequest(string Prompt);
// Replace this with your actual LLM SDK call
async IAsyncEnumerable<string> GetLlmTokensAsync(
string prompt,
[EnumeratorCancellation] CancellationToken ct)
{
var words = "This is a streamed response from the LLM based on your prompt.".Split(' ');
foreach (var word in words)
{
if (ct.IsCancellationRequested) yield break;
await Task.Delay(80, ct);
yield return word;
}
}
Swap GetLlmTokensAsync with your real LLM SDK (OpenAI, Anthropic, etc.) which already returns streaming tokens. The pattern stays the same — iterate over tokens and write each one as an SSE data: line.
JavaScript — consuming SSE
Basic EventSource usage
const evtSource = new EventSource('/api/metrics/stream');
evtSource.addEventListener('metric', (e) => {
const data = JSON.parse(e.data);
document.getElementById('cpu').textContent = data.cpu + '%';
document.getElementById('memory').textContent = data.memory + '%';
document.getElementById('requests').textContent = data.requests + ' req/s';
});
evtSource.onerror = () => {
document.getElementById('status').textContent = 'Reconnecting...';
};
evtSource.onopen = () => {
document.getElementById('status').textContent = 'Connected';
};
SSE with fetch (for POST requests)
EventSource only supports GET. For POST requests (like the LLM chat endpoint), use fetch with a readable stream:
async function chat(prompt) {
const output = document.getElementById('output');
output.textContent = '';
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt })
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line === 'data: [DONE]') return;
if (line.startsWith('data: ')) {
const { token } = JSON.parse(line.slice(6));
output.textContent += token + ' ';
}
}
}
}
Graceful connection management
let evtSource = null;
function connect() {
evtSource = new EventSource('/api/events/live');
evtSource.addEventListener('broadcast', (e) => {
const data = JSON.parse(e.data);
const li = document.createElement('li');
li.textContent = data.sentAt + ' — ' + data.Message;
document.getElementById('events-list').prepend(li);
});
evtSource.onerror = () => {
document.getElementById('connection-status').textContent = 'Disconnected — retrying...';
};
evtSource.onopen = () => {
document.getElementById('connection-status').textContent = 'Connected';
};
}
function disconnect() {
if (evtSource) {
evtSource.close();
evtSource = null;
document.getElementById('connection-status').textContent = 'Disconnected';
}
}
HTML — live dashboard example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Server Metrics Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 2rem;
}
h1 { margin-bottom: 0.5rem; }
.status {
font-size: 0.875rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
display: inline-block;
margin-bottom: 1.5rem;
}
.status.connected { background: #065f46; color: #6ee7b7; }
.status.disconnected { background: #7f1d1d; color: #fca5a5; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.card {
background: #1e293b;
border-radius: 12px;
padding: 1.5rem;
}
.card .label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
margin-bottom: 0.5rem;
}
.card .value {
font-size: 2.5rem;
font-weight: 700;
}
.bar-container {
background: #334155;
border-radius: 6px;
height: 8px;
margin-top: 0.75rem;
overflow: hidden;
}
.bar {
height: 100%;
border-radius: 6px;
transition: width 0.5s ease, background-color 0.5s ease;
}
.bar.cpu { background: #3b82f6; }
.bar.memory { background: #8b5cf6; }
.bar.high { background: #ef4444; }
</style>
</head>
<body>
<h1>Server Metrics</h1>
<span class="status disconnected" id="status">Connecting...</span>
<div class="grid">
<div class="card">
<div class="label">CPU Usage</div>
<div class="value" id="cpu">—</div>
<div class="bar-container">
<div class="bar cpu" id="cpu-bar" style="width: 0%"></div>
</div>
</div>
<div class="card">
<div class="label">Memory Usage</div>
<div class="value" id="memory">—</div>
<div class="bar-container">
<div class="bar memory" id="memory-bar" style="width: 0%"></div>
</div>
</div>
<div class="card">
<div class="label">Requests / sec</div>
<div class="value" id="requests">—</div>
</div>
</div>
<script>
const evtSource = new EventSource('/api/metrics/stream');
evtSource.addEventListener('metric', (e) => {
const d = JSON.parse(e.data);
document.getElementById('cpu').textContent = d.cpu + '%';
document.getElementById('memory').textContent = d.memory + '%';
document.getElementById('requests').textContent = d.requests;
const cpuBar = document.getElementById('cpu-bar');
cpuBar.style.width = d.cpu + '%';
cpuBar.className = d.cpu > 80 ? 'bar cpu high' : 'bar cpu';
const memBar = document.getElementById('memory-bar');
memBar.style.width = d.memory + '%';
memBar.className = d.memory > 85 ? 'bar memory high' : 'bar memory';
});
evtSource.onopen = () => {
const el = document.getElementById('status');
el.textContent = 'Connected';
el.className = 'status connected';
};
evtSource.onerror = () => {
const el = document.getElementById('status');
el.textContent = 'Reconnecting...';
el.className = 'status disconnected';
};
</script>
</body>
</html>
HTML — AI chat streaming example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>AI Chat — SSE Streaming</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
background: #fafafa;
display: flex;
justify-content: center;
padding: 2rem;
}
.chat-container { width: 100%; max-width: 640px; }
h1 { margin-bottom: 1rem; color: #1e293b; }
.input-row { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
input[type="text"] {
flex: 1; padding: 0.75rem 1rem;
border: 1px solid #cbd5e1; border-radius: 8px; font-size: 1rem;
}
button {
padding: 0.75rem 1.5rem; background: #2563eb; color: white;
border: none; border-radius: 8px; font-size: 1rem; cursor: pointer;
}
button:hover { background: #1d4ed8; }
#output {
background: white; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 1.5rem; min-height: 200px; line-height: 1.6;
white-space: pre-wrap; color: #334155;
}
</style>
</head>
<body>
<div class="chat-container">
<h1>AI Chat (SSE Streaming)</h1>
<div class="input-row">
<input type="text" id="prompt" placeholder="Ask something..." />
<button onclick="chat(document.getElementById('prompt').value)">Send</button>
</div>
<div id="output">Responses will appear here...</div>
</div>
<script>
async function chat(prompt) {
const output = document.getElementById('output');
output.textContent = '';
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt })
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line === 'data: [DONE]') return;
if (line.startsWith('data: ')) {
const { token } = JSON.parse(line.slice(6));
output.textContent += token + ' ';
}
}
}
}
</script>
</body>
</html>
CORS configuration for SSE
If your frontend and API are on different origins, configure CORS in Program.cs:
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowSSE", policy =>
{
policy.WithOrigins("https://your-frontend.com")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
var app = builder.Build();
app.UseCors("AllowSSE");
Common pitfalls and best practices
Always flush after writing
Without FlushAsync(), the response may be buffered and the client receives nothing until the buffer fills or the connection closes.
Respect the CancellationToken
When a client disconnects, ASP.NET Core signals the CancellationToken. Always check it in your loop to avoid wasting server resources on dead connections.
Disable response buffering
Some middleware or reverse proxies buffer responses. Make sure buffering is off:
// Disable IIS/Kestrel response buffering
context.Response.Headers.Append("X-Accel-Buffering", "no");
Use bounded channels for broadcasting
Unbounded channels can cause memory issues if a slow client cannot keep up. Use BoundedChannelOptions with DropOldest to prevent memory growth.
Keep payloads small
SSE is text-based. Avoid sending large JSON objects per event. If you need to send a lot of data, send a reference ID and let the client fetch the full payload separately.
Set a heartbeat
Some proxies and load balancers close idle connections. Send a comment line periodically to keep the connection alive:
// SSE comment — ignored by EventSource but keeps connection alive
await Response.WriteAsync(": heartbeat\n\n", ct);
await Response.Body.FlushAsync(ct);
Key takeaways
- SSE is the simplest real-time protocol — it works over plain HTTP, reconnects automatically, and every modern browser supports
EventSource natively.
- Use it for one-way server push — dashboards, notifications, AI token streaming, live feeds, and progress updates.
- Use the
SseWriter helper pattern — extract formatting logic to keep your endpoints clean and consistent.
- Handle reconnection with
Last-Event-ID — the browser sends it automatically; your server just needs to read the header and resume.
- Use
Channel<T> for broadcasting — it gives you an efficient, thread-safe pub/sub pattern for multiple clients.
- Always flush, always check cancellation — these two habits prevent the most common SSE bugs.
For most real-time use cases that only need server-to-client data flow, SSE is the right tool. No additional libraries, no protocol negotiation, no unnecessary complexity.