Skip to main content

Server-Sent Events and WebSocket on Spring Boot 4: SseEmitter, STOMP, and Which to Pick

Server-Sent Events and WebSocket are compared as rivals, but one question separates them: does the client need to send anything back? A beginner-to-advanced guide with everything measured — why your dashboard silently reconnects every 30 seconds, why a dead SSE client is only discovered on the second write, why a STOMP client can publish straight to a topic and skip your controller, and why Spring’s 64 KB message limit is never the one that fires.

Every few months someone asks whether to use Server-Sent Events or WebSocket, and the answers they get are about protocols. Frame overhead. Binary support. HTTP/2. All true, all beside the point. The question that actually decides it is much smaller: does the client need to send anything after the first request? If no — a dashboard, a progress bar, a notification feed, a log tail — SSE is the whole answer, and it is a return type rather than a subsystem. If yes, and the messages are frequent or need routing between clients, you want WebSocket, and almost certainly STOMP on top of it. The rest is consequences. This article is mostly about those, because the getting-started half of both is genuinely short and the days people lose are somewhere else entirely: in a stream that dies after exactly thirty seconds with nothing in the log, in a dead client the server keeps writing to, in a handler that turns out not to be private, and in a size limit that is enforced by a component nobody configured. Everything below was run against a live embedded Tomcat on Spring Boot 4.1; the companion module reproduces every number in it.
If you want…Read
the decision, and the smallest working version of eachPart 1 — the one question
why your stream dies, and what a dead client costs youPart 2 — the SSE lifecycle
the STOMP defaults that are not what you would guessPart 3 — routing, origin and the limits
Versions (September 2026): Verified against Spring Boot 4.1.1, Spring Framework 7.0.9, Tomcat 11.0.24, Jackson 3.1.5 (tools.jackson) and JDK 25.0.4.1. Versions were read from maven-metadata.xml on Maven Central and from Boot's own spring-boot-dependencies POM, not from release announcements.

Part 1 — The one question

Server-Sent Events browser server GET, Accept: text/event-stream one HTTP response, held open, chunked reconnect and Last-Event-ID are free the client cannot speak again WebSocket (with STOMP) browser server GET + Upgrade: websocket framed, bidirectional, text or binary server knows who connected and left costs: a broker, a converter stack, an origin policy, and a stateful server

SSE: a return type, not a subsystem

There is no SSE starter and no SSE auto-configuration. SseEmitter lives in spring-webmvc, so the web starter is all you need, and a live dashboard is a handler method:
@GetMapping(path = "/sse/metrics", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter metrics() {
    return registry.register(UUID.randomUUID().toString(), new SseEmitter(Long.MAX_VALUE));
}
On the wire it is deliberately boring — captured here from a raw socket rather than a client library, because any library that parses events for you hides the thing worth seeing:
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
Four field names, a blank line as the record separator, and a comment line (:stream open) which clients discard — sending one every few seconds is the standard trick for stopping a proxy reaping an idle stream. Multi-line data becomes several data: lines that the browser rejoins with \n; it does not become two events. And there is no Content-Length, so the response is chunked, which is why any intermediary that buffers chunked responses defeats SSE completely.

STOMP: four lines of configuration

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic", "/queue");
        registry.setApplicationDestinationPrefixes("/app");
        registry.setUserDestinationPrefix("/user");
    }
}
@MessageMapping("/chat.send")
@SendTo("/topic/room")
public ChatMessage send(@Payload ChatMessage in, SimpMessageHeaderAccessor headers) {
    return new ChatMessage(in.from(), in.text(), Instant.now());
}
Each of those four configuration lines decides more than it looks like it does, and Part 3 is that list.
The Boot 4 dependency rule, again. Use spring-boot-starter-websocket, not a bare org.springframework:spring-websocket. The starter brings spring-boot-websocket, which is where the auto-configuration lives; depending on the library directly compiles, starts, and then does nothing. This is the same rule that catches people with spring-kafka and spring-rabbit: in Boot 4, depending on a library rather than on its Boot starter means you are missing its auto-configuration.

The objection that is wrong, and the one that is right

“SSE pins a thread per client” is the first thing people say, and it is false. SseEmitter puts the request into asynchronous mode; the request thread returns to the pool and the response stays open with nothing attached to it. Forty open streams against a connector whose pool is capped at ten:
=== server.tomcat.threads.max=10, clients=40 ===
emitters open        : 40
connector pool size  : 10 (max 10)
connector active     : 0
one broadcast reached: 40 streams
clients that read it : 40
The objection that is right is different, and it is worth planting now because everything in Part 2 follows from it: nothing owns an emitter. Spring keeps no list of them, tells you nothing when one dies, and cleans up after none of them.

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

new SseEmitter(..) registered in your own map 1. the client goes away found on a LATER write, not the next broadcasts before failure : 2 sendsOk : 1 sendsFailed : 1 catch IOException AND IllegalState 2. async timeout 200 already committed, so the socket just closes Tomcat default: 30 000 ms no 503, no error event 3. you call complete() the only ending you control completeWithError writes nothing useful to a committed response — send your own event:error first Therefore: remove the emitter on ALL THREE callbacks onCompletion(..) onTimeout(..) onError(..) Registering only onCompletion is the leak: a timed-out emitter is never completed by the client, stays in the map for ever, and every broadcast pays for it.

Why your dashboard reconnects every thirty seconds

Three levels decide how long a stream lives, checked in order: the SseEmitter constructor argument, then spring.mvc.async.request-timeout, then whatever the servlet container defaults to. Only the last one is invisible, so rather than quote it, the companion module reads it off the live AsyncContext:
{"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 to explain it, is this property and nothing else. new SseEmitter(Long.MAX_VALUE) is the fix — and it is also the line that turns a leaked emitter into a permanent one, which is why the registry above matters. Reading that number took a little care, and the detail is worth knowing if you ever go looking yourself: 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.
A timeout is invisible to the client, by design. The response was committed with 200 when the first byte went out, so there is no status code left to change and no body to replace. All the server can do is close the socket — and the browser's EventSource treats a closed socket as an ordinary disconnect and reconnects. That is why a badly-configured SSE endpoint looks like it works: the failure is indistinguishable from the recovery. Server-side it is visible, as onTimeout.

One measured detail: a 1 200 ms emitter timeout fired at about 2 100 ms. Tomcat checks async timeouts from its background processor, so expiry is granular to roughly a second rather than exact. Do not build anything that depends on the precise moment.

A dead client is discovered by a failed write — and not the first one

There is no disconnect event in SSE. The server finds out when it writes, which means the interesting question is how many writes it takes. Closing a socket and then broadcasting:
=== client closed the socket, then: ===
broadcasts before the write failed : 2
registry.sendsOk                   : 1
registry.sendsFailed               : 0 -> 1
registry.open                      : 0
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 every dashboard, at least one full serialise-and-write happens into a connection with nobody on the other end. The consequence for your code is a loop that catches two different things:
try {
    emitter.send(SseEmitter.event().id(id).name("metric").data(payload, APPLICATION_JSON));
}
catch (IOException | IllegalStateException ex) {
    // IOException  -> the socket is gone (tab closed, proxy reaped it)
    // IllegalState -> the emitter already completed or timed out on another thread
    emitters.remove(key);
}
That second catch is not defensive padding. If an IllegalStateException escapes from a @Scheduled broadcast method, the schedule stops — and every client's dashboard freezes at once because one of them closed a tab.

The media type argument does less than everyone says

The standard advice is to always pass a media type to data(..) or your object gets written with toString(). Both halves are wrong here, and finding out why explains a real trap. SseEmitter.send(data, mediaType) walks the application's configured HttpMessageConverter list and uses the first converter whose canWrite(type, mediaType) returns true. The media type is a filter, not a preference. For a record, nothing ahead of Jackson claims it, so the two calls are identical:
=== SseEmitter.event().data(record, APPLICATION_JSON) ===
{"seq":7,"host":"node-a","cpu":0.42,"heapMb":512,"at":"2026-09-03T10:00:00Z"}
=== SseEmitter.event().data(record)  [no media type] ===
{"seq":7,"host":"node-a","cpu":0.42,"heapMb":512,"at":"2026-09-03T10:00:00Z"}
=== POJO: identical? true ===
For a String the media type is not just redundant, it is ignored — which is the part I got wrong when writing the test and had to correct. StringHttpMessageConverter supports MediaType.ALL and sits ahead of Jackson, so it claims the write even when you explicitly ask for application/json:
=== String payload, no media type   -> {"already":"json"}
=== String payload, APPLICATION_JSON -> {"already":"json"}
There is no way to make send() JSON-quote a String. Good news if you are streaming pre-rendered JSON — it goes out verbatim rather than double-encoded. A trap if you assumed a String would be escaped for you: it will not be, and a newline inside it becomes two data: lines.
The Jackson tell. That "at":"2026-09-03T10:00:00Z" is an ISO-8601 string rather than an epoch number, which is Jackson 3 doing the work. Boot 4.1.1 manages Jackson 3.1.5 under tools.jackson, and the Jackson 2 siblings are still on the classpath under their old names. Under Boot 4, a “2” in a Spring class name means the previous Jackson — the unnumbered name is current, which is the opposite of the convention you would guess. For STOMP that means JacksonJsonMessageConverter, not MappingJackson2MessageConverter.

Part 3 — Routing, origin, and the limit that actually fires

Part 1 said the difference is whether the client can speak. Here is what happens when it can.

A STOMP client can publish straight to a topic

setApplicationDestinationPrefixes("/app") says which destinations reach a @MessageMapping. It does not say which destinations a client may send to. A SEND frame addressed to /topic/room goes directly to the broker, and every subscriber gets it. Sending a message whose timestamp the controller would have overwritten:
=== SEND straight to /topic/room ===
received: ChatMessage[from=not-checked-by-anyone, text=unvalidated, at=2000-01-01T00:00:00Z]
=== the controller's Instant.now() rewrite did NOT happen ===
No handler ran. No validation ran. Nothing logged that anything had been skipped. If your controller is where authorisation, sanitisation or rate limiting lives, a client that knows the destination name walks past all of it. The fix is not a routing setting. It is Spring Security's message-level authorisation — denying client SEND frames to /topic/** — or a broker relay whose own ACLs forbid publishing. Treat a bare simple broker as an open pipe.

A handler with no @SendTo is not private

The default destination for a @MessageMapping return value is the broker prefix plus the mapping. So @MessageMapping("/chat.echo") with no annotation publishes to /topic/chat.echo, and anyone who can guess the mapping can subscribe to the replies. If a handler's answer is for the caller alone, it needs @SendToUser — not silence.

convertAndSendToUser has two silent failure modes stacked on each other

/user/** is not a real destination. On SUBSCRIBE, the resolver rewrites it per session, which is why two clients subscribed to the same string get different messages:
Translated /user/queue/whisper -> [/queue/whisper-userea271ead-...]
Translated /user/queue/whisper -> [/queue/whisper-user84865e50-...]
With no authenticated Principal, the “user” is the STOMP session id — and the three-argument convertAndSendToUser(sessionId, dest, payload) then delivers nothing at all, because the resolver looks that name up in a user registry that has never heard of it. No exception. No log line. The message has to carry the session id as well:
SimpMessageHeaderAccessor out = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
out.setSessionId(targetSessionId);
out.setLeaveMutable(true);   // without this the accessor is frozen before the resolver looks
template.convertAndSendToUser(targetSessionId, "/queue/whisper", payload, out.getMessageHeaders());
And the session id you have is probably the wrong one. StompSession.getSessionId() on the client is the client's identifier, not the one the server keeps:

alice.getSessionId() [client side] : 08b58570-0fcb-ffaf-d82c-119232e66561
server-side session id              : f882cf6a-f0d9-48e3-baf3-c573045a47d1

Whispering to the client-side value fails exactly the way the missing header does — silently. The server-side id is what appears in SimpMessageHeaderAccessor.getSessionId(), and it has to come from the server. With Spring Security in the picture the Principal is a real user name and none of this arises, which is a decent argument for putting security in front of a STOMP endpoint before you think you need it.

Origin is checked, and it is a browser control rather than authentication

The WebSocket handshake is an ordinary HTTP GET, and the browser's same-origin policy does not apply to it — without a server-side check, a page on any site could open a WebSocket to your server with the user's cookies attached. Spring checks by default. Varying only the Origin header:
no Origin header          -> HTTP/1.1 101
Origin: http://localhost  -> HTTP/1.1 101
Origin: https://evil...   -> HTTP/1.1 403
The middle row is the policy working. The first row is the one to understand: a client that sends no Origin is allowed through, which is why non-browser clients connect without ceremony. Use setAllowedOriginPatterns("https://app.example.com") for a real cross-origin front end. And note something I got wrong before testing it: setAllowedOrigins("*") is accepted here — unlike the CORS builder, which throws when you combine it with credentials, the WebSocket registration takes it without complaint and simply switches the check off. There is no error to warn you.

The 64 KB message limit is never the one that fires

WebSocketTransportRegistration.setMessageSizeLimit defaults to 64 KB and is what every guide tells you to raise. Underneath it sits the servlet container's own WebSocket buffer, which the diagnostic endpoint reports as 8192. Spring's SubProtocolWebSocketHandler does not support partial messages, so the container must assemble each text message whole before Spring sees it — and if it cannot, it closes the connection.
STOMP frame from the client 1. Tomcat WebSocket buffer defaultMaxTextMessageBufferSize = 8192 assembles the whole message first (Spring does not do partial messages) measured ceiling: ~16 400 bytes 2. Spring messageSizeLimit 65 536 bytes — NEVER REACHED raising this alone changes nothing What the client sees close status 1009 “text message was too big for the output buffer…” no Spring log line at all Once the buffer is raised StompConversionException, logged, plus a STOMP ERROR frame the client sees Raise the container buffer ABOVE Spring's limit deliberately — so the limit that fires is the one that tells you it fired.
Binary-searching for the exact ceiling at four different buffer sizes:
container buffer   largest body delivered   smallest rejected
8 192 (default)              16 459               16 522
16 384                       16 446               16 496
32 768                       32 615               32 665
65 536                       65 350               65 400
Body length is the JSON string field; the STOMP frame adds about 90 bytes of headers on top. Read that table honestly: the ceiling behaves like max(16 KB, buffer). I have not reduced the 16 KB floor to a line in Tomcat's source and will not claim a mechanism I have not read. What is certain is narrower and still the point: Spring's 64 KB limit is not reached on a stock Tomcat, the real ceiling is about 16 KB, and raising Spring's limit alone does nothing. The fix is a container bean:
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
    ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
    container.setMaxTextMessageBufferSize(256 * 1024);
    container.setMaxBinaryMessageBufferSize(256 * 1024);
    return container;
}
The two failures look completely different, and only one is diagnosable. With the container buffer raised to 256 KB, Spring's limit finally decides — and it fails better: StompConversionException: The configured STOMP buffer size limit of 65536 bytes has been exceeded, logged with the byte count, plus a STOMP ERROR frame the client can see. The container's refusal gives you none of that: a bare close, status 1009, and silence from Spring. So the advice is not simply “raise the buffer” — it is raise the container buffer deliberately above Spring's limit, so that the limit that fires is the one that tells you it fired.

Should you build either of these?

Three cases where the honest answer is no. (1) Updates arrive every few minutes. Polling every thirty seconds is one endpoint with no lifecycle, no proxy configuration, no reconnect logic and no leaked emitters — and it is the right answer more often than it is chosen. (2) You want a chat, you have one server, and you have no plan for a second. Everything in Part 3 is real work, and the first time you scale out you will do it again with a broker relay, because STOMP sessions live in one JVM's memory and two instances behind a load balancer do not share subscriptions. Decide about the broker first. (3) The client is another service. SSE's advantage is that browsers implement it; STOMP's is that browsers can speak it. Neither advantage applies service-to-service, and neither protocol gives you flow control, deadlines or a schema.

Everything else

The choosing chapter and its siblings cover the long tail. A few that did not fit:
  • SSE costs a browser connection per stream, per tab. Over HTTP/1.1 a browser allows about six per origin, and three tabs of your dashboard leaves three for everything else on the page. Over HTTP/2 they multiplex and the limit effectively disappears — so “are we on HTTP/2 end to end, proxy included?” is a real input to this decision.
  • SSE costs one serialisation per subscriber per event. A thousand open dashboards is a thousand send() calls on one scheduler thread every tick, and SseEmitter offers no way to serialise once and write the bytes N times.
  • EventSource cannot send an Authorization header. There is no header API. Cookie, or a query parameter you then have to keep out of your access logs.
  • Behind nginx, a chunked SSE response is buffered by default and the client sees nothing until the buffer fills. proxy_buffering off, or the X-Accel-Buffering: no response header.
  • text/event-stream compresses extremely well and Boot's HTTP compression is off by default. Its default MIME list — read out of Compression in the 4.1.1 jars — is text/html, text/xml, text/plain, text/css, text/javascript, application/javascript, application/json and application/xml. No text/event-stream. Add it with server.compression.additional-mime-types rather than by replacing mime-types, or you lose the other eight.
  • SockJS is a second endpoint, not an option on the first. .withSockJS() rewrites the URL into /{server}/{session}/{transport}, so a plain WebSocket client pointed at that path fails the handshake.
  • Surefire does not discover static nested test classes. Seven of the companion module's seventeen tests silently did not run until they were split into top-level classes. Check the count, not the colour.

Reproducing this

git clone https://ankurm.com/git.app/asmhatre/spring-messaging-demo.git
cd spring-messaging-demo/sse-websocket
./scripts/run-all.sh
Seventeen tests against a real embedded Tomcat over real sockets. No Docker, no broker, nothing to install but a JDK. Every transcript quoted above is regenerated by that one command into docs/output/.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.