Non-Browser SSE Clients Permalink to this section

Part of Frontend Consumption & Client Patterns.

Server-Sent Events are usually described as a browser technology, but a large share of real SSE traffic has no browser at either end. Backend services consume other services’ streams, command-line tools tail deploy progress, mobile apps show live order status, IoT gateways receive configuration pushes, and nearly every large-language-model API streams its responses as text/event-stream to server-side code. Outside the browser there is no built-in EventSource, so each client must supply what the browser provides for free: parsing, reconnection with Last-Event-ID, respect for retry:, and sensible timeouts. This guide covers the common runtimes — Node.js, Python, Go, iOS and Android — with the library to use in each, the defaults that break streams, and a checklist that applies to all of them.

How It Works Permalink to this section

Every client, in every language, does the same five things. The browser’s EventSource bundles them; outside the browser you assemble them from an HTTP client, a parser and a reconnect loop.

What EventSource provides, rebuilt outside the browser Layers of a non-browser SSE client: HTTP request with streaming response, incremental UTF-8 decoding, event stream parsing, reconnection with Last-Event-ID and retry, and application dispatch. What EventSource provides, rebuilt outside the browser HTTP client streaming body, no total timeout Decoder incremental UTF-8 Parser lines, fields, dispatch Reconnect loop backoff, Last-Event-ID, retry Application typed events, handlers
Libraries differ in how many of these layers they cover. Check that yours covers all of them, or add the missing ones.

The most common defect in non-browser clients is in the first layer: HTTP clients configured for request/response traffic apply a total request timeout (often 30 or 100 seconds) that cuts every stream at that age. The second most common is in the fourth: a client that parses correctly but does not reconnect, so the first network blip ends the feed permanently.

Runtime Recommended starting point Watch out for
Node.js eventsource package (fetch-based), or fetch + eventsource-parser custom headers need the library’s fetch option
Python httpx + httpx-sse httpx default timeouts; use timeout=None for reads
Go net/http + bufio parser (or a small library) http.Client.Timeout cuts streams
iOS / macOS URLSession bytes with a hand-written parser timeouts, background suspension
Android / JVM OkHttp okhttp-sse readTimeout must exceed heartbeats
.NET SseParser in System.Net.ServerSentEvents HttpClient.Timeout default 100 s

Server-Side Implementation Permalink to this section

Servers do not need to change for non-browser clients, but a few conventions make them much easier to consume from code:

  • Send heartbeats. Non-browser clients often rely on read timeouts to detect dead connections. A comment every 15 seconds lets them use a 45–60 second read timeout safely.
  • Always send ids and a retry value. Libraries that implement reconnection use them; hand-written loops can too.
  • Use accurate status codes before streaming. Code can act on 401 versus 429 versus 503; a 200 with an error message inside the stream is harder to handle.
  • Document the event contract. Event names, payload schemas and the meaning of ids belong in the API documentation, as described in testing and load testing SSE endpoints.
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache

retry: 5000

id: 7731
event: deployment.progress
data: {"deployment":"d_19","step":"migrate","pct":40}

: hb

Client-Side Consumption Permalink to this section

Node.js Permalink to this section

Node.js 18 and later ship fetch with streaming bodies, so the fetch-based approach from the browser works unchanged. The eventsource package provides the full EventSource API, including reconnection, and accepts a custom fetch for headers:

import { EventSource } from 'eventsource';

const es = new EventSource('https://api.example.com/deployments/d_19/events', {
  fetch: (input, init) => fetch(input, {
    ...init,
    headers: { ...init.headers, Authorization: `Bearer ${process.env.TOKEN}` },
  }),
});
es.addEventListener('deployment.progress', (e) => console.log(JSON.parse(e.data).pct));
es.onerror = (e) => console.error('stream error', e.message ?? e);

Consuming SSE in Node.js covers backpressure, graceful shutdown and using it inside services.

Python Permalink to this section

httpx streams response bodies, and httpx-sse adds a parser and event iterator:

import httpx
from httpx_sse import connect_sse

with httpx.Client(timeout=httpx.Timeout(10.0, read=60.0)) as client:     # read timeout > heartbeat
    with connect_sse(client, "GET", "https://api.example.com/deployments/d_19/events",
                     headers={"Authorization": f"Bearer {token}"}) as source:
        for sse in source.iter_sse():
            if sse.event == "deployment.progress":
                print(sse.json()["pct"])

httpx-sse parses but does not reconnect; wrap it in a loop that tracks sse.id and sends it back as Last-Event-ID. Consuming SSE in Python builds that loop for both sync and async code.

Go Permalink to this section

Go’s standard library is enough: a streaming response body, a bufio.Reader, and a parser loop. The critical detail is the client timeout:

client := &http.Client{Timeout: 0}                  // no total timeout: it would cut the stream
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Last-Event-ID", lastID)
resp, err := client.Do(req)
if err != nil { return err }
defer resp.Body.Close()

reader := bufio.NewReader(resp.Body)
var data strings.Builder
event := ""
for {
	line, err := reader.ReadString('\n')
	if err != nil { return err }                      // stream ended: caller reconnects
	line = strings.TrimRight(line, "\r\n")
	switch {
	case line == "":
		if data.Len() > 0 { handle(event, strings.TrimSuffix(data.String(), "\n")) }
		data.Reset(); event = ""
	case strings.HasPrefix(line, ":"):
	case strings.HasPrefix(line, "data:"):
		data.WriteString(strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ") + "\n")
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimPrefix(strings.TrimPrefix(line, "event:"), " ")
	case strings.HasPrefix(line, "id:"):
		lastID = strings.TrimPrefix(strings.TrimPrefix(line, "id:"), " ")
	}
}

This loop splits on LF only, which covers servers that use LF or CRLF; a server using lone CR line endings needs a fuller parser. Use a read deadline via a watchdog goroutine to detect silent connections, since Timeout: 0 removes the only built-in limit.

.NET Permalink to this section

.NET 9 and later include SseParser in System.Net.ServerSentEvents, which turns a response stream into an async sequence of SseItem<string>:

using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Add("Last-Event-ID", lastId ?? "");
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
await foreach (var item in SseParser.Create(await res.Content.ReadAsStreamAsync(ct)).EnumerateAsync(ct))
{
    lastId = item.EventId ?? lastId;
    Handle(item.EventType, item.Data);
}

HttpCompletionOption.ResponseHeadersRead is essential: without it, SendAsync tries to buffer the entire, never-ending body.

The reconnect loop every runtime needs Permalink to this section

Most libraries parse; fewer reconnect. The loop is the same in every language, and writing it once as a small, tested function avoids re-inventing it per call site:

The reconnect loop a non-browser client must run State diagram of a non-browser SSE client moving between connecting, streaming, backing off and stopped, with transitions for success, stream end, retryable failure and fatal status. The reconnect loop a non-browser client must run CONNECT send Last-Event-ID STREAM parse, dispatch BACKOFF retry ms + jitter STOP fatal or cancelled 200 event-stream end / error timer 401 403 404 / cancel
This is EventSource's internal state machine, made explicit. Fatal statuses leave the loop; everything else comes back through backoff with the last id.
# Language-neutral shape, shown in Python.
def follow(url, handle, stop):
    last_id, retry_ms, delay = None, 3000, 1.0
    while not stop.is_set():
        try:
            for evt in open_and_parse(url, last_id):          # yields parsed events
                if evt.id is not None: last_id = evt.id
                if evt.retry is not None: retry_ms = evt.retry
                handle(evt)
                delay = 1.0                                     # healthy traffic resets backoff
        except FatalStatus:
            return                                              # 401/403/404: do not loop
        except (ConnectionError, TimeoutError, StreamEnded):
            pass
        stop.wait(max(retry_ms / 1000, delay) * (1 + random.random() * 0.3))
        delay = min(delay * 2, 60)

Command-line tools Permalink to this section

For scripts and CLIs, curl -N plus a few lines of shell is often enough to follow a stream interactively, and it is the first debugging tool for every client above:

# Follow a deployment's progress, printing only the percentage, reconnecting on drops.
last=""
while true; do
  curl -sN -H "Authorization: Bearer $TOKEN" ${last:+-H "Last-Event-ID: $last"} \
    https://api.example.com/deployments/d_19/events |
  while IFS= read -r line; do
    case "$line" in
      id:*)   last="${line#id: }" ;;
      data:*) echo "${line#data: }" | jq -r '.pct // empty' ;;
    esac
  done
  sleep 3
done

Mobile Permalink to this section

On iOS, URLSession’s async byte streams deliver the body incrementally; on Android, OkHttp’s SSE module wraps parsing and exposes callbacks. Both platforms suspend network activity when the app goes to the background, so mobile clients must close streams on backgrounding and reconnect with Last-Event-ID on return. Consuming SSE on iOS and Android has the details.

What common libraries provide Matrix of non-browser SSE libraries and whether each provides parsing, automatic reconnection, Last-Event-ID handling and custom headers. What common libraries provide Library Parsing Reconnect Last-Event-ID Custom headers eventsource (Node) yes yes yes via fetch httpx-sse (Python) yes no you send it yes okhttp-sse (Android) yes no you send it yes SseParser (.NET) yes no you send it yes Hand-written (Go, Swift) yours yours yours yes
Parsing is universal; reconnection is not. Where the library stops, your code starts.

Edge Cases & Network Interference Permalink to this section

  • Total timeouts. The number one cause of non-browser streams dying at a fixed age. Disable total timeouts; keep connect timeouts; use read (inactivity) timeouts set above the heartbeat interval.
  • Proxies in corporate or cloud networks. Egress proxies may buffer responses or cut long connections. Server-to-server streams through an egress proxy need the same buffering and timeout configuration as browser traffic.
  • Compression. Many HTTP clients request gzip by default. If the server compresses the stream without flushing per event, events arrive in batches; disable Accept-Encoding for the stream request or fix the server.
  • Connection pools. A long-lived stream occupies a pooled connection indefinitely. Clients with small per-host pool limits (some default to two or five) will block other requests to the same host; give streams their own client or raise the limit.
  • Clock-free reconnection. Servers may send retry: values; honour them, and add jitter so a fleet of service clients does not reconnect in lockstep after a server restart.

Authentication deserves a note of its own. Service clients usually hold credentials that expire — OAuth client-credentials tokens, short-lived cloud identity tokens, signed requests. A stream opened with a token that is valid for an hour will keep flowing past the token’s expiry, because authentication happened at connect time; the failure appears only at the next reconnect, often in the middle of the night. Obtain a fresh token for every connection attempt rather than caching one at start-up, and treat a 401 on reconnect as “refresh and retry once” rather than as fatal. If the server ends streams at token expiry — a good practice described in adding auth headers to SSE requests — the reconnect happens at a predictable time with a valid token.

Finally, identify your client. A descriptive User-Agent (service name and version) and a client identifier header make server-side logs and rate-limit decisions far easier to reason about when one consumer misbehaves, and let the stream’s owners contact you before they have to block you.

Mitigation checklist:

Performance & Scale Considerations Permalink to this section

Non-browser clients are often services consuming many streams at once — one per tenant, one per upstream partition, one per job. Their cost is dominated by per-connection memory and by how the runtime waits on idle sockets.

Memory per idle stream in a consuming service Bar chart comparing approximate memory per idle SSE stream held by a consuming service in Go, Node.js, Python asyncio and a thread-per-stream Python client. Memory per idle stream in a consuming service Go (goroutine) ~12 KB Node.js (event loop) ~25 KB Python asyncio ~40 KB Python thread per stream ~8 MB (stack) approximate kilobytes per idle stream, consumer side
Asynchronous runtimes hold idle streams cheaply. A thread per stream costs a stack each and caps how many streams one process can follow.

Language-model APIs are the most common non-browser SSE traffic today, and they have a distinctive profile: short-lived streams (seconds to minutes), high token rates, and a POST request body. The consuming service typically relays tokens onward — to its own browser clients over SSE, or into a pipeline. Three habits keep that relay efficient: forward chunks as they arrive rather than accumulating the whole answer, propagate cancellation from the downstream client to the upstream request so abandoned generations stop billing, and watch for the provider’s explicit end event instead of relying on the connection closing, since some providers keep the connection open briefly after the final message.

A service that follows thousands of streams should use an asynchronous client and a single event loop (or goroutines), feed events into bounded queues, and apply backpressure: if the consumer cannot keep up, it is better to let TCP flow control slow the server — by not reading — than to buffer unboundedly in memory. Reading only when the downstream queue has room gives exactly that.

Validation & Debugging Permalink to this section

# Confirm the stream outlives your client's old timeout settings.
time curl -sN --max-time 300 https://api.example.com/deployments/d_19/events > /dev/null
# The process should end at 300 s because of --max-time, not earlier.

Test clients against a real server rather than a mock that returns a complete body: the bugs worth catching — timeouts, partial chunks, reconnect loops — only appear when bytes arrive over time. A small local server that emits events on a timer, with switches to drop the connection, send a 401, pause for longer than the read timeout or split frames across writes, exercises every branch of the reconnect loop in a few seconds and belongs in each client’s test suite.

In each client, log connection lifecycle — connect, first event, disconnect cause, reconnect delay, resumed-from id — at info level, and events at debug level. For services, export metrics for open streams, events received per second, reconnects by cause and time since last event per stream. An alert on “time since last event greater than three heartbeat intervals” catches silently dead streams, which are the most dangerous failure because nothing errors.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Does Node.js have a built-in EventSource?

Recent Node.js versions have added an EventSource implementation behind an experimental flag. For production code, the eventsource package or fetch with a parser are the dependable choices.

Why does my Python client stop after exactly five seconds?

httpx applies a default timeout to every operation, including reads. A quiet stream exceeds it. Set a read timeout longer than the server's heartbeat interval, or none.

Is it acceptable to parse SSE with a regular expression?

Not reliably. Line endings, multi-line data, comments and chunk boundaries make a small state machine the correct tool. Use a library parser or the algorithm in the specification.

Should a backend consume SSE or use a message broker?

If the producer is another team's HTTP API, SSE is the interface you have. Between services you control, a broker gives stronger delivery guarantees and consumer groups; SSE is simpler when one service simply follows another's feed.

Can one process follow thousands of streams?

Yes, with an asynchronous client. An event loop or goroutines hold idle streams for kilobytes each; the limits are file descriptors and how fast the process can handle events, not the number of connections as such.

Should a service client share the HTTP connection pool with other calls?

Usually not. Streams hold connections for their lifetime and can exhaust small per-host pools. Give streams a dedicated client, or size the shared pool for streams plus normal traffic.

How do I consume LLM streaming APIs?

They are ordinary text/event-stream responses to POST requests. Use a fetch-style client with a parser, disable total timeouts, and handle the provider's end-of-stream event rather than waiting for the connection to close.

Deep Dives