Explain the SseEmitter lifecycle: completion, timeout, and error callbacks, and how you manage disconnects and long-lived emitters.
answer
- onCompletion = always, cleanup here
- onTimeout = infra-enforced, then complete()
- onError = usually client disconnect
- disconnect found when send() throws
- heartbeat comment keeps it alive
basics
~20 sYou register onCompletion, onTimeout, and onError callbacks. onTimeout and onError fire when the connection times out or an I/O error occurs (e.g. client disconnect); in both you should call complete()/completeWithError(). onCompletion always fires at the end — use it to remove the emitter from your registry.
solid answer
~50 sAn SseEmitter outlives the controller method, so you must manage its lifecycle. Register `onCompletion(Runnable)` — fires once when the response finishes for any reason (normal, timeout, or error) — as the place to clean up (remove from your active-emitters map). `onTimeout(Runnable)` fires when the configured timeout elapses; Spring will complete the request after, but you should call `emitter.complete()` yourself. `onError(Consumer<Throwable>)` fires on write/IO errors, typically a client disconnect. You end a stream with `complete()` (graceful) or `completeWithError(ex)`. Key gotchas: a `send()` after the client disconnected throws (IOException/IllegalStateException) — catch it and complete; the timeout is enforced by the async infrastructure, not a thread you own; and once the response is committed you can't change status codes or route the error through @ControllerAdvice, so surface errors as an SSE error event before completing if the client needs them.
code
java · 36 lines@RestController
public class NotificationController {
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
@GetMapping("/notifications")
public SseEmitter subscribe(@RequestParam String userId) {
SseEmitter emitter = new SseEmitter(300_000L); // 5 min
emitters.put(userId, emitter);
// all termination paths converge here
emitter.onCompletion(() -> emitters.remove(userId));
emitter.onTimeout(() -> { emitters.remove(userId); emitter.complete(); });
emitter.onError(e -> emitters.remove(userId));
return emitter;
}
// called from a message listener / scheduler on another thread
public void push(String userId, Object payload) {
SseEmitter emitter = emitters.get(userId);
if (emitter == null) return;
try {
emitter.send(SseEmitter.event().name("notice").data(payload));
} catch (IOException | IllegalStateException e) {
emitter.completeWithError(e); // triggers onCompletion cleanup
}
}
@Scheduled(fixedRate = 15_000)
public void heartbeat() {
emitters.forEach((id, em) -> {
try { em.send(SseEmitter.event().comment("ping")); }
catch (Exception e) { em.completeWithError(e); }
});
}
}go deeper
Know the three callbacks exist and that complete()/completeWithError() end the stream.
Explain when each callback fires and using onCompletion for cleanup / a registry.
Reason about disconnect detection via send() failures, heartbeats, committed-response error semantics, and timeout config.
Design robust multi-emitter fan-out: concurrent registry, thread-safe sends, reconnect strategy, proxy idle timeouts, back-pressure/overflow handling.
## Why lifecycle matters An `SseEmitter` (or `ResponseBodyEmitter`) represents a connection that stays open long after the controller method returns. You typically store active emitters in a **registry** (e.g. `CopyOnWriteArrayList<SseEmitter>` or a `Map<UserId, SseEmitter>`) and push to them from schedulers, message listeners, or `@Async` methods. If you never remove dead emitters, you leak memory and keep trying to write to closed connections. The three callbacks are how you know when to stop. ## The three callbacks - **`onCompletion(Runnable)`.** Registered callback that runs **once, when the request has completed** — for *any* reason: you called `complete()`, a timeout fired and Spring finished the request, or an error terminated it. This is the canonical cleanup hook: remove the emitter from your registry here so all termination paths converge on one cleanup. It runs in the container thread that finalizes the async request. - **`onTimeout(Runnable)`.** Runs when the emitter's timeout elapses. The timeout can be set per instance (`new SseEmitter(millis)`; `null` = no timeout / rely on container default) or globally via `spring.mvc.async.request-timeout` / `WebMvcConfigurer.configureAsyncSupport().setDefaultTimeout(...)`. When it fires, the async request is on its way to being completed by the framework; best practice is to also call `emitter.complete()` and stop your producer. **Important:** the timeout is enforced by Spring's async/servlet infrastructure — you don't run a watchdog thread yourself. After timeout, further `send()` calls fail. - **`onError(Consumer<Throwable>)`.** Runs when an error occurs while processing/writing — most commonly the **client disconnected** and a write failed. Use it to stop producing and clean up (though `onCompletion` will also run). ## Ending the stream - `emitter.complete()` finishes normally (client sees stream end). - `emitter.completeWithError(Throwable)` finishes with an error — Spring will try to dispatch the error, but **if the response is already committed** (headers/first bytes sent, which for SSE happens immediately), it can't change the HTTP status; it just terminates the connection. So the browser's `EventSource` sees a drop and will auto-reconnect. ## Disconnect detection gotcha There is no proactive push-notification of a browser closing the tab in the servlet model; you usually *discover* the disconnect when your next `send()` throws `IOException` (broken pipe) or `IllegalStateException` (emitter already completed). So wrap `send()` in try/catch, and on failure `completeWithError(e)` / remove from registry. This is why a **heartbeat** (periodic `emitter.send(SseEmitter.event().comment("ping"))`) is useful — it forces a write so dead connections are detected promptly and idle proxies don't close the stream. ## Error handling & @ControllerAdvice - Exceptions thrown *inside the controller method before returning the emitter* go through normal exception handling. - But exceptions thrown *later*, on the producer thread while streaming, occur after the response is committed — they cannot be turned into a clean error response and generally won't hit your `@ExceptionHandler`. Design for this: emit a domain-level error as an SSE event (`event: error`) then `complete()`, so the client can react, rather than relying on HTTP status. ## Concurrency and long-lived streams - **Thread-safety.** A single `SseEmitter` is not meant to be written concurrently from multiple threads without coordination; serialize sends per emitter. The registry itself should be a concurrent collection because add/remove/iterate happen across threads. - **Timeout vs keep-alive.** If your stream is legitimately long-lived (dashboards), either set a large/no timeout and rely on heartbeats + client reconnect, or set a modest timeout and let `EventSource` reconnect — reconnect is cheap and resets proxy idle timers. The `retry:` field (via `SseEventBuilder.reconnectTime`) tunes the browser's reconnect delay.
- How does your server learn that a browser closed the SSE tab?Usually not proactively — you discover it when the next send() throws IOException (broken pipe) or IllegalStateException. A periodic heartbeat send makes detection prompt; then completeWithError/cleanup runs.
- Why can't a @ControllerAdvice @ExceptionHandler handle an error thrown mid-stream?By the time you're streaming, the response is already committed (status and headers sent). Spring can't produce a new error response, so exceptions on the producer thread bypass normal handling; emit an SSE error event instead.
saying these in an interview costs you the question
- Claiming you must run your own thread to enforce the timeout (Spring's async infra does it).
- Assuming the server gets an immediate callback when the client disconnects (usually detected on next send()).
- Forgetting to remove emitters from the registry, leaking them.
- Expecting @ExceptionHandler to catch errors thrown after the response is committed.