---
title: Server-Sent Events (SSE) and EventSource
description: "Learn SSE and the EventSource API with C# and Node.js server examples, plus HTML/JavaScript client code—advantages, alternatives, and real-world use cases."
image: "https://wavepillars.com/learn/server-sent-events-sse-eventsource-og.webp"
url: "https://wavepillars.com/learn/ai/server-sent-events-sse-eventsource/"
---

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

## Key Takeaways

- SSE fits server-to-client push over HTTP; WebSockets fit bidirectional low-latency chat.
- EventSource auto-reconnects; classic API limits custom headers on connect.
- Disable proxy buffering and send heartbeats to keep streams alive.
- SSE is text-only one-way—use WebSockets or HTTP POST for client uploads.
- Legacy MCP SSE builds on these patterns; new MCP should use Streamable HTTP.

## 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 the `data:` prefix on each line.
- **`event`** — custom event name. Without it, the browser fires the default `message` event.
- **`id`** — event ID. On reconnect, the browser sends `Last-Event-ID` so 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:

```html
<!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:

- **`EventSource` does 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-ID` when 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:

```bash
dotnet new web -n SseDemo
cd SseDemo
```

Add the SSE endpoint in `Program.cs`:

```csharp
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:

```bash
mkdir sse-demo && cd sse-demo
npm init -y
npm install express
npm install cors
```

Create `server.js` file with the following content:

```javascript
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:

```bash
node server.js
```

You should see `SSE server listening on http://localhost:3000/events`. 

Test the stream from another terminal:

```bash
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** — `EventSource` handles framing; you receive `MessageEvent` objects
- **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: no` on 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\n` comments 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; `EventSource` triggers 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

## FAQ

### When should I use Server-Sent Events instead of WebSockets?

Use SSE when the server pushes updates to the browser over a single long-lived HTTP connection and the client only needs to listen—live logs, notifications, progress streams. WebSockets fit bidirectional, low-latency chat or gaming where the client sends frequent messages too. SSE reuses HTTP, works through many corporate proxies, and has built-in browser reconnect in EventSource.

### How does EventSource work in the browser?

EventSource opens a GET request the server keeps open, sending text/event-stream formatted chunks. The API reconnects automatically with Last-Event-ID after drops. You cannot send custom headers on the initial request in classic EventSource—use cookies or query tokens for auth, or fetch-plus-ReadableStream when you need header control.

### What are common SSE production pitfalls?

Reverse proxies buffer responses unless configured otherwise, killing real-time delivery. Missing heartbeats let intermediaries close idle connections. Sending binary data or expecting client uploads over the same channel fails—SSE is text-only and server-to-client. Set appropriate timeouts and comment heartbeats.

### How does SSE relate to MCP transports?

Legacy MCP SSE transport used EventSource-style streams for server-to-client JSON-RPC messages alongside HTTP POST for client requests. Modern MCP prefers Streamable HTTP. Understanding SSE still helps when maintaining older MCP servers or building non-MCP progress UIs in ASP.NET Core.

## Internal Links

- [Build MCP Server with SSE Transport in .NET](/learn/ai/build-mcp-server-sse-dotnet/)
- [Build MCP Server with Streamable HTTP in .NET](/learn/ai/build-mcp-server-streamable-http-dotnet/)
- [What is the MCP Protocol?](/learn/ai/what-is-mcp-server/)
- [What should a software engineer know? Skills checklist & roadmap](/learn/engineering/software-engineer-knowledge-framework/)
- [Run MCP Server in Docker](/learn/ai/run-mcp-server-in-docker/)

```json
{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","headline":"Server-Sent Events (SSE) and EventSource","description":"Learn SSE and the EventSource API with C# and Node.js server examples, plus HTML/JavaScript client code—advantages, alternatives, and real-world use cases.","datePublished":"2026-05-21","dateModified":"2026-07-11","url":"https://wavepillars.com/learn/ai/server-sent-events-sse-eventsource/","author":{"@type":"Person","name":"Kiryl Bahdanovich"},"publisher":{"@type":"Organization","name":"WAVEPILLARS","url":"https://wavepillars.com/"},"image":"https://wavepillars.com/learn/server-sent-events-sse-eventsource-og.webp","timeRequired":"PT14M"},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"When should I use Server-Sent Events instead of WebSockets?","acceptedAnswer":{"@type":"Answer","text":"Use SSE when the server pushes updates to the browser over a single long-lived HTTP connection and the client only needs to listen—live logs, notifications, progress streams. WebSockets fit bidirectional, low-latency chat or gaming where the client sends frequent messages too. SSE reuses HTTP, works through many corporate proxies, and has built-in browser reconnect in EventSource."}},{"@type":"Question","name":"How does EventSource work in the browser?","acceptedAnswer":{"@type":"Answer","text":"EventSource opens a GET request the server keeps open, sending text/event-stream formatted chunks. The API reconnects automatically with Last-Event-ID after drops. You cannot send custom headers on the initial request in classic EventSource—use cookies or query tokens for auth, or fetch-plus-ReadableStream when you need header control."}},{"@type":"Question","name":"What are common SSE production pitfalls?","acceptedAnswer":{"@type":"Answer","text":"Reverse proxies buffer responses unless configured otherwise, killing real-time delivery. Missing heartbeats let intermediaries close idle connections. Sending binary data or expecting client uploads over the same channel fails—SSE is text-only and server-to-client. Set appropriate timeouts and comment heartbeats."}},{"@type":"Question","name":"How does SSE relate to MCP transports?","acceptedAnswer":{"@type":"Answer","text":"Legacy MCP SSE transport used EventSource-style streams for server-to-client JSON-RPC messages alongside HTTP POST for client requests. Modern MCP prefers Streamable HTTP. Understanding SSE still helps when maintaining older MCP servers or building non-MCP progress UIs in ASP.NET Core."}}]}]}
```