1
0
Files
spring-messaging-demo/sse-websocket/docs/02-sse-lifecycle.md
2026-09-04 00:52:18 +05:30

5.4 KiB

2. The SSE lifecycle, and the three ways a stream ends

Previous: 1. Two protocols · Next: 3. The payload


An SseEmitter has exactly one interesting property: nothing owns it. Spring does not keep a list, does not notify you when one dies, and does not clean up after you. Every SSE bug in production is a variation on that sentence.

The wire format

docs/output/sse-wire-format.txt, captured from a raw socket by SseWireFormatTest:

HTTP/1.1 200
Content-Type: text/event-stream
Transfer-Encoding: chunked

:stream open

id:1
event:metric
retry:3000
data:{"seq":1,"host":"node-a","cpu":0.42,"heapMb":512,"at":"2026-09-03T10:00:00Z"}

id:2
event:note
data:line one
data:line two

Points that matter:

  • No Content-Length, so the response is chunked. Any intermediary that buffers a chunked response defeats SSE entirely; that is why X-Accel-Buffering: no exists for nginx.
  • A blank line terminates an event. Two data: lines in one event are rejoined by the client with \n — a multi-line string does not become two events.
  • A line starting with : is a comment and is discarded. Sending one periodically is the standard trick for keeping an idle stream alive through a proxy that reaps quiet connections.
  • retry: sets the client's reconnect delay in milliseconds, and it is sticky.
  • id: is what comes back as Last-Event-ID on the browser's automatic reconnect. See /sse/resume for the replay shape; almost no server implements it, which is why "SSE guarantees delivery" is false in practice.

Ending 1: the client goes away

The server does not find out until it writes. SseDisconnectTest closes a socket, then broadcasts, and counts:

broadcasts before the write failed : 2
registry.sendsOk                   : 1
registry.sendsFailed               : 0 -> 1

The first write after the client vanished succeeded. TCP accepted the bytes into the socket buffer and only surfaced the reset on the write after that. So on any dashboard, at least one full serialise-and-write happens into a dead connection, and on a slow-to-reset path it can be several.

The consequence for your code: the broadcast loop must catch and drop, and it must catch two things. IOException is the socket. IllegalStateException is the emitter having already been completed or timed out on a different thread — and if that escapes onto a @Scheduled method, the schedule stops and every client's dashboard freezes at once.

Ending 2: the async request times out

SseTimeoutTest opens a stream with a 1.2-second emitter timeout, sends one event, then waits. docs/output/sse-lifecycle.txt:

HTTP/1.1 200
Content-Type: text/event-stream
Transfer-Encoding: chunked

event:hello
data:then silence

0
=== stream ended after ~2165 ms ===

Nothing marks the timeout. No 503, no error event, no trailer. The response was committed with 200 when the first byte went out, so closing the socket is the only thing left to do, and the browser's EventSource treats that as an ordinary disconnect and reconnects. Server-side it is visible: onTimeout ran and the registry's counter moved.

Note the ~2.1 s for a 1.2 s timeout. Tomcat checks async timeouts from its background processor, so expiry is granular to about a second rather than exact.

The number that decides how long a stream lives

Three levels, checked in order:

  1. new SseEmitter(millis) — per handler
  2. spring.mvc.async.request-timeout — per application
  3. whatever the servlet container defaults to — invisible

AsyncTimeoutUnsetTest reads the third off the live AsyncContext rather than quoting it (async-timeout.txt):

{"spring.mvc.async.request-timeout":"<unset>","servletContainer":"Apache Tomcat/11.0.24","effectiveAsyncTimeoutMs":30000}
{"spring.mvc.async.request-timeout":"5s","servletContainer":"Apache Tomcat/11.0.24","effectiveAsyncTimeoutMs":5000}

Thirty seconds. A dashboard that silently reconnects every thirty seconds, with nothing in the log, is this and nothing else. new SseEmitter(Long.MAX_VALUE) is the usual fix, and it is also what turns a leaked emitter into a permanent one.

Reading it took some care: inside the handler method request.isAsyncStarted() is still false, because Spring starts async processing after the method returns and it has seen the return type. The first place the real AsyncContext exists is AsyncHandlerInterceptor.afterConcurrentHandlingStarted.

Ending 3: you complete it

emitter.complete(), or completeWithError(ex). The second writes nothing useful to a stream that is already committed — if you want the client to know something went wrong, send an event:error of your own first, then complete.

Therefore: a registry

EmitterRegistry exists because of the three endings above. It removes on all three callbacks, not just onCompletion. Registering only onCompletion is the common version of this bug: a timed-out emitter is never completed by the client, so it stays in the map for ever and every subsequent broadcast pays for it.


Previous: 1. Two protocols · Next: 3. The payload