skip to content

Explain the SseEmitter lifecycle: completion, timeout, and error callbacks, and how you manage disconnects and long-lived emitters.

level: seniorimportance: should knowfreq 42%

answer

  1. onCompletion = always, cleanup here
  2. onTimeout = infra-enforced, then complete()
  3. onError = usually client disconnect
  4. disconnect found when send() throws
  5. heartbeat comment keeps it alive

basics

~20 s

You 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 s

An 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
java
@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

for a junior

Know the three callbacks exist and that complete()/completeWithError() end the stream.

for a middle

Explain when each callback fires and using onCompletion for cleanup / a registry.

for a senior

Reason about disconnect detection via send() failures, heartbeats, committed-response error semantics, and timeout config.

for a principal

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.

context