SSE was the transport. Server push was the pattern. Decoupling was the goal.
This is a decision note from a real-time log monitoring feature in a distributed system: why I chose Server-Sent Events over SignalR and WebSockets, and why the more important decision wasn't the transport at all.
The system and the need
The system had three parts:
- Remote .NET Worker Services running as Windows Services, each monitoring local log files.
- An ASP.NET Core backend coordinating the workers.
- A React admin panel used by operators.
Operators needed to follow new log entries as they were written, without the browser repeatedly polling for changes. The key decision turned out to be about communication boundaries.
Two paths, distinct responsibilities
Authenticated HTTP APIs start administrative actions. The backend coordinates execution with the worker.
Workers report log events independently of browser connections. The backend distributes events to authorized clients.
Separating the two paths meant each one could use the simplest mechanism that fit. Commands are request/response, so ordinary HTTP APIs with authentication were already the right tool. Only the event path needed push.
WebSockets, SignalR or Server-Sent Events
I evaluated three options for the browser-facing event path:
| WebSockets | SignalR | Server-Sent Events | |
|---|---|---|---|
| Direction | Full-duplex | Two-way, via hubs | Server to client |
| Protocol | Upgraded from HTTP to its own framing | Abstraction over WebSockets, SSE or long polling | Plain HTTP, text/event-stream |
| What you get | A raw bidirectional channel | Hubs, connection management, groups, transport fallback | A simple one-way event stream |
| Client | Browser WebSocket API | SignalR client library | EventSource, or fetch streaming |
| Fit at this boundary | More than needed | Valid, but a broader model than needed | Matched the one-way requirement |
Why SSE instead of SignalR?
WebSockets support full-duplex communication. SignalR provides a higher-level real-time abstraction with hubs and connection management, and supports multiple transports.
Our browser clients only needed server-to-client log events. Commands already had dedicated HTTP APIs. SSE matched that one-way requirement over standard HTTP using text/event-stream, without introducing a hub-based messaging abstraction.
SignalR remained a valid alternative. Its broader interaction model simply wasn't required at this boundary.
On the backend, an SSE endpoint is an ordinary HTTP endpoint that keeps the response open and writes events in the SSE text format. A minimal sketch:
// Illustrative sketch: an authorized SSE endpoint in ASP.NET Core app.MapGet("/api/logs/stream", async ( HttpContext http, ILogEventFeed feed, CancellationToken ct) => { http.Response.ContentType = "text/event-stream"; http.Response.Headers.CacheControl = "no-cache"; // the loop ends when the browser disconnects await foreach (var e in feed.Subscribe(ct)) { await http.Response.WriteAsync($"id: {e.Sequence}\ndata: {e.Json}\n\n", ct); await http.Response.Body.FlushAsync(ct); } }).RequireAuthorization();
ILogEventFeed stands in for whatever distributes events inside the backend. Recent ASP.NET Core versions also include a built-in result type for Server-Sent Events; the principle is the same.
The more important decision: lifecycle isolation
The transport was the visible choice. The decision that mattered more was keeping three lifecycles independent:
- An operation could finish before its final logs reached the UI.
- A browser disconnect didn't stop the worker or its log monitoring.
This separation reduced coupling between runtime execution and UI connectivity. The UI became an observer of the system, not a dependency of it.
The trade-off
SSE is a streaming transport, not a durable messaging system. Worker-side file tracking doesn't guarantee backend event retention or browser replay.
Connection recovery, missed events and ordering require explicit application-level handling. Authorization and stream resource management are separate operational concerns. In practice that means deciding, deliberately:
| Concern | Question to answer | Typical options |
|---|---|---|
| Recovery | What happens after a dropped connection? | Reconnect with backoff; resume from the last event id. |
| Missed events | Must the UI see every line? | A bounded replay buffer on the backend, or accept gaps for a live view. |
| Ordering | Does order matter across workers? | Sequence numbers per source; order within a source only. |
| Authorization | Who may see which worker's logs? | Check on connect and filter per client. |
| Resources | How many open streams can the backend hold? | Limits per user, timeouts, cleanup on disconnect. |
Consuming the stream in React
The browser's built-in EventSource handles reconnection for you, but it can't set custom headers such as Authorization. In React, we used fetch with a ReadableStream for authorization headers and cancellation, and took responsibility for stream parsing and reconnection ourselves.
// Illustrative sketch: reading an SSE stream with fetch const controller = new AbortController(); // abort on unmount const res = await fetch("/api/logs/stream", { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal, }); const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); let buffer = ""; for (;;) { const { value, done } = await reader.read(); if (done) break; // stream ended: reconnect is on us buffer += value; const events = buffer.split("\n\n"); buffer = events.pop(); // keep the incomplete tail events.forEach((raw) => onEvent(parseSse(raw))); }
It's more code than new EventSource(url), and that's the honest cost: the flexibility of headers and cancellation comes with ownership of parsing and recovery.
Choosing the right abstraction
The decision wasn't about choosing the most powerful technology.
It was about choosing the right abstraction for the problem.
SignalR or WebSockets would have worked. SSE fit the boundary with the least machinery, and the separation of command and event paths, plus independent lifecycles, is what kept the system decoupled. That part would have mattered whichever transport we chose.
How do you choose between SSE and SignalR for real-time monitoring? Join the discussion on LinkedIn, or see how I approach system design.
Quick answers
When should I choose SSE instead of SignalR?
When browser clients only need server-to-client updates and commands already travel over ordinary HTTP APIs. SSE is plain HTTP with the text/event-stream format. SignalR is a better fit when you need two-way messaging, hub-style method calls, groups or transport fallback managed for you.
Can Server-Sent Events send an Authorization header?
The browser's built-in EventSource API cannot set custom headers. Common options are cookie-based authentication, or reading the stream with fetch and a ReadableStream, which allows headers and cancellation but makes parsing and reconnection your responsibility.
Is SSE a reliable messaging system?
No. SSE is a streaming transport. Durability, replay of missed events and ordering guarantees have to be designed at the application level, for example with event ids, sequence numbers and a bounded replay buffer, or with a real message broker behind the stream.
Does a browser disconnect stop the worker?
It shouldn't. In this design the operation, the worker's log collection and the browser stream have separate lifecycles, so a closed tab only ends that one stream.