How I Use Server-Sent Events in C# to Stream Data in Real Time

By · · Technology

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}

When should you use SSE?

SSE is the right choice when

SSE is NOT the right choice when

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

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.