← All articles

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