# `sse-websocket` — a live dashboard over SSE and a chat over STOMP Companion project for [**Server-Sent Events and WebSocket on Spring Boot 4: SseEmitter, STOMP, and Which to Pick**](https://ankurm.com/spring-boot-4-sse-websocket-stomp/) on ankurm.com. Seventeen tests against a **real embedded Tomcat**, talking over real sockets. No Docker, no broker to install, nothing but a JDK. [`./scripts/run-all.sh`](scripts/run-all.sh) regenerates everything under [`docs/output/`](docs/output/). ## Versions | | Version | Notes | |---|---|---| | JDK | 25 (Temurin 25.0.4.1+1) | current LTS | | Spring Boot | 4.1.1 | latest GA of the 4.1 line | | Spring Framework | 7.0.9 | Boot-managed | | Tomcat | 11.0.24 | Boot-managed; its WebSocket buffer is the limit that matters | | Jackson | 3.1.5 (`tools.jackson`) | why `JacksonJsonMessageConverter`, not `MappingJackson2MessageConverter` | Read from `repo1.maven.org/.../maven-metadata.xml` and from Boot's own `spring-boot-dependencies` POM, not from release announcements. ## Quickstart ```bash ./scripts/run-all.sh # every test, then regenerate docs/output/ mvn test # the same without the capture mvn spring-boot:run # then: curl -N localhost:8080/sse/metrics curl -s localhost:8080/diag # open streams, counters, converter order, container buffers ``` ## Endpoints | Path | What it is for | |---|---| | `GET /sse/metrics` | the dashboard stream; `SseEmitter(Long.MAX_VALUE)`, fed by a scheduled broadcast | | `GET /sse/raw` | three events and a close, so the wire format can be captured verbatim | | `GET /sse/payload` | the same record with and without a media type, plus the same for a `String` | | `GET /sse/silent?timeoutMs=` | one event then silence, to observe an async timeout from the client side | | `GET /sse/resume` | echoes `Last-Event-ID` and replays from it | | `GET /diag` | emitter counters, `SimpUserRegistry` count, converter order, container buffer sizes | | `GET /diag/async-timeout` | the effective async timeout, read off the live `AsyncContext` | | `WS /ws` | the STOMP endpoint | | `WS /ws-sockjs` | the SockJS fallback — a *separate* endpoint, not an option on the first | STOMP destinations: `SEND /app/chat.send` → `/topic/room`; `SEND /app/chat.echo` → `/topic/chat.echo` (no `@SendTo`, on purpose); `SEND /app/chat.whisper` → `/user/queue/whisper`. ## Profiles | Profile | Effect | |---|---| | *(none)* | Tomcat's own WebSocket buffers — 8 192 bytes | | `bigframes` | `ServletServerContainerFactoryBean` with 256 KB buffers; `-Dws.buffer-bytes=` to vary it | ## Documentation 1. [Two protocols, one question](docs/01-two-protocols.md) 2. [The SSE lifecycle, and the three ways a stream ends](docs/02-sse-lifecycle.md) 3. [The payload, and a media type that does less than you think](docs/03-the-payload.md) 4. [STOMP: four lines of configuration, three surprising defaults](docs/04-stomp.md) 5. [The limits: origin, size, and which one actually fires](docs/05-limits.md) 6. [Choosing, and when the answer is neither](docs/06-choosing.md) ## Captured output | File | What it shows | |---|---| | [`sse-wire-format.txt`](docs/output/sse-wire-format.txt) | the raw bytes of an SSE response, headers included | | [`sse-payload-conversion.txt`](docs/output/sse-payload-conversion.txt) | `data(x)` vs `data(x, APPLICATION_JSON)` for a record and a `String` | | [`sse-lifecycle.txt`](docs/output/sse-lifecycle.txt) | an async timeout as the client sees it, and how many writes a dead client absorbs | | [`async-timeout.txt`](docs/output/async-timeout.txt) | the container default and the property override, read off `AsyncContext` | | [`sse-concurrency.txt`](docs/output/sse-concurrency.txt) | 40 open streams on a 10-thread connector | | [`stomp-routing.txt`](docs/output/stomp-routing.txt) | fan-out, the default destination, the controller bypass, user destinations, presence | | [`websocket-size-limits.txt`](docs/output/websocket-size-limits.txt) | the ceiling binary-searched at four container buffer sizes | | [`handshake-origin.txt`](docs/output/handshake-origin.txt) | 101 / 101 / 403 for absent, same and foreign `Origin` | | [`tests.txt`](docs/output/tests.txt) | 17 tests | ## Six things this module exists to prove 1. **SSE does not pin a thread per client.** Forty streams stay open on a connector pool capped at ten, with zero active threads. 2. **An unset `spring.mvc.async.request-timeout` means 30 seconds on Tomcat 11.** Read off the live `AsyncContext`, not quoted. That is why dashboards reconnect every 30 s with nothing in the log. 3. **A dead SSE client is discovered by a failed write, and not by the first one.** One full serialise-and-write succeeded into a socket whose client had already gone. 4. **The media type argument to `SseEmitter.data(..)` is a filter, not a preference.** For a record it changes nothing; for a `String` it changes nothing either, because `StringHttpMessageConverter` claims the write first. 5. **A STOMP client can publish straight to `/topic/**` and skip every controller.** The timestamp the controller would have overwritten arrives untouched. 6. **Spring's 64 KB `messageSizeLimit` is never reached on a stock Tomcat.** The container's 8 192-byte buffer caps a STOMP frame at about 16 KB and closes the connection with status 1009 — no exception, no log line from Spring.