Skip to main content

Server-Sent Events (SSE) and EventSource

May 21, 2026 · 14 min read

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

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

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 parsingEventSource 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

Related articles

What is the MCP Protocol?

MCP explained—when to use tools, resources, and prompts, plus transports (stdio, Streamable HTTP, legacy SSE), Docker, and client setup.

Building MCP Server Using .NET

Step-by-step guide to building a Model Context Protocol (MCP) server in .NET with stdio transport—JSON-RPC over stdin/stdout, tools, and Claude Desktop testing.