1
0

Add the sse-websocket module

This commit is contained in:
2026-09-04 00:52:18 +05:30
parent e1f8aa7402
commit 5224afdad2
51 changed files with 2761 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
# 1. Two protocols, one question
Next: [2. The SSE lifecycle](02-sse-lifecycle.md)
---
Server-Sent Events and WebSocket get compared as if they were rivals for the same job. They are
not. The question that separates them is much smaller than "which is better", and it is this:
> **Does the client need to send anything after the first request?**
If the answer is no — a dashboard, a progress bar, a notification feed, a log tail, a
"your export is ready" ping — SSE is the whole answer, and it is a return type rather than a
subsystem. If the answer is yes, and the messages are frequent or need routing between clients,
you want WebSocket, and almost certainly STOMP on top of it.
Everything else in the comparison follows from that one asymmetry.
## What each one costs you to set up
| | SSE | STOMP over WebSocket |
|---|---|---|
| Dependency | `spring-boot-starter-webmvc` | `spring-boot-starter-websocket` |
| Auto-configuration involved | none | `spring-boot-websocket` |
| Server-side surface | a handler method returning `SseEmitter` | `@EnableWebSocketMessageBroker`, a configurer, a broker, a converter stack |
| Transport | one long-lived HTTP response | an HTTP/1.1 Upgrade to a framed, bidirectional connection |
| Client | `new EventSource(url)`, built into every browser | a STOMP library |
| Reconnect | automatic, with `Last-Event-ID` | yours to write |
| Server knows who is connected | no | yes — `SimpUserRegistry`, connect/subscribe/disconnect events |
| Payload | UTF-8 text only | text or binary |
The dependency line is the Boot 4 detail worth stating out loud. `spring-boot-starter-websocket`
pulls in `spring-boot-websocket`, which is where the auto-configuration lives. Depending on
`org.springframework:spring-websocket` and `spring-messaging` directly compiles and starts, and
then nothing works — the same rule that catches people with `spring-kafka` in
[`../kafka-basics`](../kafka-basics/README.md) and `spring-rabbit` in
[`../rabbitmq`](../rabbitmq/README.md). In Boot 4, **depending on a library rather than on its
Boot starter means you are missing its auto-configuration.**
## What SSE is not
Two things people assume, both wrong, both measured in this module:
**"It pins a thread per client."** It does not. `SseEmitter` switches the request to asynchronous
mode; the request thread goes back to the pool immediately and the response stays open with
nothing attached to it. [`SseConcurrencyTest`](../src/test/java/com/ankurm/ssews/SseConcurrencyTest.java)
holds forty streams open against a connector whose pool is capped at ten:
```
emitters open : 40
connector pool size : 10 (max 10)
connector active : 0
```
**"You have to pass a media type or it calls `toString()`."** Also not true, and the opposite is
closer to it. See [3. The payload](03-the-payload.md).
## What SSE genuinely cannot do
- **Take input.** `EventSource` issues one GET and never sends again. Client-to-server traffic has
to go over ordinary HTTP requests, which is fine for low rates and awful for a chat.
- **Send custom headers.** The browser's `EventSource` has no header API, so bearer-token
authentication needs a cookie or a query parameter. `withCredentials` exists; `Authorization`
does not.
- **Carry binary.** The format is line-oriented UTF-8. Binary means base64, which costs a third.
- **Tell you a client left.** There is no disconnect event, only a write that fails later —
see [2. The SSE lifecycle](02-sse-lifecycle.md).
- **Escape the browser's per-origin connection limit.** Over HTTP/1.1 a browser allows about six
concurrent connections to one origin, and an open SSE stream is one of them, *per tab*. Four
tabs of a dashboard is 4 streams; six is a hung site. HTTP/2 multiplexes and the limit
effectively disappears, which makes "do we serve this over HTTP/2?" a real part of the decision.
## What STOMP costs
A broker, a message-converter stack, a session registry, an origin policy, and a set of routing
rules whose defaults are not what you would guess. Chapters
[4](04-stomp.md) and [5](05-limits.md) are that list.
---
Next: [2. The SSE lifecycle](02-sse-lifecycle.md)

View File

@@ -0,0 +1,132 @@
# 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":"<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 &mdash; 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) &middot; Next: [3. The payload](03-the-payload.md)

View File

@@ -0,0 +1,83 @@
# 3. The payload, and a media type that does less than you think
Previous: [2. The SSE lifecycle](02-sse-lifecycle.md) &middot; Next: [4. STOMP](04-stomp.md)
---
The advice you will find is: always pass a media type to `data(..)`, or your object is written
with `toString()`. Both halves of that turn out to be wrong on Boot 4.1, and the mechanism
underneath explains a real trap that the advice misses.
## What actually happens
`SseEmitter.send(Object data, MediaType mediaType)` walks the application's configured
`HttpMessageConverter` list and uses **the first converter whose `canWrite(type, mediaType)`
returns true**. The media type argument is a *filter*, not a preference. A `null` media type
filters nothing, so the first converter that can write the type at all wins.
`/diag` prints the list in order:
```
ByteArrayHttpMessageConverter
StringHttpMessageConverter
ResourceHttpMessageConverter
ResourceRegionHttpMessageConverter
AllEncompassingFormHttpMessageConverter
JacksonJsonHttpMessageConverter
Jaxb2RootElementHttpMessageConverter
```
Two consequences, both measured by
[`SseDataConversionTest`](../src/test/java/com/ankurm/ssews/SseDataConversionTest.java)
([`sse-payload-conversion.txt`](output/sse-payload-conversion.txt)):
**For a record or POJO, the media type changes nothing.** No converter ahead of Jackson claims it,
so Jackson writes it either way:
```
=== 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 *still* changes nothing** &mdash; and this is the part that
surprised me enough to rewrite the test. `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 &mdash; it goes out verbatim, not double-encoded. A trap if you assumed a
`String` field would be escaped for you: it will not be, and a payload containing a newline
becomes two `data:` lines rather than one.
## Jackson 3, and the `Instant` that proves it
`"at":"2026-09-03T10:00:00Z"` &mdash; an ISO-8601 string, not an epoch number. Boot 4.1.1 manages
Jackson **3.1.5** under `tools.jackson`, and `JacksonJsonHttpMessageConverter` is the Jackson 3
converter. The Jackson 2 sibling (`MappingJackson2HttpMessageConverter`) is still on the
classpath in the messaging stack.
**Rule for 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, and it is the same fork
that catches people in [`../kafka-basics`](../kafka-basics/README.md) and
[`../rabbitmq`](../rabbitmq/README.md). For STOMP it is
`JacksonJsonMessageConverter` you want, not `MappingJackson2MessageConverter`.
## Practical guidance
- Pass `MediaType.APPLICATION_JSON` anyway. It costs nothing, it documents intent, and it makes
the code correct against a converter list you did not configure yourself.
- Do not stream a `String` and assume it is escaped. Send the object.
- Keep events small. Every subscriber pays for one serialisation per event; see
[6. Choosing](06-choosing.md).
---
Previous: [2. The SSE lifecycle](02-sse-lifecycle.md) &middot; Next: [4. STOMP](04-stomp.md)

View File

@@ -0,0 +1,129 @@
# 4. STOMP: four lines of configuration, three surprising defaults
Previous: [3. The payload](03-the-payload.md) &middot; Next: [5. The limits](05-limits.md)
---
[`WebSocketConfig`](../src/main/java/com/ankurm/ssews/WebSocketConfig.java) is short. Each line
decides more than it looks like it does.
```java
registry.enableSimpleBroker("/topic", "/queue");
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/user");
```
## Surprise 1: a 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` to `/topic/room` goes
directly to the broker, and every subscriber receives it.
[`StompRoutingTest`](../src/test/java/com/ankurm/ssews/StompRoutingTest.java) sends a message with
a timestamp the controller would have overwritten, and it arrives untouched:
```
=== 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 was skipped. If your controller is
where authorisation, sanitisation or rate limiting happens, a client that knows the destination
name walks past all of it.
The fix is not a routing setting &mdash; it is Spring Security's message-level authorisation
(`simpDestMatchers("/topic/**").denyAll()` for client `SEND` frames), or a broker relay whose
own ACLs forbid publishing. Treat a bare simple broker as an open pipe.
## Surprise 2: a handler with no `@SendTo` is not private
The default destination for a `@MessageMapping` return value is the broker prefix plus the
mapping. `@MessageMapping("/chat.echo")` with no annotation publishes to **`/topic/chat.echo`**:
```
=== @MessageMapping("/chat.echo") with NO @SendTo ===
subscribed to /topic/chat.echo, received: ChatMessage[from=echo, text=who can see this?, ...]
```
Anyone who can guess the mapping can subscribe to the replies. If a handler's answer is for the
caller only, it needs `@SendToUser`, not silence.
## Surprise 3: `convertAndSendToUser` needs more than a name
`/user/**` is not a real destination. On `SUBSCRIBE`, `DefaultUserDestinationResolver` rewrites
`/user/queue/whisper` into a session-scoped destination, 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 &mdash; and the three-argument
`convertAndSendToUser(sessionId, dest, payload)` **silently delivers nothing**, 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 too:
```java
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 there is a second trap stacked on the first, which cost a debugging round here:
> **`StompSession.getSessionId()` on the client is not the server's session id.**
> ```
> alice.getSessionId() [client side] : 08b58570-0fcb-ffaf-d82c-119232e66561
> server-side session id : f882cf6a-f0d9-48e3-baf3-c573045a47d1
> ```
> Whispering to the client-side value delivers nothing, silently, exactly like the missing header
> does. 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 applies
&mdash; which is a good argument for putting security in front of a STOMP endpoint even when you
do not think you need authentication yet.
## What STOMP gives you that SSE cannot
Presence. [`PresenceListener`](../src/main/java/com/ankurm/ssews/PresenceListener.java) is fifteen
lines and produces:
```
| CONNECTED d775c936-8492-4b32-871e-c659346a2fb9
| SUBSCRIBE d775c936-8492-4b32-871e-c659346a2fb9 -> /topic/room
| DISCONNECT def64a1f-dc10-45bc-92d3-aa2e842ae02f status=CloseStatus[code=1000, reason=null]
```
The server knows who is connected, what they subscribed to, and when they left, with a close
status attached. SSE has no equivalent of any of those three.
## The simple broker is not a broker
`enableSimpleBroker` is in-memory, single JVM, no persistence, no acknowledgement, and it drops
everything on restart. It is also fine for a great many applications. When it is not,
`enableStompBrokerRelay("/topic", "/queue")` swaps in RabbitMQ or ActiveMQ **without touching a
single controller** &mdash; the annotations and destinations stay exactly as they are. The broker
side of that is [`../rabbitmq`](../rabbitmq/README.md).
Note the asymmetry once you relay: `/topic` and `/queue` become real broker destinations with
real semantics, and the "client can publish straight to a topic" problem becomes the broker's ACL
problem rather than yours.
## SockJS is a second endpoint, not an option on the first
```java
registry.addEndpoint("/ws");
registry.addEndpoint("/ws-sockjs").withSockJS();
```
`.withSockJS()` on the same path does not give you both. SockJS rewrites the URL into
`/{server}/{session}/{transport}`, and a plain WebSocket client pointed at that path fails the
handshake. In 2026, SockJS is mostly legacy &mdash; WebSocket support is universal &mdash; but it
is still the answer for a corporate proxy that strips `Upgrade`.
---
Previous: [3. The payload](03-the-payload.md) &middot; Next: [5. The limits](05-limits.md)

View File

@@ -0,0 +1,119 @@
# 5. The limits: origin, size, and which one actually fires
Previous: [4. STOMP](04-stomp.md) &middot; Next: [6. Choosing](06-choosing.md)
---
## Origin: checked by default, and the check is real
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 `Origin` by default.
[`WebSocketHandshakeOriginTest`](../src/test/java/com/ankurm/ssews/WebSocketHandshakeOriginTest.java)
varies only that header ([`handshake-origin.txt`](output/handshake-origin.txt)):
```
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` at all is allowed through, which is why every Java test in this module connects
without ceremony &mdash; and why `Origin` is a browser control, not an authentication mechanism.
For a real cross-origin front end use `setAllowedOriginPatterns("https://app.example.com")`;
`setAllowedOrigins("*")` is rejected outright when credentials are allowed.
## Size: two limits, and the documented one is not the one that fires
`WebSocketTransportRegistration.setMessageSizeLimit` defaults to 64&nbsp;KB and is what every
guide tells you to raise. Underneath it sits the servlet container's own WebSocket buffer, which
`/diag` reports as:
```
"ws.container.defaultMaxTextMessageBufferSize":8192
```
Spring's `SubProtocolWebSocketHandler` does not support partial messages, so the container has to
assemble each text message whole before Spring sees it. Cross the container's ceiling and the
connection is **closed** &mdash; WebSocket status 1009, `The decoded text message was too big for
the output buffer and the endpoint does not support partial messages` &mdash; before Spring's
limit is ever consulted.
[`StompSizeLimitDefaultBufferTest`](../src/test/java/com/ankurm/ssews/StompSizeLimitDefaultBufferTest.java)
binary-searches for the exact ceiling
([`websocket-size-limits.txt`](output/websocket-size-limits.txt)):
| container buffer | largest body delivered | smallest rejected |
|---|---|---|
| 8&nbsp;192 (Tomcat default) | 16&nbsp;459 | 16&nbsp;522 |
| 16&nbsp;384 | 16&nbsp;446 | 16&nbsp;496 |
| 32&nbsp;768 | 32&nbsp;615 | 32&nbsp;665 |
| 65&nbsp;536 | 65&nbsp;350 | 65&nbsp;400 |
Body length is the JSON string field; the STOMP frame adds about 90 bytes of headers on top, so
each row is a frame just under 16&nbsp;KB, 16&nbsp;KB, 32&nbsp;KB and 64&nbsp;KB respectively.
Read that table honestly: **the ceiling behaves like `max(16 KB, buffer)`**. Setting the buffer to
16&nbsp;KB changes nothing from the default; above 16&nbsp;KB the ceiling follows it. I have not
reduced the 16&nbsp;KB floor to a line in Tomcat's source and will not claim a mechanism I have
not read. What is certain, and is the point:
> **Spring's 64&nbsp;KB `messageSizeLimit` is not reached on a stock Tomcat.** The real ceiling is
> about 16&nbsp;KB, it is enforced by the container, and raising Spring's limit alone does
> nothing whatsoever.
The fix is a `ServletServerContainerFactoryBean`
([`WebSocketBufferConfig`](../src/main/java/com/ankurm/ssews/WebSocketBufferConfig.java), active
under the `bigframes` profile):
```java
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(256 * 1024);
container.setMaxBinaryMessageBufferSize(256 * 1024);
return container;
}
```
### The two failures look different, and only one is diagnosable
With the buffer at 256&nbsp;KB, Spring's limit finally decides &mdash; and it fails *better*:
```
60 KB body -> delivered, body length 61440
80 KB body -> NOT delivered, connection dropped
org.springframework.messaging.simp.stomp.StompConversionException: The configured STOMP buffer size limit of 65536 bytes has been exceeded
```
Spring logs the exception, names the limit, and sends a STOMP `ERROR` frame the client can see.
The container's refusal does none of that. So the practical advice is not just "raise the buffer"
&mdash; it is **raise the container buffer above Spring's limit deliberately, so that the limit
that fires is the one that tells you it fired.**
### The client has the same limit
A `WebSocketStompClient` built on `StandardWebSocketClient` inherits the same 8&nbsp;KB default,
and hits it on its *own* session first. That produced a false positive while writing these tests:
a message that looked rejected by the server was actually refused by the client.
[`SizeProbe`](../src/test/java/com/ankurm/ssews/SizeProbe.java) raises the client side to 1&nbsp;MB
before measuring anything.
## Send buffer and send time
Two limits that bite in production rather than in tests, both set in `WebSocketConfig`:
- `setSendBufferSizeLimit` (512&nbsp;KB here) &mdash; how much undelivered output Spring will hold
for one slow client before closing its session. Without it, one client on hotel wifi can grow
the heap until the process dies.
- `setSendTimeLimit` (20&nbsp;s here) &mdash; how long a single send may take before the session is
closed.
Both exist because a broadcast is only as fast as its slowest subscriber, and a WebSocket session
has no equivalent of TCP's back-pressure reaching your application code. This is precisely the gap
RSocket closes, and the reason the [next article](06-choosing.md) exists.
---
Previous: [4. STOMP](04-stomp.md) &middot; Next: [6. Choosing](06-choosing.md)

View File

@@ -0,0 +1,80 @@
# 6. Choosing, and when the answer is neither
Previous: [5. The limits](05-limits.md)
---
## The decision, in one table
| If | Use | Because |
|---|---|---|
| Server pushes, client never speaks | **SSE** | A return type. No broker, no converter stack, no origin policy. Reconnect is free. |
| Clients talk to each other, or to the server, often | **STOMP over WebSocket** | Routing, presence, per-user destinations, and a path to a real broker without touching controllers. |
| One-off progress on an operation the client already started | **SSE** | The stream is scoped to the request. Nothing to clean up. |
| Payloads are binary, or large | **WebSocket** | SSE is line-oriented UTF-8; binary costs a third in base64. |
| The consumer is another service, not a browser | **neither** &mdash; see below | Browsers are the only reason to accept SSE's or STOMP's constraints. |
| You need the consumer to say "slow down" | **neither** | Covered in the next article. |
## The costs nobody puts in the table
**SSE costs a connection per stream, per tab.** Over HTTP/1.1 a browser allows about six
concurrent connections per origin, and an open `EventSource` is one of them. Three tabs of your
dashboard leaves three for everything else on the page. Over HTTP/2 they multiplex over one
connection and the limit effectively disappears &mdash; so "are we behind HTTP/2 end to end,
including the proxy?" is a real input to this decision, not a detail.
**SSE costs one serialisation per subscriber per event.** The fan-out in
[`EmitterRegistry.broadcast`](../src/main/java/com/ankurm/ssews/EmitterRegistry.java) is a loop:
a thousand open dashboards means a thousand `send()` calls on one scheduler thread, every tick.
The obvious fix &mdash; serialise once and write the bytes N times &mdash; is not something
`SseEmitter` offers.
**SSE does not pin threads, though.** That objection is wrong, and
[`sse-concurrency.txt`](output/sse-concurrency.txt) is the evidence:
```
=== 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
```
**STOMP costs a stateful server.** Sessions live in one JVM's memory. Two instances behind a load
balancer do not share subscriptions, so a message published on instance A never reaches a
subscriber on instance B &mdash; which is the real reason to move to a broker relay, well before
you need persistence.
## Should you build this at all?
Three cases where the honest answer is no:
- **Updates arrive every few minutes.** Polling every thirty seconds is one endpoint, no
lifecycle, no proxy configuration, no reconnect logic and no leaked emitters. The right answer
more often than it is chosen.
- **You want a chat and you have one server and no plan for a second.** Everything in
[chapter 4](04-stomp.md) is real work, and the first time you scale out you will be doing it
again with a broker relay. Decide about the broker first.
- **The client is another service.** SSE's advantage is that browsers implement it. STOMP's
advantage is that browsers can speak it. Neither advantage applies service-to-service, and
neither protocol gives you flow control, deadlines or a schema &mdash; which is what the
companion article on RSocket, gRPC and raw WebSocket is about.
## Everything else, one line each
- **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.
- **`EventSource` cannot send an `Authorization` header.** Cookie, or a query parameter you then
have to keep out of access logs.
- **Heartbeats.** SSE: send a `:` comment every N seconds. STOMP: `setHeartbeatValue` plus a
`TaskScheduler`, and both sides must agree.
- **Compression.** `text/event-stream` compresses extremely well and Boot's HTTP compression is
off by default; `server.compression.mime-types` must list it explicitly.
- **Surefire does not discover static nested test classes.** Seven of this module's seventeen
tests silently did not run until they were split into top-level classes. Check the count, not
the colour.
---
Previous: [5. The limits](05-limits.md)

View File

@@ -0,0 +1,4 @@
=== spring.mvc.async.request-timeout ABSENT ===
{"spring.mvc.async.request-timeout":"<unset>","servletContainer":"Apache Tomcat/11.0.24","effectiveAsyncTimeoutMs":30000}
=== spring.mvc.async.request-timeout=5s ===
{"spring.mvc.async.request-timeout":"5s","servletContainer":"Apache Tomcat/11.0.24","effectiveAsyncTimeoutMs":5000}

View File

@@ -0,0 +1,4 @@
=== GET /ws handshake, 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

View File

@@ -0,0 +1,6 @@
=== 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

View File

@@ -0,0 +1,23 @@
=== GET /sse/silent?timeoutMs=1200 : whole response ===
| HTTP/1.1 200
| Content-Type: text/event-stream
| Transfer-Encoding: chunked
| Date: Thu, 03 Sep 2026 18:59:34 GMT
| Connection: close
|
| 1f
| event:hello
| data:then silence
|
|
| 0
|
=== stream ended after ~1697 ms ===
=== registry.timedOut(): 0 -> 1 ===
=== client closed the socket, then: ===
broadcasts before the write failed : 2
registry.sendsOk : 1
registry.sendsFailed : 0 -> 1
registry.open : 0
=== writes that appeared to succeed after the client was gone: 1 ===

View File

@@ -0,0 +1,8 @@
=== 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 ===
=== String payload, no media type -> {"already":"json"}
=== String payload, APPLICATION_JSON -> {"already":"json"}
=== String payload, APPLICATION_JSON -> {"already":"json"}

View File

@@ -0,0 +1,22 @@
=== GET /sse/raw : raw bytes, one line per row ===
| HTTP/1.1 200
| Content-Type: text/event-stream
| Transfer-Encoding: chunked
| Date: Thu, 03 Sep 2026 18:59:33 GMT
| Connection: close
|
| ac
| :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
|
|
| 0

View File

@@ -0,0 +1,23 @@
=== SEND /app/chat.send -> @SendTo("/topic/room") ===
alice received : ChatMessage[from=alice, text=is this thing on?, at=2026-09-03T18:59:36.632180396Z]
bob received : ChatMessage[from=alice, text=is this thing on?, at=2026-09-03T18:59:36.632180396Z]
=== presence events the server saw ===
| CONNECTED 122616fa-6e84-4c09-a317-6dee68f17794
| CONNECTED 22633c5f-f886-4ff2-94d0-156e43d92870
| SUBSCRIBE 122616fa-6e84-4c09-a317-6dee68f17794 -> /topic/room
| SUBSCRIBE 22633c5f-f886-4ff2-94d0-156e43d92870 -> /topic/room
| DISCONNECT 122616fa-6e84-4c09-a317-6dee68f17794 status=CloseStatus[code=1002, reason=null]
=== @MessageMapping("/chat.echo") with NO @SendTo ===
subscribed to /topic/chat.echo, received: ChatMessage[from=echo, text=who can see this?, at=2026-09-03T18:59:38.089388303Z]
=== 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 ===
alice.getSessionId() [client side] : 97d9d368-53ab-0f29-0dd4-2c0515124971
server-side session id : c6ddbdda-36a6-4a9e-8a15-6714379ecdb9
=== convertAndSendToUser(aliceSession, "/queue/whisper", ..) ===
both subscribed to the SAME string : /user/queue/whisper
alice received : ChatMessage[from=42eda7c9-16b6-4ffc-bf68-60e8edfe4239, text=just for you, at=2026-09-03T19:00:15.135586620Z]
bob received : null

View File

@@ -0,0 +1,16 @@
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 25.88 s -- in com.ankurm.ssews.StompSizeLimitTunedBufferTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.620 s -- in com.ankurm.ssews.AsyncTimeoutUnsetTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.044 s -- in com.ankurm.ssews.SseWireFormatTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.042 s -- in com.ankurm.ssews.WebSocketHandshakeOriginTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.416 s -- in com.ankurm.ssews.AsyncTimeoutPropertyTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.144 s -- in com.ankurm.ssews.SseDisconnectTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.342 s -- in com.ankurm.ssews.SseConcurrencyTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.023 s -- in com.ankurm.ssews.SseTimeoutTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.146 s -- in com.ankurm.ssews.StompChatTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.647 s -- in com.ankurm.ssews.StompRoutingTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 13.52 s -- in com.ankurm.ssews.StompSizeLimitSpringLimitTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.040 s -- in com.ankurm.ssews.SseDataConversionTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.042 s -- in com.ankurm.ssews.WebSocketContainerRaisedTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 22.80 s -- in com.ankurm.ssews.StompSizeLimitDefaultBufferTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.143 s -- in com.ankurm.ssews.StompUserDestinationTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.025 s -- in com.ankurm.ssews.WebSocketContainerDefaultsTest

View File

@@ -0,0 +1,36 @@
The limit that fires first is the servlet container's WebSocket buffer, not Spring's
messageSizeLimit. 'body' below is the length of the JSON string field; the STOMP frame
carries about 90 bytes of headers on top of it.
=== container buffer: Tomcat default (8192, see /diag) ===
8 KB body -> delivered, body length 8192
16 KB body -> delivered, body length 16384
60 KB body -> NOT delivered, connection dropped
largest body delivered : 16459 bytes
smallest body rejected : 16522 bytes
=== container buffer set to 16384 ===
largest body delivered : 16446 bytes
smallest body rejected : 16496 bytes
=== container buffer set to 32768 ===
largest body delivered : 32615 bytes
smallest body rejected : 32665 bytes
=== container buffer set to 65536 ===
largest body delivered : 65350 bytes
smallest body rejected : 65400 bytes
=== bigframes profile: container buffer 256 KB, Spring limit 64 KB ===
16 KB body -> delivered, body length 16384
60 KB body -> delivered, body length 61440
desti..], byteCount=82068, last=true] in session 1655473b-76fe-4489-99d6-e3f8200b41b4. Sending STOMP ERROR to client.
org.springframework.messaging.simp.stomp.StompConversionException: The configured STOMP buffer size limit of 65536 bytes has been exceeded
80 KB body -> NOT delivered, connection dropped
desti..], byteCount=204949, last=true] in session 8dcce90c-48db-43a5-8d32-659da1fe32d8. Sending STOMP ERROR to client.
org.springframework.messaging.simp.stomp.StompConversionException: The configured STOMP buffer size limit of 65536 bytes has been exceeded
200 KB body -> NOT delivered, connection dropped
Spring's own limit, once the container is out of the way:
org.springframework.messaging.simp.stomp.StompConversionException: The configured STOMP buffer size limit of 65536 bytes has been exceeded
"ws.container.defaultMaxTextMessageBufferSize":262144
"ws.container.defaultMaxTextMessageBufferSize":8192