# 2. The SSE lifecycle, and the three ways a stream ends Previous: [1. Two protocols](01-two-protocols.md) · Next: [3. The payload](03-the-payload.md) --- 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`](output/sse-wire-format.txt), captured from a raw socket by [`SseWireFormatTest`](../src/test/java/com/ankurm/ssews/SseWireFormatTest.java): ``` 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`](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`](output/async-timeout.txt)): ``` {"spring.mvc.async.request-timeout":"","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`](../src/main/java/com/ankurm/ssews/EmitterRegistry.java) 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](01-two-protocols.md) · Next: [3. The payload](03-the-payload.md)