Let’s talk Server-Sent Events

Server-Sent Events (SSE) are a lightweight streaming/push technology for pushing updates to a web client over a single long-live HTTP connection.

For example as use case where your web client connects to a server and periodically receives events or notifications from the server. Alternates to SSE might be long polling the server for updates, web sockets, Signal R etc. SSE is simpler than setting up web sockets and for C#/.NET developers can be seen to be similar to asynchronous streams.

All modern browser should support SSE using EventSource. SSE also supports automatic reconnection but one key thing to remember is this is only for one-way updates (unlike Signal R).

ASP.NET Sample

Let’s create an ASP.NET server sample with an endpoint /events of type text/event-stream. This will just look and send “ticks” to a client.

app.MapGet("/events", async (HttpContext context, CancellationToken cancellationToken) =>
{
    context.Response.ContentType = "text/event-stream";
    context.Response.Headers.CacheControl = "no-cache";

    try
    {
        for (var i = 1; i <= 20; i++)
        {
            var payload = JsonSerializer.Serialize(new
            {
                id = i,
                message = $"Server tick {i}",
                sentAtUtc = DateTimeOffset.UtcNow
            });

            await context.Response.WriteAsync($"id: {i}\n", cancellationToken);
            await context.Response.WriteAsync("event: tick\n", cancellationToken);
            await context.Response.WriteAsync($"data: {payload}\n\n", cancellationToken);
            await context.Response.Body.FlushAsync(cancellationToken);

            await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
        }
    }
    catch (OperationCanceledException)
    {
    }
});

Pretty simple. As mentioned we need to set the ContentType to text/event-stream. SSE must never be cached, hence context.Response.Headers.CacheControl = “no-cache”. The handler should not return until all responses are sent, i.e. it goes into a loop or similar and writes responses.

It’s also important to note that the SSE spec requires the following wire format

  • id: An optional event ID
  • event: An optional even name
  • datae: The actual payload
  • Ends with a blank line: Notice of the line setting the data we have two new lines, i.e. one blank line. Without this the browser will not dispatch the event
  • Flush after each event: This will force the server to push the event immediately instead of buffering, otherwise the client might receive events in chunks

C# client code

Let’s look at what’s required for a C# client to interact with our server.

The HttpClient looks like this

using var httpClient = new HttpClient
{
    BaseAddress = new Uri("http://localhost:5127")
};

Now we’ll created the request tot he /events endpoint and accept the text/event-stream i.e.

using var cancellation = new CancellationTokenSource();

using var request = new HttpRequestMessage(HttpMethod.Get, "/events");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));

using var response = await httpClient.SendAsync(
    request,
    HttpCompletionOption.ResponseHeadersRead,
    cancellation.Token);

response.EnsureSuccessStatusCode();

Okay, seo we’ve configured things and called the endpoint, now let’s look at how we might read the SSE

await using var stream = await response.Content.ReadAsStreamAsync(cancellation.Token);
using var reader = new StreamReader(stream);

while (!cancellation.IsCancellationRequested)
{
  var line = await reader.ReadLineAsync(cancellation.Token);

  if (line is null)
  {
    break;
  }

  if (line.Length == 0)
  {
    if (eventDataBuilder.Length > 0)
    {
      Console.WriteLine($"[{DateTime.Now:T}] id={eventId}, event={eventName}, data={eventDataBuilder}");
    }

    eventId = string.Empty;
    eventName = string.Empty;
    eventDataBuilder.Clear();
    continue;
  }

  if (line.StartsWith("id: ", StringComparison.Ordinal))
  {
    eventId = line[4..];
    continue;
  }

  if (line.StartsWith("event: ", StringComparison.Ordinal))
  {
    eventName = line[7..];
    continue;
  }

  if (line.StartsWith("data: ", StringComparison.Ordinal))
  {
    if (eventDataBuilder.Length > 0)
    {
      eventDataBuilder.Append('\n');
    }

    eventDataBuilder.Append(line[6..]);
}

In the example code, above, we read each line from the SSE and get each part until we’re ready to put the read data together to write to the console.

Typescript client

The following example is a React Typescript implementation of a simply UI with connect buttons etc. to connect to the events URL, using an EventSource we add a listener to and output the SSE’s as they arrive.

type TickEvent = {
  id: number
  message: string
  sentAtUtc: string
}

const maxItems = 50

function App() {
  const [events, setEvents] = useState<TickEvent[]>([])
  const [isConnected, setIsConnected] = useState(false)
  const [status, setStatus] = useState('Disconnected')
  const eventSourceRef = useRef<EventSource | null>(null)

  useEffect(() => {
    return () => {
      eventSourceRef.current?.close()
      eventSourceRef.current = null
    }
  }, [])

  const connect = () => {
    if (eventSourceRef.current) {
      return
    }

    setStatus('Connecting...')

    const eventSource = new EventSource(import.meta.env.VITE_SSE_URL ?? '/events')
    eventSourceRef.current = eventSource

    eventSource.onopen = () => {
      setIsConnected(true)
      setStatus('Connected')
    }

    eventSource.addEventListener('tick', (event) => {
      const messageEvent = event as MessageEvent<string>

      try {
        const payload = JSON.parse(messageEvent.data) as TickEvent
        setEvents((previous) => [payload, ...previous].slice(0, maxItems))
      } catch {
        setStatus('Received malformed event payload')
      }
    })

    eventSource.onerror = () => {
      setStatus('Connection lost or closed by server')
      setIsConnected(false)
      eventSource.close()
      eventSourceRef.current = null
    }
  }

  const disconnect = () => {
    eventSourceRef.current?.close()
    eventSourceRef.current = null
    setIsConnected(false)
    setStatus('Disconnected')
  }

  const clear = () => setEvents([])

  return (
    <main className="app">
      <h1>Server Sent Events Demo</h1>
      <p className="subtitle">React + TypeScript client for the ServerSentEventsSample API</p>

      <div className="controls">
        <button onClick={connect} disabled={isConnected}>Connect</button>
        <button onClick={disconnect} disabled={!isConnected}>Disconnect</button>
        <button onClick={clear} disabled={events.length === 0}>Clear</button>
      </div>

      <p className="status">
        Status: <strong>{status}</strong>
      </p>

      <ul className="event-list">
        {events.length === 0 && <li className="empty">No events yet.</li>}
        {events.map((item) => (
          <li key={`${item.id}-${item.sentAtUtc}`}>
            <div className="row">
              <span className="id">#{item.id}</span>
              <span>{item.message}</span>
            </div>
            <time dateTime={item.sentAtUtc}>
              {new Date(item.sentAtUtc).toLocaleTimeString()}
            </time>
          </li>
        ))}
      </ul>
    </main>
  )
}

Source Code

Full code for these examples is available on Github.