Distributed Systems·9 min read

SSE vs. SignalR for Real-Time Log Monitoring

For real-time log monitoring across remote .NET workers, I evaluated WebSockets, SignalR and Server-Sent Events. SSE was the transport, server push was the pattern, and decoupling was the goal.

Hamed RezaeiSoftware Architect & Senior .NET Backend Engineer·Expanded from my LinkedIn post

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

Command path and event path
Command path · control plane
ReactBackendWorker

Authenticated HTTP APIs start administrative actions. The backend coordinates execution with the worker.

Event path · server push
WorkerHTTP callbackBackendSSE streamReact

Workers report log events independently of browser connections. The backend distributes events to authorized clients.

Commands and events travel on different paths with different owners. Conceptual diagram.

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:

WebSocketsSignalRServer-Sent Events
DirectionFull-duplexTwo-way, via hubsServer to client
ProtocolUpgraded from HTTP to its own framingAbstraction over WebSockets, SSE or long pollingPlain HTTP, text/event-stream
What you getA raw bidirectional channelHubs, connection management, groups, transport fallbackA simple one-way event stream
ClientBrowser WebSocket APISignalR client libraryEventSource, or fetch streaming
Fit at this boundaryMore than neededValid, but a broader model than neededMatched the one-way requirement
Comparison for this specific boundary: browser clients that only receive log events.

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:

Three independent lifecycles
Operation executionstarts and finishes on its own schedule
Worker log collectionkeeps watching files, browser or not
Browser streamingopens and closes with the operator's tab
None of the three waits for, or stops, the others.
  • 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:

ConcernQuestion to answerTypical options
RecoveryWhat happens after a dropped connection?Reconnect with backoff; resume from the last event id.
Missed eventsMust the UI see every line?A bounded replay buffer on the backend, or accept gaps for a live view.
OrderingDoes order matter across workers?Sequence numbers per source; order within a source only.
AuthorizationWho may see which worker's logs?Check on connect and filter per client.
ResourcesHow many open streams can the backend hold?Limits per user, timeouts, cleanup on disconnect.
What SSE leaves to the application. The options are general patterns, not a description of one implementation.

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.

  • Distributed Systems
  • Server-Sent Events
  • SignalR
  • WebSockets
  • ASP.NET Core
  • .NET Worker Services
  • React