Compare commits

...
2 Commits
Author SHA1 Message Date
asmhatre 00910f2291 Add the protocol-comparison module 2026-09-04 00:52:18 +05:30
asmhatre 5224afdad2 Add the sse-websocket module 2026-09-04 00:52:18 +05:30
81 changed files with 4388 additions and 0 deletions
+7
View File
@@ -10,6 +10,8 @@ module's `scripts/run-all.sh`, never typed by hand.
| [`kafka-basics/`](kafka-basics/README.md) | [Spring Boot 4.1 and Apache Kafka: Producer, Consumer and Serialisation from Scratch](https://ankurm.com/spring-boot-4-1-kafka-producer-consumer-serialisation/) | The on-ramp: what the starter gives you, the two Jackson serializer families, where a key lands and why, and which defaults are Kafka's rather than Spring's |
| [`kafka-error-handling/`](kafka-error-handling/README.md) | [Kafka Error Handling with Spring Kafka 4.1: DLT, Retry Topics and Poison Pills](https://ankurm.com/spring-kafka-4-1-error-handling-dlt-retry-topics/) | What the default error handler really does, why a poison pill stops a partition, and what blocking retries cost that retry topics do not |
| [`rabbitmq/`](rabbitmq/README.md) | [Spring Boot and RabbitMQ: Exchanges, Queues, Bindings and a Working Dead-Letter Queue](https://ankurm.com/spring-boot-rabbitmq-exchanges-dead-letter-queue/) | All four exchange types against a real broker, manual acknowledgement, and a dead-letter path exercised through both rejection and TTL expiry |
| [`sse-websocket/`](sse-websocket/README.md) | [Server-Sent Events and WebSocket on Spring Boot 4: SseEmitter, STOMP, and Which to Pick](https://ankurm.com/spring-boot-4-sse-websocket-stomp/) | The two browser-facing options side by side: the SSE lifecycle and the three ways a stream ends, STOMP's routing defaults, and the size limit that is enforced by Tomcat rather than by Spring |
| [`protocol-comparison/`](protocol-comparison/README.md) | [RSocket vs gRPC vs WebSocket on Spring Boot 4.1: When Each One Wins](https://ankurm.com/rsocket-vs-grpc-vs-websocket-spring-boot-4-1/) | One application serving the same two operations over all three, benchmarked in one JVM, ending in the only measurement that transfers off the box: what each server produces for a consumer that asked for a hundred |
| [`broker-comparison/`](broker-comparison/README.md) | [Kafka vs RabbitMQ vs Pulsar for Java Teams: A Decision Framework with Benchmarks](https://ankurm.com/kafka-vs-rabbitmq-vs-pulsar-java-decision-framework/) | The three brokers measured side by side on ordering, replay, consumer scaling and operational footprint, ending in a decision table where every row has a transcript behind it |
The brokers make an instructive set. Kafka's consumer holds an offset and the broker remembers
@@ -19,6 +21,11 @@ choose how it is read. Almost every difference in how you handle failure follows
sentences — which is why Kafka needs a retry topic to do what RabbitMQ does with a queue
argument, and why `broker-comparison` is mostly an argument about where a message lives.
The last two modules are about the *other* kind of messaging — a connection rather than a
broker — and they are deliberately adjacent to the broker modules, because the question
"should this be a topic or a stream?" is answered by whether anything needs to survive a dropped
connection. Nothing in `sse-websocket` or `protocol-comparison` persists a single message.
They are meant to be read in order. `kafka-basics` establishes that the default acknowledgement
mode is `BATCH` and therefore that delivery is at-least-once; everything the error-handling
module does exists because of that one sentence.
+89
View File
@@ -0,0 +1,89 @@
# `protocol-comparison` — RSocket vs gRPC vs WebSocket, same use case, measured
Companion project for
[**RSocket vs gRPC vs WebSocket on Spring Boot 4.1: When Each One Wins**](https://ankurm.com/rsocket-vs-grpc-vs-websocket-spring-boot-4-1/)
on ankurm.com.
**One application serves the same two operations over all three protocols.** Same JVM, same heap,
same data generator, same five fields — so a difference in the numbers is a difference in the
transports rather than in three separately-written demos.
[`./scripts/run-all.sh`](scripts/run-all.sh) re-measures 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 |
| grpc-java | **1.83.1** | Boot-managed. Boot 4.1.0 pinned 1.80.0 — it moved |
| protobuf-java | **4.35.1** | Boot-managed. Boot 4.1.0 pinned 4.34.2 |
| rsocket-java | 1.1.5 | Boot-managed |
| Spring gRPC | 1.1.1 | the programming model; separate version from Boot's |
| Jackson | 3.1.5 (`tools.jackson`) | plus `jackson-dataformat-cbor`, RSocket's default |
Read from `repo1.maven.org/.../maven-metadata.xml` and Boot's own `spring-boot-dependencies` POM.
## Quickstart
```bash
./scripts/run-all.sh # five tests, then re-measure docs/output/
mvn test # the same without the capture
mvn spring-boot:run # gRPC 9090, RSocket 7000, WebSocket ws://localhost:8080/quotes
```
Nothing to install: `protoc` and the gRPC codegen plugin resolve as Maven artifacts for your OS
and architecture, and all three servers start inside the test JVM.
## The operations
| | gRPC | RSocket route | WebSocket command |
|---|---|---|---|
| one quote | `QuoteService/GetQuote` | `quote` | `QUOTE <symbol>` |
| N quotes | `QuoteService/StreamQuotes` | `quotes` | `STREAM <symbol> <n> <delayMs>` |
| unbounded | `QuoteService/StreamUnbounded` | `quotes.unbounded` | `UNBOUNDED <symbol>` |
| same, `isReady()`-gated | `QuoteService/StreamUnboundedReady` | — | — |
| server-side produced count | `QuoteService/Produced` | `produced` | `PRODUCED` |
## Documentation
1. [The same use case, three ways](docs/01-three-protocols.md)
2. [How they measure — and why a loopback benchmark flatters the wrong one](docs/02-benchmarks.md)
3. [Payload: the one comparison that needs no benchmark](docs/03-payload.md)
4. [Back-pressure: the measurement that decides it](docs/04-backpressure.md)
5. [Choosing](docs/05-choosing.md)
## Captured output
| File | What it shows |
|---|---|
| [`payload-sizes.txt`](docs/output/payload-sizes.txt) | one message as protobuf, JSON and CBOR, in hex |
| [`request-response.txt`](docs/output/request-response.txt) | 5 000 sequential calls, p50/p99/mean per protocol |
| [`stream-throughput.txt`](docs/output/stream-throughput.txt) | 50 000 messages on one connection |
| [`backpressure.txt`](docs/output/backpressure.txt) | how many the server produced for a client that wanted 100 |
| [`grpc-isready.txt`](docs/output/grpc-isready.txt) | the documented gRPC fix, tested — and it did not work here |
| [`tests.txt`](docs/output/tests.txt) | 5 tests |
## Read the benchmark honestly
Client and server are the same process on one loopback interface, two cores. **There is no
network.** That deletes protobuf's size advantage, and it flatters whichever protocol does the
least work per message — which is why raw WebSocket wins both speed benchmarks and why that is
not the recommendation. Treat the ratios as indicative and the absolutes as meaningless off this
box.
The one result that *does* transfer is the back-pressure table, because it is a property of the
protocols rather than of the link.
## Four things this module exists to prove
1. **RSocket's `request(n)` is real and the others have no equivalent.** For a client that took
100 messages then went quiet for two seconds, the servers produced **100**, **1 346 233** and
**311 617** respectively.
2. **`ServerCallStreamObserver.isReady()` did not bound a gRPC producer here.** The documented fix
was tested; the transcript is committed as measured, including its instability.
3. **Raw WebSocket is the fastest and has the worst tail.** p50 of 124 µs against a p99 of
1 448 µs — a 12x spread, where gRPC's is 3.5x.
4. **CBOR keeps the field names.** It is binary JSON, not a schema format: 67 bytes against
protobuf's 35 and JSON's 83, and the difference is entirely the punctuation.
@@ -0,0 +1,83 @@
# 1. The same use case, three ways
Next: [2. How they measure](02-benchmarks.md)
---
One application in this module serves the same two operations over gRPC, RSocket and a raw
WebSocket: fetch one quote, and stream N quotes. Same JVM, same heap, same
[`QuoteSource`](../src/main/java/com/ankurm/protocols/QuoteSource.java), same five fields.
That constraint is the point. Cross-protocol benchmarks published on the internet almost always
compare three different applications, and end up measuring three different serialisation
libraries, three JIT states and three thread pools.
## What each one is, in one sentence
| | gRPC | RSocket | Raw WebSocket |
|---|---|---|---|
| Transport | HTTP/2 | TCP, WebSocket, or others | HTTP/1.1 Upgrade |
| Schema | `.proto`, mandatory, code-generated | none required | none |
| Encoding here | protobuf | CBOR (Spring's default) | JSON |
| Interaction models | 4, declared in the `.proto` | 4, chosen by return type | whatever you invent |
| Demand signalling | none in the API | `request(n)` on the wire | none at all |
| Deadlines | per call, absolute, propagating | none built in | none |
| Browser client | needs grpc-web + a proxy | yes, over the WebSocket transport | yes, natively |
| Boot 4 starters | `spring-boot-starter-grpc-server` / `-client` | `spring-boot-starter-rsocket` | `spring-boot-starter-websocket` |
## The three server sides, side by side
**gRPC** ([`GrpcQuoteService`](../src/main/java/com/ankurm/protocols/GrpcQuoteService.java)) is
generated-class inheritance. There is no registration code: the generated `ImplBase` is a
`BindableService` and Boot registers every such bean.
```java
@Override
public void getQuote(QuoteRequest request, StreamObserver<Quote> observer) {
observer.onNext(toProto(source.at(request.getSymbol(), 0)));
observer.onCompleted();
}
```
**RSocket** ([`RSocketQuoteController`](../src/main/java/com/ankurm/protocols/RSocketQuoteController.java))
is annotation routing, and the interaction model *is the return type*:
```java
@MessageMapping("quote")
public Mono<Quote> quote(String symbol) { ... } // request/response
@MessageMapping("quotes")
public Flux<Quote> quotes(StreamSpec spec) { ... } // request/stream
```
**Raw WebSocket** ([`WebSocketQuoteHandler`](../src/main/java/com/ankurm/protocols/WebSocketQuoteHandler.java))
is a `switch` on a string, because there is nothing else:
```java
switch (parts[0]) {
case "QUOTE" -> send(session, source.at(parts[1], 0));
case "STREAM" -> { for (int i = 0; i < count; i++) { send(...); } }
...
}
```
That third block is the honest summary of what a raw WebSocket gives you: a bidirectional pipe
for text or bytes. Everything above it &mdash; routing, correlation, errors, versioning, demand
&mdash; is yours to invent, and inventing it is how teams end up with a private, undocumented
protocol that only its authors can debug. STOMP exists to stop that, and is covered in
[`../sse-websocket`](../../sse-websocket/README.md).
## Boot 4 version notes
Boot 4.1.1's BOM pins **grpc-java 1.83.1** and **protobuf-java 4.35.1**, both deliberately older
than the newest releases on Maven Central. Overriding them independently is how you get a
`NoSuchMethodError` between grpc-java and its shaded Netty. Note that these have moved since Boot
4.1.0, which pinned 1.80.0 and 4.34.2 &mdash; so a `spring.grpc.*` guide written against 4.1.0 is
already describing different jars.
RSocket is **rsocket-java 1.1.5**, and `spring-boot-starter-rsocket` brings
`jackson-dataformat-cbor` on purpose: CBOR, not JSON, is Spring's default RSocket data mime type.
---
Next: [2. How they measure](02-benchmarks.md)
+69
View File
@@ -0,0 +1,69 @@
# 2. How they measure
Previous: [1. Three protocols](01-three-protocols.md) &middot; Next: [3. Payload](03-payload.md)
---
## Read this before the numbers
Client and server are **the same process on one machine**, talking over loopback: two cores,
3.9&nbsp;GB, JDK 25, Boot 4.1.1. That has three consequences, and ignoring them makes the numbers
say the opposite of the truth.
1. **There is no network.** On a real link, a 35-byte message and an 83-byte message differ by
more than the framing cost, and protobuf's size advantage starts paying. Here it does not.
2. **A protocol that does less looks faster.** WebSocket wins both benchmarks below partly because
it has no flow control, no deadlines, no schema and no per-call metadata. That is a real cost
difference, and it is also exactly what you give up.
3. **Ratios, not absolutes.** Absolute microsecond figures from a two-core sandbox mean nothing
for your hardware. The ordering and the rough factors are what transfer.
Re-measured on every `./scripts/run-all.sh`.
## Request/response
5&nbsp;000 sequential calls after 2&nbsp;000 warm-up
([`request-response.txt`](output/request-response.txt)):
```
gRPC n=5000 p50= 291.3 us p99= 1015.3 us mean= 324.4 us ~3,083 calls/s
RSocket n=5000 p50= 300.9 us p99= 1337.4 us mean= 350.9 us ~2,850 calls/s
WebSocket n=5000 p50= 124.3 us p99= 1447.8 us mean= 172.8 us ~5,786 calls/s
```
**gRPC and RSocket are the same speed.** Within noise across runs the two swap places; treat them
as indistinguishable for request/response on a warm connection.
**Raw WebSocket is about twice as fast at the median &mdash; and has the worst tail.** p50 of
124&nbsp;&micro;s against a p99 of 1&nbsp;448&nbsp;&micro;s is a 12x spread. gRPC's is 3.5x. The
median is cheap because a text frame over an already-open socket is nearly free; the tail is bad
because nothing is scheduling anything, so a GC pause or a scheduling hiccup lands on the request
undiluted.
If your SLO is a percentile rather than an average &mdash; and it should be &mdash; that table
does not say what a first glance suggests.
## Server streaming
50&nbsp;000 messages on one connection
([`stream-throughput.txt`](output/stream-throughput.txt)):
```
gRPC 50,000 msgs in 1.116 s = 44,801 msgs/s ( 22.32 us each)
RSocket 50,000 msgs in 0.702 s = 71,175 msgs/s ( 14.05 us each)
WebSocket 50,000 msgs in 0.459 s = 109,022 msgs/s ( 9.17 us each)
```
Same ordering, wider gaps: WebSocket about 2.4x gRPC, RSocket about 1.6x.
Now read the WebSocket row again with its harness in view. The client's inbox is an
`ArrayBlockingQueue` sized to hold **all fifty thousand messages**, because there was nothing else
to do with them &mdash; a raw WebSocket consumer that falls behind can buffer or drop, and those
are the only two options. WebSocket "won" this benchmark by buffering the entire stream in the
client's heap.
That is not a rhetorical point. It is the next chapter.
---
Previous: [1. Three protocols](01-three-protocols.md) &middot; Next: [3. Payload](03-payload.md)
+44
View File
@@ -0,0 +1,44 @@
# 3. Payload: the one comparison that needs no benchmark
Previous: [2. Benchmarks](02-benchmarks.md) &middot; Next: [4. Back-pressure](04-backpressure.md)
---
Five fields &mdash; a symbol, a sequence number, two doubles and a timestamp &mdash; encoded three
ways ([`payload-sizes.txt`](output/payload-sizes.txt)):
```
protobuf : 35 bytes 0a044141504c102a197b14ae47e11a5940215c8fc2f5281c5940288080abb4fef39203
JSON : 83 bytes {"symbol":"AAPL","seq":42,"bid":100.42,"ask":100.44,"epochMicros":1772000000000000}
CBOR : 67 bytes bf6673796d626f6c644141504c63736571182a63626964fb40591ae147ae147b6361736bfb40591c28f5c28f5c6b65706f63684d6963726f731b00064b9fe68ac000ff
JSON is 2.37x protobuf; CBOR is 1.91x protobuf
```
This is a property of the formats, not of a machine, so it is the one row of the comparison that
transfers to your hardware unchanged.
**Protobuf is 35 bytes because field names are integers.** `0a04 41 41 50 4c` is field 1, length
4, `AAPL`. There is no `"symbol"` on the wire at all &mdash; the name lives in the `.proto` file
that both sides compiled against. That is where the size comes from, and it is also the whole
argument about schemas: the saving and the coupling are the same fact.
**CBOR is 67 bytes and still writes the field names.** `6673796d626f6c` is a 6-character text
string, `symbol`. CBOR is binary JSON, not a schema format: it saves the punctuation and encodes
numbers compactly, and it keeps every key. So its advantage over JSON is real but modest, and it
does not require the coupling.
Concretely, on a stream of a million quotes: 35&nbsp;MB, 67&nbsp;MB, 83&nbsp;MB. On a loopback
that difference disappears into memory bandwidth &mdash; which is exactly why the benchmarks in
[chapter 2](02-benchmarks.md) do not reward it and a real link would.
Two practical notes:
- **Spring's RSocket default is CBOR**, not JSON, which is why
`spring-boot-starter-rsocket` brings `jackson-dataformat-cbor`. If you are debugging with
`tcpdump` and expecting to read your payloads, that is why you cannot.
- **You can put protobuf on RSocket.** The encoding and the protocol are independent choices;
the row above is what each stack does *by default*, not what it is capable of.
---
Previous: [2. Benchmarks](02-benchmarks.md) &middot; Next: [4. Back-pressure](04-backpressure.md)
+109
View File
@@ -0,0 +1,109 @@
# 4. Back-pressure: the measurement that decides it
Previous: [3. Payload](03-payload.md) &middot; Next: [5. Choosing](05-choosing.md)
---
Every protocol comparison eventually reduces to one question, and it is not throughput. It is:
> **When the consumer stops keeping up, what happens?**
[`BackPressureTest`](../src/test/java/com/ankurm/protocols/BackPressureTest.java) asks it the
simplest way there is. Each server offers an unbounded stream. Each client takes a hundred
messages and then does nothing. How many did the server produce?
The sweep over quiet periods is what makes the answer conclusive. A number that stays flat as the
wait grows means something bounded the producer. A number that keeps climbing means nothing did.
[`backpressure.txt`](output/backpressure.txt):
```
protocol quiet 0.5s quiet 1s quiet 2s
RSocket 100 100 100
gRPC 142,000 680,542 1,346,233
WebSocket 23,693 125,099 311,617
```
RSocket produced **exactly one hundred**, at every quiet period. gRPC produced over a million in
two seconds. Raw WebSocket, three hundred thousand.
(The growth is faster than linear because the loop is still being JIT-compiled during the shortest
run. The shape is the finding, not the exponent.)
## Why RSocket stops
`request(n)` is a **frame on the wire**. When the client's subscriber calls `subscription.request(100)`,
a REQUEST_N frame carries that number to the server, and Reactor's `Flux.generate` is called
exactly a hundred times and then not again. Nothing blocks; there is no buffer to size and no
thread parked. The producer simply is not invoked.
That is what "Reactive Streams over the network" means, and it is the entire reason RSocket
exists. The same property is why RSocket's streaming throughput in
[chapter 2](02-benchmarks.md) is respectable rather than the highest: it is doing real work per
batch that the others are not doing at all.
## Why the other two do not
**Raw WebSocket has no mechanism.** The frame protocol has no notion of demand. The server writes
until something underneath refuses &mdash; a socket buffer, Spring's `sendBufferSizeLimit`, the
heap. Every one of those 311&nbsp;617 messages was serialised and written for a client that had
asked for a hundred.
**gRPC has HTTP/2 flow control, and it did not help.** This is the result I did not expect.
`StreamObserver.onNext` on a gRPC server **does not block**, so a plain server-streaming loop keeps
producing regardless of what the client is doing.
The documented answer is `ServerCallStreamObserver.isReady()`, and this module tests it rather than
citing it ([`grpc-isready.txt`](output/grpc-isready.txt)):
```
handler quiet 0.5s quiet 2s
plain onNext loop 353,996 1,883,443
isReady() checked before write 1,285,787 63,859
```
**It did not bound the producer, and it was not even stable** &mdash; the same handler produced
1.3&nbsp;million in half a second on one row and 64&nbsp;thousand in two seconds on the next. I
have not established whether the surplus accumulates in the server's outbound queue, in the client
transport, or is discarded after the deframer, and I am not going to assert a mechanism I have not
read the source for.
What the numbers do establish is narrower and still worth knowing: **`isReady()` is not a demand
signal**, and on a fast link with a stalled consumer it does not behave like one. If you are
streaming from gRPC to a consumer that can fall behind, the bound has to come from your own
design &mdash; a paged API, an explicit "send me more" message on a bidirectional stream, or a
`count` in the request as `StreamQuotes` uses.
## The honest caveat
A loopback connection is the worst case for this test in the fairest possible way: the wire drains
instantly, so nothing downstream ever pushes back. On a slow real link, TCP and the HTTP/2 window
*would* eventually stop the gRPC and WebSocket producers &mdash; by blocking a thread or by
filling a buffer, but they would stop.
That does not rescue them, because the failure mode you actually meet is not "the network is
slow", it is "one consumer of forty is slow", and a shared server discovers that as latency for
everyone else or as a heap that will not shrink. The difference RSocket makes is that the slow
consumer's slowness is *expressed*, in a number, that the producer can act on before anything
fills up.
## The other thing gRPC does not do for you
Abandoning a gRPC stream produced this, repeatedly, in the server log:
```
java.lang.IllegalStateException: Failed to close the call
Caused by: java.lang.IllegalStateException: call already closed
```
That is the cancellation story from
[the Spring gRPC article](https://ankurm.com/spring-grpc-spring-boot-4/) showing up in practice:
gRPC does not interrupt your thread when a client leaves, it sets a flag on the `Context`, and a
handler that touches an already-closed stream throws. The loop in
[`GrpcQuoteService`](../src/main/java/com/ankurm/protocols/GrpcQuoteService.java) checks
`Context.current().isCancelled()` and returns without calling `onCompleted` for exactly this
reason.
---
Previous: [3. Payload](03-payload.md) &middot; Next: [5. Choosing](05-choosing.md)
+63
View File
@@ -0,0 +1,63 @@
# 5. Choosing
Previous: [4. Back-pressure](04-backpressure.md)
---
## The decision table
| If | Pick | Because |
|---|---|---|
| Service-to-service RPC, polyglot team, you want a schema | **gRPC** | The `.proto` is a contract other languages generate from. Deadlines are absolute and propagate. Boot 4 makes it a first-class starter. |
| A stream whose consumer can fall behind | **RSocket** | `request(n)` is the only demand signal that crosses the wire. 100 vs 1.3 million is not a tuning difference. |
| A browser is one of the peers | **WebSocket**, with STOMP on it | gRPC needs grpc-web and a proxy. RSocket over WebSocket works but the client ecosystem is thin. |
| Server pushes, client never speaks | **none of these** — use SSE | See [`../sse-websocket`](../../sse-websocket/README.md). |
| You want the lowest possible median latency and control both ends | **raw WebSocket** | It is fastest here because it does the least. Read the p99 before deciding that is what you want. |
| Long-lived bidirectional exchange with independent streams | **RSocket channel** or **gRPC bidi** | Both give you two independent streams; only RSocket gives each of them demand. |
## What each one really costs
**gRPC** costs a build step, a schema you must version, and an operational story for HTTP/2 through
your proxies. In exchange you get the best-supported cross-language RPC there is, and deadlines
&mdash; which is a bigger deal than it sounds, because an absolute deadline that propagates across
hops is the single most effective defence against one slow dependency taking down a system.
**RSocket** costs ecosystem. It is the least deployed of the three, the client libraries outside
Java and JavaScript are thin, and if you are not already reactive, `Mono` and `Flux` in your
service signatures is a real commitment. In exchange you get the only demand signalling in the
list, plus resumption and leasing.
**Raw WebSocket** costs everything you then invent: routing, correlation, error shape, versioning,
and the demand mechanism you will eventually need. That is the cost STOMP exists to prevent, and
if you find yourself designing a text protocol with a command word at the front &mdash; as
[`WebSocketQuoteHandler`](../src/main/java/com/ankurm/protocols/WebSocketQuoteHandler.java)
deliberately does &mdash; that is the signal to stop and use one.
## Should you use any of them?
Three cases where the answer is no:
- **Request/response between two services that both speak HTTP already.** A REST endpoint with a
`RestClient` and a timeout is fine, and everything in your organisation already knows how to
observe, cache, proxy and debug it. Reach for gRPC when the schema or the deadline propagation
is the thing you need, not because it benchmarks faster.
- **Streaming that is really batching.** If the consumer processes a page at a time anyway,
paginated HTTP has back-pressure for free: the consumer asks for the next page when it is ready.
That is `request(n)` with a URL.
- **Events that outlive the connection.** None of these three persist anything. A dropped
connection loses the messages in flight. If that matters, the answer is a broker &mdash;
[`../kafka-basics`](../../kafka-basics/README.md),
[`../rabbitmq`](../../rabbitmq/README.md), and the comparison in
[`../broker-comparison`](../../broker-comparison/README.md).
## And the benchmark caveat, one more time
Every number in this module was measured with client and server in one JVM on one machine. That
deletes the network, which is where protobuf's 35 bytes would pay and where the tail latencies
would look completely different. The measurement that does transfer is
[chapter 4](04-backpressure.md), because "how many did the server produce for a consumer that
wanted a hundred" is a property of the protocol and not of the link.
---
Previous: [4. Back-pressure](04-backpressure.md)
@@ -0,0 +1,10 @@
=== unbounded stream; client takes 100, then goes quiet ===
protocol quiet 0.5s quiet 1s quiet 2s
RSocket 100 100 100
gRPC 142,000 680,542 1,346,233
WebSocket 23,693 125,099 311,617
RSocket is flat: the server produced exactly what request(n) asked for.
gRPC grows 9.5x between 0.5 s and 2 s; WebSocket grows 13.2x.
Growth that tracks the wait, rather than settling at a buffer size,
means nothing bounded the producer. (Growth is faster than linear
because the loop is still being JIT-compiled during the first run.)
@@ -0,0 +1,4 @@
=== gRPC server streaming, client takes 100 then stops ===
handler quiet 0.5s quiet 2s
plain onNext loop 353,996 1,883,443
isReady() checked before write 1,285,787 63,859
@@ -0,0 +1,5 @@
=== one Quote, five fields, three encodings ===
protobuf : 35 bytes 0a044141504c102a197b14ae47e11a5940215c8fc2f5281c5940288080abb4fef39203
JSON : 83 bytes {"symbol":"AAPL","seq":42,"bid":100.42,"ask":100.44,"epochMicros":1772000000000000}
CBOR : 67 bytes bf6673796d626f6c644141504c63736571182a63626964fb40591ae147ae147b6361736bfb40591c28f5c28f5c6b65706f63684d6963726f731b00064b9fe68ac000ff
JSON is 2.37x protobuf; CBOR is 1.91x protobuf
@@ -0,0 +1,4 @@
=== request/response, 5000 sequential calls after 2000 warm-up, loopback, JDK 25.0.4.1 ===
gRPC n=5000 p50= 291.3 us p99= 1015.3 us mean= 324.4 us ~3,083 calls/s
RSocket n=5000 p50= 300.9 us p99= 1337.4 us mean= 350.9 us ~2,850 calls/s
WebSocket n=5000 p50= 124.3 us p99= 1447.8 us mean= 172.8 us ~5,786 calls/s
@@ -0,0 +1,4 @@
=== server streaming, 50000 messages on one connection, loopback ===
gRPC 50,000 msgs in 1.116 s = 44,801 msgs/s ( 22.32 us each)
RSocket 50,000 msgs in 0.702 s = 71,175 msgs/s ( 14.05 us each)
WebSocket 50,000 msgs in 0.459 s = 109,022 msgs/s ( 9.17 us each)
@@ -0,0 +1,5 @@
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.301 s -- in com.ankurm.protocols.PayloadSizeTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.84 s -- in com.ankurm.protocols.RequestResponseBenchmarkTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.31 s -- in com.ankurm.protocols.BackPressureTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 26.70 s -- in com.ankurm.protocols.GrpcIsReadyTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.055 s -- in com.ankurm.protocols.StreamThroughputBenchmarkTest
+95
View File
@@ -0,0 +1,95 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>protocol-comparison</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- One application speaks all three protocols, so the comparison is not confounded by
different JVMs, different heaps or different warm-up states. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- RSocket. The starter drags in reactor-netty and jackson-dataformat-cbor; CBOR is not
an accident, it is RSocket's default data mime type in Spring. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-rsocket</artifactId>
</dependency>
<!-- gRPC. Two starters, both Boot-managed. See ankurm.com/spring-grpc-spring-boot-4/ -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-grpc-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-grpc-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- protoc and the gRPC codegen plugin resolve as Maven artifacts for this OS and
architecture. Nothing to install locally. Both versions come from the Boot BOM;
overriding them independently is how you get a NoSuchMethodError between grpc-java
and its shaded Netty. -->
<plugin>
<groupId>io.github.ascopes</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>3.9.0</version>
<configuration>
<protocVersion>${protobuf-java.version}</protocVersion>
<binaryMavenPlugins>
<binaryMavenPlugin>
<groupId>io.grpc</groupId>
<artifactId>protoc-gen-grpc-java</artifactId>
<version>${grpc-java.version}</version>
</binaryMavenPlugin>
</binaryMavenPlugins>
</configuration>
<executions>
<execution><goals><goal>generate</goal></goals></execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Regenerate every file under docs/output/. Needs a JDK and nothing else: protoc and the gRPC
# codegen plugin resolve as Maven artifacts, and all three servers start inside the test JVM.
#
# The benchmark numbers are NOT deterministic. They are re-measured on every run and the ratios
# between the three protocols are what the documentation quotes.
set -eu
cd "$(dirname "$0")/.."
LOG=/tmp/protocol-comparison-test.log
MAVEN_OPTS=${MAVEN_OPTS:--Xmx900m} mvn -B test > "$LOG" 2>&1
# Interleaved log lines from other contexts have to come out, or the transcripts are unreadable.
clean() { grep -vE '^2026-|^\s*$|Initializing|Completed initialization'; }
sed -n '/=== one Quote, five fields/,/^JSON is /p' "$LOG" | clean > docs/output/payload-sizes.txt
sed -n '/=== request\/response/,/^WebSocket /p' "$LOG" | clean > docs/output/request-response.txt
sed -n '/=== server streaming, 50000/,/^WebSocket /p' "$LOG" | clean > docs/output/stream-throughput.txt
{ grep -m1 '=== unbounded stream' "$LOG"; grep -m1 '^protocol ' "$LOG"
grep -m1 '^RSocket *[0-9]' "$LOG"; grep -m1 '^gRPC *[0-9]' "$LOG"
grep -m1 '^WebSocket *[0-9]' "$LOG"
sed -n '/^RSocket is flat/,/JIT-compiled/p' "$LOG"; } > docs/output/backpressure.txt
{ grep -m1 '=== gRPC server streaming' "$LOG"; grep -m1 '^handler ' "$LOG"
grep -m1 '^plain onNext loop' "$LOG"; grep -m1 '^isReady() checked' "$LOG"; } \
> docs/output/grpc-isready.txt
grep -E 'Tests run:.*in com\.ankurm' "$LOG" | sed 's/^\[INFO\] //' > docs/output/tests.txt
echo "regenerated:"; ls -1 docs/output/
@@ -0,0 +1,121 @@
package com.ankurm.protocols;
import com.ankurm.protocols.grpc.Quote;
import com.ankurm.protocols.grpc.QuoteRequest;
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
import com.ankurm.protocols.grpc.StreamRequest;
import io.grpc.Context;
import io.grpc.stub.ServerCallStreamObserver;
import io.grpc.stub.StreamObserver;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Service;
/**
* The gRPC side. No registration code: the generated {@code ImplBase} is a
* {@code BindableService} and Boot registers every such bean with the server.
*
* <p>The cancellation check in {@link #streamQuotes} is not decoration. gRPC does not interrupt
* your thread when a client goes away &mdash; it sets a flag on the {@link Context} &mdash; so a
* loop that never looks keeps producing into a stream nobody is reading. That is covered at
* length in <a href="https://ankurm.com/spring-grpc-spring-boot-4/">the Spring gRPC article</a>;
* here it matters because the back-pressure benchmark deliberately abandons a stream.
*
* @see <a href="../../../../../docs/04-backpressure.md">docs/04-backpressure.md</a>
*/
@Service
public class GrpcQuoteService extends QuoteServiceGrpc.QuoteServiceImplBase {
/** Counts what the SERVER produced, comparable with the RSocket and WebSocket counters. */
static final AtomicLong PRODUCED = new AtomicLong();
private final QuoteSource source;
GrpcQuoteService(QuoteSource source) {
this.source = source;
}
@Override
public void getQuote(QuoteRequest request, StreamObserver<Quote> observer) {
observer.onNext(toProto(source.at(request.getSymbol(), 0)));
observer.onCompleted();
}
/**
* An effectively unbounded stream, for the back-pressure comparison. A consumer that stops
* calling {@code next()} closes the HTTP/2 flow-control window, and this loop blocks inside
* {@code onNext} &mdash; so the server stops producing, but it stops by blocking a thread
* rather than by being asked to stop.
*/
@Override
public void streamUnbounded(QuoteRequest request, StreamObserver<Quote> observer) {
PRODUCED.set(0);
while (!Context.current().isCancelled()) {
observer.onNext(toProto(source.at(request.getSymbol(), PRODUCED.get())));
PRODUCED.incrementAndGet();
}
}
/**
* The fix, and the reason the unfixed version is worth measuring.
*
* <p>{@code StreamObserver.onNext} on a gRPC server <strong>never blocks</strong>. If the
* client is not reading, the message is queued in the server's outbound buffer and the loop
* keeps going &mdash; HTTP/2 flow control governs the wire, not your code. The only thing
* that connects the two is {@link ServerCallStreamObserver#isReady()}, and using it means
* restructuring the handler around {@code setOnReadyHandler} rather than writing a loop.
*
* <p>The spin here is deliberately the simplest possible demonstration rather than the
* shape you would ship; a real implementation registers an on-ready handler and returns.
*/
@Override
public void streamUnboundedReady(QuoteRequest request, StreamObserver<Quote> observer) {
PRODUCED.set(0);
ServerCallStreamObserver<Quote> ready = (ServerCallStreamObserver<Quote>) observer;
while (!ready.isCancelled()) {
if (!ready.isReady()) {
Thread.onSpinWait();
continue;
}
ready.onNext(toProto(source.at(request.getSymbol(), PRODUCED.get())));
PRODUCED.incrementAndGet();
}
}
@Override
public void produced(QuoteRequest request, StreamObserver<com.ankurm.protocols.grpc.ProducedCount> observer) {
observer.onNext(com.ankurm.protocols.grpc.ProducedCount.newBuilder()
.setCount(PRODUCED.get()).build());
observer.onCompleted();
}
@Override
public void streamQuotes(StreamRequest request, StreamObserver<Quote> observer) {
for (int i = 0; i < request.getCount(); i++) {
if (Context.current().isCancelled()) {
// Do NOT call onCompleted/onError here: the stream is already closed and
// touching it throws IllegalStateException. Just return.
return;
}
observer.onNext(toProto(source.at(request.getSymbol(), i)));
if (request.getDelayMs() > 0) {
try {
Thread.sleep(request.getDelayMs());
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return;
}
}
}
observer.onCompleted();
}
static Quote toProto(com.ankurm.protocols.Quote q) {
return Quote.newBuilder()
.setSymbol(q.symbol()).setSeq(q.seq())
.setBid(q.bid()).setAsk(q.ask())
.setEpochMicros(q.epochMicros())
.build();
}
}
@@ -0,0 +1,22 @@
package com.ankurm.protocols;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* One application, three protocols, one set of data.
*
* <p>gRPC on 9090, RSocket on 7000, WebSocket on the servlet port. Serving the same two
* operations from the same JVM is the only way to make a benchmark mean anything: the heap, the
* JIT state, the CPU and the data generator are shared, so a difference in the numbers is a
* difference in the transports.
*
* @see <a href="../../../../../docs/01-three-protocols.md">docs/01-three-protocols.md</a>
*/
@SpringBootApplication
public class ProtocolComparisonApplication {
public static void main(String[] args) {
SpringApplication.run(ProtocolComparisonApplication.class, args);
}
}
@@ -0,0 +1,8 @@
package com.ankurm.protocols;
/**
* The JSON/CBOR shape, field-for-field identical to the protobuf {@code Quote} message, so the
* encoded sizes in {@code docs/output/payload-sizes.txt} compare like with like.
*/
public record Quote(String symbol, long seq, double bid, double ask, long epochMicros) {
}
@@ -0,0 +1,22 @@
package com.ankurm.protocols;
import java.time.Instant;
import org.springframework.stereotype.Component;
/**
* Deterministic quote generation, shared by all three protocol adapters.
*
* <p>Deliberately cheap and deliberately allocation-free apart from the record: a benchmark of
* three transports must not spend its time in the thing behind them.
*/
@Component
public class QuoteSource {
public Quote at(String symbol, long seq) {
double base = 100.0 + (seq % 97) * 0.01;
Instant now = Instant.now();
return new Quote(symbol, seq, base, base + 0.02,
now.getEpochSecond() * 1_000_000L + now.getNano() / 1_000L);
}
}
@@ -0,0 +1,72 @@
package com.ankurm.protocols;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicLong;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* The RSocket side. The same two operations, and one extra that exists only to make
* back-pressure visible.
*
* <p>Note what the signatures say that the other two protocols' cannot: {@code Mono} means
* request/response, {@code Flux} means request/stream, and the choice of interaction model is
* the return type rather than a separate declaration. RSocket has four (fire-and-forget,
* request/response, request/stream, channel) and Spring picks from the method shape.
*
* @see <a href="../../../../../docs/04-backpressure.md">docs/04-backpressure.md</a>
*/
@Controller
public class RSocketQuoteController {
private static final Logger log = LoggerFactory.getLogger(RSocketQuoteController.class);
/** Counts what the SERVER produced, which is the number the back-pressure test cares about. */
static final AtomicLong PRODUCED = new AtomicLong();
private final QuoteSource source;
RSocketQuoteController(QuoteSource source) {
this.source = source;
}
@MessageMapping("quote")
public Mono<Quote> quote(String symbol) {
return Mono.just(source.at(symbol, 0));
}
@MessageMapping("quotes")
public Flux<Quote> quotes(StreamSpec spec) {
Flux<Quote> flux = Flux.range(0, spec.count()).map(i -> source.at(spec.symbol(), i));
return spec.delayMs() > 0 ? flux.delayElements(Duration.ofMillis(spec.delayMs())) : flux;
}
/**
* An unbounded generator. It emits only when the transport asks, so the count it reaches is
* a direct read-out of how much the consumer requested &mdash; which is the whole argument
* for RSocket.
*/
@MessageMapping("quotes.unbounded")
public Flux<Quote> unbounded(String symbol) {
PRODUCED.set(0);
return Flux.generate(() -> 0L, (seq, sink) -> {
sink.next(source.at(symbol, seq));
PRODUCED.incrementAndGet();
return seq + 1;
});
}
@MessageMapping("produced")
public Mono<Long> produced() {
return Mono.just(PRODUCED.get());
}
/** Request payload for {@code quotes}. */
public record StreamSpec(String symbol, int count, int delayMs) {
}
}
@@ -0,0 +1,27 @@
package com.ankurm.protocols;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
/**
* {@code @EnableWebSocket}, not {@code @EnableWebSocketMessageBroker}: this module wants the raw
* transport, with no broker in the path, so the benchmark measures a socket rather than a routing
* layer. The {@code sse-websocket} module in this repository is the STOMP version.
*/
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final WebSocketQuoteHandler handler;
WebSocketConfig(WebSocketQuoteHandler handler) {
this.handler = handler;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(handler, "/quotes").setAllowedOriginPatterns("*");
}
}
@@ -0,0 +1,87 @@
package com.ankurm.protocols;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import tools.jackson.databind.ObjectMapper;
/**
* The WebSocket side, with no STOMP and no framework on top: a two-word text protocol, so the
* benchmark measures the transport and not a broker.
*
* <pre>
* QUOTE &lt;symbol&gt; -&gt; one JSON quote
* STREAM &lt;symbol&gt; &lt;count&gt; &lt;delayMs&gt; -&gt; count JSON quotes, then "END"
* UNBOUNDED &lt;symbol&gt; -&gt; quotes until the session dies
* </pre>
*
* <p>The absence worth noticing is in {@code UNBOUNDED}: there is nowhere for a consumer to say
* how many it wants. {@code sendMessage} either succeeds, blocks, or eventually throws when
* Spring's send buffer limit is exceeded. That is not a gap in this handler &mdash; it is what a
* raw WebSocket offers.
*
* @see <a href="../../../../../docs/04-backpressure.md">docs/04-backpressure.md</a>
*/
@Component
public class WebSocketQuoteHandler extends TextWebSocketHandler {
/** Counts what the SERVER wrote, comparable with RSocket's PRODUCED. */
static final AtomicLong PRODUCED = new AtomicLong();
private final QuoteSource source;
private final ObjectMapper mapper;
WebSocketQuoteHandler(QuoteSource source, ObjectMapper mapper) {
this.source = source;
this.mapper = mapper;
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String[] parts = message.getPayload().trim().split("\\s+");
switch (parts[0]) {
case "QUOTE" -> send(session, source.at(parts[1], 0));
case "STREAM" -> {
int count = Integer.parseInt(parts[2]);
int delay = parts.length > 3 ? Integer.parseInt(parts[3]) : 0;
for (int i = 0; i < count; i++) {
send(session, source.at(parts[1], i));
if (delay > 0) {
Thread.sleep(delay);
}
}
session.sendMessage(new TextMessage("END"));
}
case "UNBOUNDED" -> {
PRODUCED.set(0);
// No requestN. Nothing here can be told to slow down: the loop writes until the
// socket, the send buffer or the send-time limit stops it.
while (session.isOpen()) {
send(session, source.at(parts[1], PRODUCED.get()));
PRODUCED.incrementAndGet();
}
}
case "PRODUCED" -> session.sendMessage(new TextMessage(Long.toString(PRODUCED.get())));
default -> session.sendMessage(new TextMessage("ERR unknown command"));
}
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
// Where an over-buffered slow consumer surfaces: as a transport error on the writer,
// long after the consumer stopped reading.
PRODUCED.addAndGet(0);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
}
private void send(WebSocketSession session, Quote quote) throws Exception {
session.sendMessage(new TextMessage(mapper.writeValueAsString(quote)));
}
}
@@ -0,0 +1,45 @@
syntax = "proto3";
package quotes;
option java_multiple_files = true;
option java_package = "com.ankurm.protocols.grpc";
// The same two operations the RSocket and WebSocket sides expose, so the comparison is between
// transports rather than between designs.
service QuoteService {
// Request/response.
rpc GetQuote (QuoteRequest) returns (Quote);
// Server streaming: count quotes, as fast as the transport allows.
rpc StreamQuotes (StreamRequest) returns (stream Quote);
// Never completes on its own. Used to show what stops a gRPC server producing.
rpc StreamUnbounded (QuoteRequest) returns (stream Quote);
// The same unbounded stream, but the server checks ServerCallStreamObserver.isReady()
// before every write. Same API, different memory profile.
rpc StreamUnboundedReady (QuoteRequest) returns (stream Quote);
// How many messages the server has produced on the current unbounded stream.
rpc Produced (QuoteRequest) returns (ProducedCount);
}
message ProducedCount {
int64 count = 1;
}
message QuoteRequest {
string symbol = 1;
}
message StreamRequest {
string symbol = 1;
int32 count = 2;
// Milliseconds to sleep between emissions; 0 means "as fast as possible".
int32 delay_ms = 3;
}
message Quote {
string symbol = 1;
int64 seq = 2;
double bid = 3;
double ask = 4;
int64 epoch_micros = 5;
}
@@ -0,0 +1,22 @@
spring:
application:
name: protocol-comparison
rsocket:
server:
# RSocket gets its own port and its own Reactor Netty server. TCP here; "websocket" is the
# other transport and is what you use when the client is a browser.
port: 7000
transport: tcp
grpc:
server:
# spring.grpc.server.port -- note the spring. prefix and that "port: -1" means in-process
# only. See the property table in ankurm.com/spring-grpc-spring-boot-4/
port: 9090
server:
port: 8080
logging:
level:
io.grpc: WARN
io.rsocket: WARN
@@ -0,0 +1,156 @@
package com.ankurm.protocols;
import java.util.Iterator;
import java.util.concurrent.atomic.AtomicLong;
import com.ankurm.protocols.grpc.QuoteRequest;
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import org.junit.jupiter.api.Test;
import org.reactivestreams.Subscription;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.messaging.rsocket.RSocketStrategies;
import reactor.core.publisher.BaseSubscriber;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The measurement that decides the article.
*
* <p>Each protocol serves an unbounded stream. Each client takes a hundred messages and then does
* nothing for a while. The question is: <strong>how many did the server produce?</strong>
*
* <p>The sweep over quiet periods is what makes the answer conclusive. A number that stays flat
* as the quiet period grows means something bounded the producer. A number that grows in
* proportion to the wait means <em>nothing did</em> &mdash; the producer is running flat out and
* the only limit is the clock.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"spring.rsocket.server.port=7003", "spring.grpc.server.port=9093"})
class BackPressureTest {
private static final int WANTED = 100;
private static final long[] QUIET_MS = {500, 1_000, 2_000};
@LocalServerPort
int httpPort;
@Autowired
RSocketStrategies strategies;
@Test
void howManyDidTheServerProduce() throws Exception {
System.out.println("=== unbounded stream; client takes " + WANTED + ", then goes quiet ===");
System.out.printf("%-14s %10s %10s %10s%n", "protocol", "quiet 0.5s", "quiet 1s", "quiet 2s");
long[] rs = new long[QUIET_MS.length];
long[] gr = new long[QUIET_MS.length];
long[] ws = new long[QUIET_MS.length];
for (int i = 0; i < QUIET_MS.length; i++) {
rs[i] = rsocket(QUIET_MS[i]);
gr[i] = grpc(QUIET_MS[i]);
ws[i] = websocket(QUIET_MS[i]);
}
row("RSocket", rs);
row("gRPC", gr);
row("WebSocket", ws);
System.out.println();
System.out.println("RSocket is flat: the server produced exactly what request(n) asked for.");
System.out.printf("gRPC grows %.1fx between 0.5 s and 2 s; WebSocket grows %.1fx.%n",
gr[2] / (double) gr[0], ws[2] / (double) ws[0]);
System.out.println("Growth that tracks the wait, rather than settling at a buffer size,");
System.out.println("means nothing bounded the producer. (Growth is faster than linear");
System.out.println("because the loop is still being JIT-compiled during the first run.)");
// RSocket delivered exactly the demand, at every quiet period.
assertThat(rs).containsOnly(WANTED);
// The other two grew with the wait rather than settling at a buffer size.
assertThat(gr[2]).isGreaterThan(gr[0] * 2);
assertThat(ws[2]).isGreaterThan(ws[0] * 2);
}
private void row(String label, long[] v) {
System.out.printf("%-14s %,10d %,10d %,10d%n", label, v[0], v[1], v[2]);
}
/**
* RSocket: {@code request(n)} is a frame on the wire. The server is told the number, produces
* that many, and stops. Nothing blocks and nothing buffers &mdash; the producer is simply not
* called again.
*/
private long rsocket(long quietMs) throws Exception {
RSocketRequester requester = RSocketRequester.builder()
.rsocketStrategies(strategies).tcp("localhost", 7003);
try {
AtomicLong received = new AtomicLong();
BaseSubscriber<Quote> subscriber = new BaseSubscriber<>() {
@Override
protected void hookOnSubscribe(Subscription subscription) {
subscription.request(WANTED); // and never again
}
@Override
protected void hookOnNext(Quote value) {
received.incrementAndGet();
}
};
requester.route("quotes.unbounded").data("AAPL")
.retrieveFlux(Quote.class).subscribe(subscriber);
Thread.sleep(quietMs);
long produced = requester.route("produced").retrieveMono(Long.class).block();
subscriber.dispose();
assertThat(received.get()).isEqualTo(WANTED);
return produced;
}
finally {
requester.dispose();
}
}
/**
* gRPC: there is no application-level demand signal. The blocking stub's {@code Iterator}
* requests one message per {@code next()}, and the handler writes with
* {@code StreamObserver.onNext}, which never blocks.
*/
private long grpc(long quietMs) throws Exception {
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9093)
.usePlaintext().build();
try {
var stub = QuoteServiceGrpc.newBlockingStub(channel);
QuoteRequest req = QuoteRequest.newBuilder().setSymbol("AAPL").build();
Iterator<com.ankurm.protocols.grpc.Quote> it = stub.streamUnbounded(req);
long received = 0;
while (received < WANTED && it.hasNext()) {
it.next();
received++;
}
Thread.sleep(quietMs);
return stub.produced(req).getCount();
}
finally {
channel.shutdownNow();
}
}
/** Raw WebSocket: the frame protocol has no notion of demand at all. */
private long websocket(long quietMs) throws Exception {
try (WsClient client = new WsClient(httpPort, WANTED)) {
client.send("UNBOUNDED AAPL");
long received = 0;
while (received < WANTED && client.take(5_000) != null) {
received++;
}
Thread.sleep(quietMs);
// Ask on a second connection: the first one's server-side thread is busy writing.
try (WsClient asker = new WsClient(httpPort, 8)) {
asker.send("PRODUCED");
return Long.parseLong(asker.take(5_000));
}
}
}
}
@@ -0,0 +1,29 @@
package com.ankurm.protocols;
import java.util.Arrays;
/** Latency bookkeeping, kept in one place so the three protocols are measured identically. */
final class Bench {
private final long[] nanos;
private int i;
Bench(int n) {
this.nanos = new long[n];
}
void record(long ns) {
nanos[i++] = ns;
}
/** p50, p99 and mean in microseconds, plus calls per second derived from the mean. */
String summary(String label) {
long[] sorted = Arrays.copyOf(nanos, i);
Arrays.sort(sorted);
double p50 = sorted[(int) (sorted.length * 0.50)] / 1000.0;
double p99 = sorted[(int) (sorted.length * 0.99)] / 1000.0;
double mean = Arrays.stream(sorted).average().orElse(0) / 1000.0;
return String.format("%-12s n=%-6d p50=%8.1f us p99=%9.1f us mean=%8.1f us ~%,.0f calls/s",
label, sorted.length, p50, p99, mean, 1_000_000.0 / mean);
}
}
@@ -0,0 +1,66 @@
package com.ankurm.protocols;
import java.util.Iterator;
import com.ankurm.protocols.grpc.QuoteRequest;
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
/**
* The documented gRPC answer to over-production, tested rather than assumed.
*
* <p>Every guide to gRPC flow control says the same thing: {@code StreamObserver.onNext} does not
* block, so a server-streaming handler must consult
* {@code ServerCallStreamObserver.isReady()} before writing. This test runs the identical
* scenario against a handler that does exactly that.
*
* <p>It did not help. The transcript is committed as measured &mdash; on a loopback connection
* with a client that has stopped reading, the ready flag stays set and the loop keeps producing
* at the same rate. I have not established whether the messages accumulate in the server's
* outbound queue, in the client transport, or are discarded after the deframer, and I am not
* going to assert a mechanism I have not read the source for. What the numbers do establish is
* narrower and still useful: <strong>{@code isReady()} is not a substitute for a demand
* signal.</strong>
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"spring.rsocket.server.port=7004", "spring.grpc.server.port=9094"})
class GrpcIsReadyTest {
private static final int WANTED = 100;
@Test
void isReadyDoesNotBoundTheProducerHere() throws Exception {
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9094)
.usePlaintext().build();
try {
var stub = QuoteServiceGrpc.newBlockingStub(channel);
QuoteRequest req = QuoteRequest.newBuilder().setSymbol("AAPL").build();
System.out.println("=== gRPC server streaming, client takes 100 then stops ===");
System.out.printf("%-28s %10s %10s%n", "handler", "quiet 0.5s", "quiet 2s");
System.out.printf("%-28s %,10d %,10d%n", "plain onNext loop",
run(stub, req, false, 500), run(stub, req, false, 2_000));
System.out.printf("%-28s %,10d %,10d%n", "isReady() checked before write",
run(stub, req, true, 500), run(stub, req, true, 2_000));
}
finally {
channel.shutdownNow();
}
}
private long run(QuoteServiceGrpc.QuoteServiceBlockingStub stub, QuoteRequest req,
boolean ready, long quietMs) throws Exception {
Iterator<com.ankurm.protocols.grpc.Quote> it =
ready ? stub.streamUnboundedReady(req) : stub.streamUnbounded(req);
long received = 0;
while (received < WANTED && it.hasNext()) {
it.next();
received++;
}
Thread.sleep(quietMs);
return stub.produced(req).getCount();
}
}
@@ -0,0 +1,46 @@
package com.ankurm.protocols;
import java.util.HexFormat;
import com.ankurm.protocols.grpc.Quote;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.dataformat.cbor.CBORMapper;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The same five fields, encoded three ways.
*
* <p>This is the one comparison that needs no server, no warm-up and no statistics: it is a
* property of the wire formats. It is also the honest place to start, because on a loopback
* benchmark the encoding is most of what separates the three protocols.
*/
class PayloadSizeTest {
private static final com.ankurm.protocols.Quote POJO =
new com.ankurm.protocols.Quote("AAPL", 42, 100.42, 100.44, 1_772_000_000_000_000L);
@Test
void protobufVersusJsonVersusCbor() {
byte[] proto = Quote.newBuilder()
.setSymbol("AAPL").setSeq(42)
.setBid(100.42).setAsk(100.44)
.setEpochMicros(1_772_000_000_000_000L)
.build().toByteArray();
byte[] json = new ObjectMapper().writeValueAsBytes(POJO);
byte[] cbor = new CBORMapper().writeValueAsBytes(POJO);
System.out.println("=== one Quote, five fields, three encodings ===");
System.out.printf("protobuf : %3d bytes %s%n", proto.length, HexFormat.of().formatHex(proto));
System.out.printf("JSON : %3d bytes %s%n", json.length, new String(json));
System.out.printf("CBOR : %3d bytes %s%n", cbor.length, HexFormat.of().formatHex(cbor));
System.out.printf("JSON is %.2fx protobuf; CBOR is %.2fx protobuf%n",
json.length / (double) proto.length, cbor.length / (double) proto.length);
// Protobuf is smallest because field names are numbers and there is no framing text.
assertThat(proto.length).isLessThan(cbor.length);
assertThat(cbor.length).isLessThan(json.length);
}
}
@@ -0,0 +1,111 @@
package com.ankurm.protocols;
import com.ankurm.protocols.grpc.QuoteRequest;
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.messaging.rsocket.RSocketStrategies;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Sequential request/response round trips, all three protocols, same JVM, same data source.
*
* <p>Read the numbers as a comparison of the three, not as absolutes. Client and server are the
* same process on one loopback interface with two cores, so the network &mdash; the thing that
* dominates every real deployment &mdash; is absent. That deletes the advantage a smaller
* encoding would have on a real link and leaves framing and dispatch cost, which is exactly the
* part a loopback measures well.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"spring.rsocket.server.port=7001", "spring.grpc.server.port=9091"})
class RequestResponseBenchmarkTest {
private static final int WARMUP = 2_000;
private static final int MEASURED = 5_000;
@LocalServerPort
int httpPort;
@Autowired
RSocketStrategies strategies;
@Test
void threeProtocolsOneOperation() throws Exception {
System.out.println("=== request/response, " + MEASURED + " sequential calls after "
+ WARMUP + " warm-up, loopback, JDK " + System.getProperty("java.version") + " ===");
System.out.println(grpc());
System.out.println(rsocket());
System.out.println(websocket());
}
private String grpc() {
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9091)
.usePlaintext().build();
try {
var stub = QuoteServiceGrpc.newBlockingStub(channel);
QuoteRequest req = QuoteRequest.newBuilder().setSymbol("AAPL").build();
for (int i = 0; i < WARMUP; i++) {
stub.getQuote(req);
}
Bench bench = new Bench(MEASURED);
for (int i = 0; i < MEASURED; i++) {
long t0 = System.nanoTime();
var quote = stub.getQuote(req);
bench.record(System.nanoTime() - t0);
assertThat(quote.getSymbol()).isEqualTo("AAPL");
}
return bench.summary("gRPC");
}
finally {
channel.shutdownNow();
}
}
private String rsocket() {
RSocketRequester requester = RSocketRequester.builder()
.rsocketStrategies(strategies).tcp("localhost", 7001);
try {
for (int i = 0; i < WARMUP; i++) {
requester.route("quote").data("AAPL").retrieveMono(Quote.class).block();
}
Bench bench = new Bench(MEASURED);
for (int i = 0; i < MEASURED; i++) {
long t0 = System.nanoTime();
Quote quote = requester.route("quote").data("AAPL")
.retrieveMono(Quote.class).block();
bench.record(System.nanoTime() - t0);
assertThat(quote).isNotNull();
}
return bench.summary("RSocket");
}
finally {
requester.dispose();
}
}
private String websocket() throws Exception {
try (WsClient client = new WsClient(httpPort, 64)) {
for (int i = 0; i < WARMUP; i++) {
client.send("QUOTE AAPL");
assertThat(client.take(5_000)).isNotNull();
}
Bench bench = new Bench(MEASURED);
for (int i = 0; i < MEASURED; i++) {
long t0 = System.nanoTime();
client.send("QUOTE AAPL");
String reply = client.take(5_000);
bench.record(System.nanoTime() - t0);
assertThat(reply).contains("AAPL");
}
return bench.summary("WebSocket");
}
}
}
@@ -0,0 +1,120 @@
package com.ankurm.protocols;
import java.util.Iterator;
import com.ankurm.protocols.grpc.StreamRequest;
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.messaging.rsocket.RSocketStrategies;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Server streaming: how fast can one connection carry N messages one way?
*
* <p>This is the shape most of these protocols are actually chosen for &mdash; a price feed, a
* log tail, a progress stream &mdash; and it separates them differently from request/response,
* because the per-message cost stops being dominated by a round trip.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"spring.rsocket.server.port=7002", "spring.grpc.server.port=9092"})
class StreamThroughputBenchmarkTest {
private static final int WARMUP = 5_000;
private static final int N = 50_000;
@LocalServerPort
int httpPort;
@Autowired
RSocketStrategies strategies;
@Test
void fiftyThousandMessagesEachWay() throws Exception {
System.out.println("=== server streaming, " + N + " messages on one connection, loopback ===");
System.out.println(grpc());
System.out.println(rsocket());
System.out.println(websocket());
}
private String grpc() {
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9092)
.usePlaintext().build();
try {
var stub = QuoteServiceGrpc.newBlockingStub(channel);
drain(stub, WARMUP);
long t0 = System.nanoTime();
int seen = drain(stub, N);
return report("gRPC", seen, System.nanoTime() - t0);
}
finally {
channel.shutdownNow();
}
}
private int drain(QuoteServiceGrpc.QuoteServiceBlockingStub stub, int count) {
// The blocking stub returns an Iterator. Errors surface mid-iteration rather than at the
// call, which is the first thing that surprises people about gRPC server streaming.
Iterator<com.ankurm.protocols.grpc.Quote> it = stub.streamQuotes(
StreamRequest.newBuilder().setSymbol("AAPL").setCount(count).build());
int seen = 0;
while (it.hasNext()) {
it.next();
seen++;
}
return seen;
}
private String rsocket() {
RSocketRequester requester = RSocketRequester.builder()
.rsocketStrategies(strategies).tcp("localhost", 7002);
try {
var warm = new RSocketQuoteController.StreamSpec("AAPL", WARMUP, 0);
requester.route("quotes").data(warm).retrieveFlux(Quote.class).blockLast();
var spec = new RSocketQuoteController.StreamSpec("AAPL", N, 0);
long t0 = System.nanoTime();
long seen = requester.route("quotes").data(spec)
.retrieveFlux(Quote.class).count().block();
return report("RSocket", (int) seen, System.nanoTime() - t0);
}
finally {
requester.dispose();
}
}
private String websocket() throws Exception {
// A generous inbox, because there is nothing else to do with messages that arrive faster
// than they are read. This IS the WebSocket back-pressure story, in one constant.
try (WsClient client = new WsClient(httpPort, N + WARMUP + 16)) {
client.send("STREAM AAPL " + WARMUP + " 0");
int warm = 0;
String m;
while ((m = client.take(20_000)) != null && !"END".equals(m)) {
warm++;
}
assertThat(warm).isEqualTo(WARMUP);
long t0 = System.nanoTime();
client.send("STREAM AAPL " + N + " 0");
int seen = 0;
while ((m = client.take(20_000)) != null && !"END".equals(m)) {
seen++;
}
return report("WebSocket", seen, System.nanoTime() - t0);
}
}
private String report(String label, int seen, long elapsedNanos) {
assertThat(seen).isEqualTo(N);
double seconds = elapsedNanos / 1e9;
return String.format("%-12s %,7d msgs in %6.3f s = %,10.0f msgs/s (%6.2f us each)",
label, seen, seconds, seen / seconds, elapsedNanos / 1000.0 / seen);
}
}
@@ -0,0 +1,60 @@
package com.ankurm.protocols;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import jakarta.websocket.ContainerProvider;
import jakarta.websocket.WebSocketContainer;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.handler.TextWebSocketHandler;
/**
* A minimal raw-WebSocket client for the benchmark.
*
* <p>The queue is the point of interest. A WebSocket client has no way to tell the server how
* many messages it is ready for, so the only thing it can do with an over-eager producer is
* buffer them &mdash; here, in a bounded queue that starts dropping. That is the shape of every
* real WebSocket consumer, and it is what {@code BackPressureTest} contrasts with RSocket.
*/
final class WsClient implements AutoCloseable {
private final WebSocketSession session;
private final BlockingQueue<String> inbox;
WsClient(int port, int inboxCapacity) throws Exception {
this.inbox = new ArrayBlockingQueue<>(inboxCapacity);
WebSocketContainer container = ContainerProvider.getWebSocketContainer();
container.setDefaultMaxTextMessageBufferSize(1024 * 1024);
this.session = new StandardWebSocketClient(container)
.execute(new TextWebSocketHandler() {
@Override
protected void handleTextMessage(WebSocketSession s, TextMessage message) {
inbox.offer(message.getPayload()); // drops when full; nothing else to do
}
}, "ws://localhost:" + port + "/quotes").get();
}
void send(String command) throws Exception {
session.sendMessage(new TextMessage(command));
}
String take(long timeoutMs) throws Exception {
return inbox.poll(timeoutMs, TimeUnit.MILLISECONDS);
}
int drained() {
return inbox.size();
}
boolean isOpen() {
return session.isOpen();
}
@Override
public void close() throws Exception {
session.close();
}
}
+96
View File
@@ -0,0 +1,96 @@
# `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.
+81
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 &mdash; a dashboard, a progress bar, a notification feed, a log tail, a
"your export is ready" ping &mdash; 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 &mdash; `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 &mdash; 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 &mdash;
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&nbsp;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)
+132
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) &middot; 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` &mdash; 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 &mdash; 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&nbsp;s for a 1.2&nbsp;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)` &mdash; per handler
2. `spring.mvc.async.request-timeout` &mdash; per application
3. whatever the servlet container defaults to &mdash; 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)
+83
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)
+129
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)
+119
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)
+80
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)
@@ -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}
@@ -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
@@ -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
@@ -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 ===
@@ -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"}
@@ -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
@@ -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
+16
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
@@ -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
+59
View File
@@ -0,0 +1,59 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>sse-websocket</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- SseEmitter lives in spring-webmvc, so the web starter is all SSE needs. There is no
"SSE starter" and no SSE auto-configuration: it is a return type, not a subsystem. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- spring-boot-starter-websocket, NOT a bare org.springframework:spring-websocket.
In Boot 4 the auto-configuration lives in the spring-boot-websocket module. Depending
on spring-websocket + spring-messaging directly compiles and starts, and then the
handshake 404s because no WebSocketUpgrade/ServerContainer is configured. Same rule as
spring-kafka in ../kafka-basics. See docs/01-two-protocols.md. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Regenerate every file under docs/output/. Nothing to install beyond a JDK: every test starts an
# embedded Tomcat on a random port and talks to it over a real socket.
set -eu
cd "$(dirname "$0")/.."
LOG=/tmp/sse-websocket-test.log
mvn -B test > "$LOG" 2>&1
# The buffer sweep: the same binary search at three container buffer sizes, so the ceiling can be
# shown as a function of the setting rather than as one number.
: > /tmp/sse-buffer-sweep.log
for b in 16384 32768 65536; do
mvn -B test -Dtest=StompSizeLimitTunedBufferTest -Dwsbuf="$b" 2>&1 \
| grep -E '^=== container buffer|^largest body|^smallest body' >> /tmp/sse-buffer-sweep.log
done
sed -n '/=== GET \/sse\/raw/,/^| 0$/p' "$LOG" > docs/output/sse-wire-format.txt
sed -n '/=== SseEmitter.event()/,/=== String payload, APPLICATION_JSON/p' "$LOG" \
> docs/output/sse-payload-conversion.txt
{
sed -n '/=== GET \/sse\/silent/,/registry.timedOut/p' "$LOG"
echo
sed -n '/=== client closed the socket/,/appeared to succeed/p' "$LOG"
} > docs/output/sse-lifecycle.txt
sed -n '/=== server.tomcat.threads.max=10/,/clients that read it/p' "$LOG" \
> docs/output/sse-concurrency.txt
grep -E '^=== spring.mvc.async|^\{"spring.mvc.async' "$LOG" > docs/output/async-timeout.txt
{
sed -n '/=== SEND \/app\/chat.send/,/^| DISCONNECT/p' "$LOG"; echo
sed -n '/=== @MessageMapping/,/^subscribed to/p' "$LOG"; echo
sed -n '/=== SEND straight to/,/did NOT happen/p' "$LOG"; echo
sed -n '/^alice.getSessionId/,/^bob received/p' "$LOG"
} > docs/output/stomp-routing.txt
{
echo "The limit that fires is the servlet container's WebSocket buffer, not Spring's"
echo "messageSizeLimit. Body length below is the JSON string field; the STOMP frame carries"
echo "about 90 bytes of headers on top."
echo
sed -n '/=== container buffer: Tomcat default/,/smallest body rejected/p' "$LOG"
echo
cat /tmp/sse-buffer-sweep.log
echo
sed -n '/=== bigframes profile/,/^200 KB body/p' "$LOG" \
| grep -vE '^\s+at |^2026-|^$|Initializing|Completed initialization'
echo
echo "Spring's own limit, once the container is out of the way:"
grep -m1 -A1 'Failed to parse TextMessage' "$LOG" | sed 's/^\[[^]]*\] //'
grep -m1 'STOMP buffer size limit' "$LOG"
echo
grep -oE '"ws.container.defaultMaxTextMessageBufferSize":[0-9]+' "$LOG" | sort -u
} > docs/output/websocket-size-limits.txt
sed -n '/=== GET \/ws handshake/,/Origin: https:\/\/evil/p' "$LOG" > docs/output/handshake-origin.txt
grep -E 'Tests run:.*in com\.ankurm' "$LOG" | sed 's/^\[INFO\] //' > docs/output/tests.txt
echo "regenerated:"; ls -1 docs/output/
@@ -0,0 +1,86 @@
package com.ankurm.ssews;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;
import org.springframework.web.servlet.AsyncHandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Answers "how long does my SSE stream actually get?" without waiting for it to happen.
*
* <p>The chain is: {@code SseEmitter}'s own constructor argument, else
* {@code spring.mvc.async.request-timeout}, else whatever the servlet container defaults to.
* Only the last one is invisible.
*
* <p>Reading it needs a little care. Inside the handler method {@code isAsyncStarted()} is still
* {@code false} &mdash; Spring starts async processing <em>after</em> the method returns, once
* it sees the return type. The first place the real {@code AsyncContext} exists is
* {@link AsyncHandlerInterceptor#afterConcurrentHandlingStarted}, so that is where the timeout
* is captured.
*/
@RestController
public class AsyncTimeoutProbe {
static final AtomicReference<Object> LAST_TIMEOUT = new AtomicReference<>("<not captured>");
private final String configured;
AsyncTimeoutProbe(@Value("${spring.mvc.async.request-timeout:<unset>}") String configured) {
this.configured = configured;
}
/** Start async, so the interceptor below can read the effective timeout, then answer. */
@GetMapping("/diag/async-start")
public DeferredResult<String> start() {
DeferredResult<String> result = new DeferredResult<>();
result.setResult("started");
return result;
}
@GetMapping("/diag/async-timeout")
public Map<String, Object> asyncTimeout(HttpServletRequest request) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("spring.mvc.async.request-timeout", configured);
m.put("servletContainer", request.getServletContext().getServerInfo());
m.put("effectiveAsyncTimeoutMs", LAST_TIMEOUT.get());
return m;
}
@Component
static class CaptureAsyncTimeout implements AsyncHandlerInterceptor {
@Override
public void afterConcurrentHandlingStarted(HttpServletRequest request,
HttpServletResponse response, Object handler) {
if (request.isAsyncStarted()) {
LAST_TIMEOUT.set(request.getAsyncContext().getTimeout());
}
}
}
@Configuration
static class RegisterInterceptor implements WebMvcConfigurer {
private final CaptureAsyncTimeout interceptor;
RegisterInterceptor(CaptureAsyncTimeout interceptor) {
this.interceptor = interceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(interceptor);
}
}
}
@@ -0,0 +1,70 @@
package com.ankurm.ssews;
import java.security.Principal;
import java.time.Instant;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessageType;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
/**
* The chat. Three handlers, each demonstrating a different routing rule.
*
* @see <a href="../../../../../docs/04-stomp.md">docs/04-stomp.md</a>
*/
@Controller
public class ChatController {
private final SimpMessagingTemplate template;
ChatController(SimpMessagingTemplate template) {
this.template = template;
}
/** Explicit fan-out destination. Client SENDs to /app/chat.send, subscribers get /topic/room. */
@MessageMapping("/chat.send")
@SendTo("/topic/room")
public ChatMessage send(@Payload ChatMessage in, SimpMessageHeaderAccessor headers) {
String from = in.from() == null ? headers.getSessionId() : in.from();
return new ChatMessage(from, in.text(), Instant.now());
}
/**
* No {@code @SendTo}. The return value still goes somewhere: the default destination is the
* broker prefix plus the mapping, i.e. {@code /topic/chat.echo}. That default is why a
* handler you thought was private is readable by anyone who guesses the path.
*/
@MessageMapping("/chat.echo")
public ChatMessage echo(@Payload ChatMessage in) {
return new ChatMessage("echo", in.text(), Instant.now());
}
/**
* Point-to-point. Sent to one session's private destination; every other subscriber to
* /user/queue/whisper gets nothing, because the broker rewrote the destination per session.
*
* <p>The three extra lines are not optional and their absence is silent. With no
* authenticated {@link Principal} the "user" is the STOMP session id, and
* {@code DefaultUserDestinationResolver} can only turn a session id into a real destination
* if the message also <em>carries</em> that session id in its headers. Call the two-argument
* {@code convertAndSendToUser(sessionId, dest, payload)} instead and the message is resolved
* against a user registry that has never heard of that name: no exception, no log line, no
* delivery. {@code setLeaveMutable(true)} is required too &mdash; without it the accessor is
* immutable by the time the resolver looks, and the session id is lost again.
*/
@MessageMapping("/chat.whisper")
public void whisper(@Payload ChatMessage in, SimpMessageHeaderAccessor headers, Principal principal) {
String targetSessionId = in.from();
SimpMessageHeaderAccessor out = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
out.setSessionId(targetSessionId);
out.setLeaveMutable(true);
template.convertAndSendToUser(targetSessionId, "/queue/whisper",
new ChatMessage(principal == null ? headers.getSessionId() : principal.getName(),
in.text(), Instant.now()),
out.getMessageHeaders());
}
}
@@ -0,0 +1,7 @@
package com.ankurm.ssews;
import java.time.Instant;
/** A single chat line, carried as the STOMP frame body. */
public record ChatMessage(String from, String text, Instant at) {
}
@@ -0,0 +1,117 @@
package com.ankurm.ssews;
import java.io.IOException;
import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
* The live dashboard, and four smaller endpoints that exist only so the article can quote a
* transcript rather than assert a behaviour.
*
* @see <a href="../../../../../docs/02-sse-lifecycle.md">docs/02-sse-lifecycle.md</a>
*/
@RestController
public class DashboardSseController {
private final EmitterRegistry registry;
private final AtomicLong seq = new AtomicLong();
DashboardSseController(EmitterRegistry registry) {
this.registry = registry;
}
/**
* The dashboard stream. Subscribers get whatever the scheduled broadcaster pushes.
*
* <p>{@code Long.MAX_VALUE} disables the async timeout for this handler. That is the right
* call for a stream that is supposed to stay open, and it is also the line that turns a
* leaked emitter into a permanent leak &mdash; which is why {@link EmitterRegistry} removes
* on all three callbacks.
*/
@GetMapping(path = "/sse/metrics", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter metrics() {
return registry.register(UUID.randomUUID().toString(), new SseEmitter(Long.MAX_VALUE));
}
/**
* Three events and a close, written from the request thread, so a curl transcript shows the
* exact wire format: field names, the blank-line record separator, and where {@code retry}
* and {@code id} land.
*/
@GetMapping(path = "/sse/raw", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter raw() throws IOException {
SseEmitter emitter = new SseEmitter(10_000L);
emitter.send(SseEmitter.event().comment("stream open"));
emitter.send(SseEmitter.event()
.id("1").name("metric").reconnectTime(3000)
.data(new MetricSnapshot(1, "node-a", 0.42, 512, Instant.parse("2026-09-03T10:00:00Z")),
MediaType.APPLICATION_JSON));
// Multi-line data is split into several data: lines by the SSE spec, and the browser
// rejoins them with "\n". Worth seeing once.
emitter.send(SseEmitter.event().id("2").name("note").data("line one\nline two"));
emitter.complete();
return emitter;
}
/**
* Sends the same record twice: once with an explicit JSON media type and once with none.
* The difference is the whole of {@code docs/03-the-payload.md}.
*/
@GetMapping(path = "/sse/payload", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter payload() throws IOException {
SseEmitter emitter = new SseEmitter(10_000L);
MetricSnapshot snap =
new MetricSnapshot(7, "node-a", 0.42, 512, Instant.parse("2026-09-03T10:00:00Z"));
emitter.send(SseEmitter.event().name("with-json").data(snap, MediaType.APPLICATION_JSON));
emitter.send(SseEmitter.event().name("no-media-type").data(snap));
// The same two calls for a String payload, where the answer is different: a String is
// written by StringHttpMessageConverter unless a JSON media type forces Jackson, and
// Jackson then quotes and escapes it.
emitter.send(SseEmitter.event().name("string-plain").data("{\"already\":\"json\"}"));
emitter.send(SseEmitter.event().name("string-json")
.data("{\"already\":\"json\"}", MediaType.APPLICATION_JSON));
emitter.complete();
return emitter;
}
/**
* Opens a stream, writes one event, then never writes again. With a short timeout this shows
* what a client actually observes when the async request times out mid-stream.
*/
@GetMapping(path = "/sse/silent", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter silent(@RequestParam(defaultValue = "1500") long timeoutMs) throws IOException {
SseEmitter emitter = registry.register(UUID.randomUUID().toString(), new SseEmitter(timeoutMs));
emitter.send(SseEmitter.event().name("hello").data("then silence"));
return emitter;
}
/**
* Echoes the {@code Last-Event-ID} request header. The browser sends it automatically on
* reconnect; the server is expected to replay from there, and almost nothing does.
*/
@GetMapping(path = "/sse/resume", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter resume(@RequestHeader(name = "Last-Event-ID", required = false) String lastEventId)
throws IOException {
SseEmitter emitter = new SseEmitter(10_000L);
long from = lastEventId == null ? 0 : Long.parseLong(lastEventId);
emitter.send(SseEmitter.event().name("resumed")
.data("Last-Event-ID=" + lastEventId + " -> replaying from " + (from + 1)));
for (long i = from + 1; i <= from + 2; i++) {
emitter.send(SseEmitter.event().id(Long.toString(i)).name("metric").data("event " + i));
}
emitter.complete();
return emitter;
}
long nextSeq() {
return seq.incrementAndGet();
}
}
@@ -0,0 +1,81 @@
package com.ankurm.ssews;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import jakarta.servlet.ServletContext;
import jakarta.websocket.server.ServerContainer;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.messaging.simp.user.SimpUserRegistry;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Prints the runtime state that is otherwise invisible: how many SSE streams are open, how many
* writes discovered a dead one, and how many WebSocket sessions the broker believes it has.
*
* <p>Delete this before shipping. It is here because "the emitter map is leaking" and "the
* broker still thinks that user is connected" are both conditions you cannot see from the
* outside, and both are much easier to argue about with a number in front of you.
*/
@RestController
public class DiagnosticsController {
private final EmitterRegistry registry;
private final SimpUserRegistry userRegistry;
private final List<HttpMessageConverter<?>> converters;
private final String asyncTimeout;
private final String tomcatMaxThreads;
DiagnosticsController(EmitterRegistry registry,
SimpUserRegistry userRegistry,
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter adapter,
@Value("${spring.mvc.async.request-timeout:<unset>}") String asyncTimeout,
@Value("${server.tomcat.threads.max:<unset>}") String tomcatMaxThreads) {
this.registry = registry;
this.userRegistry = userRegistry;
this.converters = adapter.getMessageConverters();
this.asyncTimeout = asyncTimeout;
this.tomcatMaxThreads = tomcatMaxThreads;
}
@org.springframework.beans.factory.annotation.Autowired
ServletContext servletContext;
@GetMapping("/diag")
public Map<String, Object> diag() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("sse.open", registry.open());
m.put("sse.opened", registry.opened());
m.put("sse.completed", registry.completed());
m.put("sse.timedOut", registry.timedOut());
m.put("sse.errored", registry.errored());
m.put("sse.sendsOk", registry.sendsOk());
m.put("sse.sendsFailed", registry.sendsFailed());
m.put("ws.users", userRegistry.getUserCount());
m.put("config.spring.mvc.async.request-timeout", asyncTimeout);
m.put("config.server.tomcat.threads.max", tomcatMaxThreads);
// The order that decides what SseEmitter.send(data, mediaType) actually does: the first
// converter whose canWrite(type, mediaType) returns true wins, and the media type you
// passed is only a filter, never a preference.
m.put("http.messageConverters",
converters.stream().map(c -> c.getClass().getSimpleName()).toList());
// The number that actually decides how big a STOMP frame can be. It belongs to the
// servlet container, not to Spring, and Spring's own messageSizeLimit never sees a
// frame the container refused to assemble.
Object sc = servletContext.getAttribute("jakarta.websocket.server.ServerContainer");
if (sc instanceof ServerContainer container) {
m.put("ws.container.defaultMaxTextMessageBufferSize",
container.getDefaultMaxTextMessageBufferSize());
m.put("ws.container.defaultMaxBinaryMessageBufferSize",
container.getDefaultMaxBinaryMessageBufferSize());
m.put("ws.container.defaultMaxSessionIdleTimeout",
container.getDefaultMaxSessionIdleTimeout());
}
return m;
}
}
@@ -0,0 +1,98 @@
package com.ankurm.ssews;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
* The piece every SSE tutorial leaves out: something that owns the open emitters and removes
* them again.
*
* <p>An {@link SseEmitter} is not a subscription. Nothing in Spring keeps a list of them, and
* nothing tells you when one dies &mdash; you find out on the next write, as an
* {@link IOException} thrown from {@code send()} on whichever thread happened to be
* broadcasting. If that thread is a scheduler thread and the exception escapes, the schedule
* stops and the whole dashboard silently freezes for everyone.
*
* <p>The counters here are exposed by {@link DiagnosticsController} so the article can quote
* real numbers for "how many streams were open, and how many writes discovered a dead one".
*
* @see <a href="../../../../../docs/02-sse-lifecycle.md">docs/02-sse-lifecycle.md</a>
*/
@Component
public class EmitterRegistry {
private static final Logger log = LoggerFactory.getLogger(EmitterRegistry.class);
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
private final AtomicLong opened = new AtomicLong();
private final AtomicLong completed = new AtomicLong();
private final AtomicLong timedOut = new AtomicLong();
private final AtomicLong errored = new AtomicLong();
private final AtomicLong sendsOk = new AtomicLong();
private final AtomicLong sendsFailed = new AtomicLong();
public SseEmitter register(String id, SseEmitter emitter) {
emitters.put(id, emitter);
opened.incrementAndGet();
// All three callbacks must remove the emitter. Registering only onCompletion is the
// most common variant of this bug: a timed-out emitter is never completed by the
// client, so it stays in the map for ever and every broadcast keeps paying for it.
emitter.onCompletion(() -> { emitters.remove(id); completed.incrementAndGet(); });
emitter.onTimeout(() -> {
emitters.remove(id);
timedOut.incrementAndGet();
// Spring completes the async request on timeout, but call complete() so the
// onCompletion callback semantics stay consistent for our own bookkeeping.
emitter.complete();
});
emitter.onError(t -> { emitters.remove(id); errored.incrementAndGet(); });
return emitter;
}
/**
* Broadcast to everyone, dropping the streams that turn out to be gone.
*
* <p>The try/catch is not defensive programming; it is the only way a dead client is
* detected. Note also that this is where SSE's cost lives: one serialise-and-write per
* open stream, on the calling thread.
*/
public int broadcast(String eventName, MetricSnapshot payload) {
int delivered = 0;
for (Map.Entry<String, SseEmitter> e : emitters.entrySet()) {
try {
e.getValue().send(SseEmitter.event()
.id(Long.toString(payload.seq()))
.name(eventName)
.reconnectTime(3000)
.data(payload, org.springframework.http.MediaType.APPLICATION_JSON));
sendsOk.incrementAndGet();
delivered++;
}
catch (IOException | IllegalStateException ex) {
// IOException -> the socket is gone (client closed the tab, proxy reaped it).
// IllegalState -> the emitter already completed or timed out on another thread.
sendsFailed.incrementAndGet();
emitters.remove(e.getKey());
log.debug("dropping emitter {}: {}", e.getKey(), ex.getClass().getSimpleName());
}
}
return delivered;
}
public int open() { return emitters.size(); }
public long opened() { return opened.get(); }
public long completed() { return completed.get(); }
public long timedOut() { return timedOut.get(); }
public long errored() { return errored.get(); }
public long sendsOk() { return sendsOk.get(); }
public long sendsFailed() { return sendsFailed.get(); }
}
@@ -0,0 +1,14 @@
package com.ankurm.ssews;
import java.time.Instant;
/**
* One row of the live dashboard.
*
* <p>{@link Instant} is here on purpose: it is the field that fails to serialise if the wrong
* converter is chosen for the SSE payload, which is what {@code SseDataConversionTest} pins.
*
* @see <a href="../../../../../docs/03-the-payload.md">docs/03-the-payload.md</a>
*/
public record MetricSnapshot(long seq, String host, double cpu, long heapMb, Instant at) {
}
@@ -0,0 +1,42 @@
package com.ankurm.ssews;
import java.time.Instant;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Pushes one snapshot to every open dashboard stream on a fixed schedule.
*
* <p>Fan-out happens on the scheduler thread, which is the shape almost every SSE dashboard
* ends up with and also its first scaling limit: 1&nbsp;000 open streams means 1&nbsp;000
* serialise-and-write calls in a row on one thread, every tick.
* {@code docs/06-choosing.md} has the measurement.
*/
@Component
@ConditionalOnProperty(name = "dashboard.broadcast.enabled", havingValue = "true", matchIfMissing = true)
public class MetricsBroadcaster {
private final EmitterRegistry registry;
private final AtomicLong seq = new AtomicLong();
MetricsBroadcaster(EmitterRegistry registry) {
this.registry = registry;
}
@Scheduled(fixedRateString = "${dashboard.broadcast.interval-ms:500}")
public void tick() {
if (registry.open() == 0) {
return;
}
registry.broadcast("metric", new MetricSnapshot(
seq.incrementAndGet(),
"node-a",
Math.round(ThreadLocalRandom.current().nextDouble() * 10000) / 10000.0,
256 + ThreadLocalRandom.current().nextInt(256),
Instant.now()));
}
}
@@ -0,0 +1,47 @@
package com.ankurm.ssews;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
import org.springframework.web.socket.messaging.SessionSubscribeEvent;
/**
* Presence, and the reason people reach for STOMP over SSE in the first place: the server knows
* who is connected, who subscribed to what, and when they left.
*
* <p>SSE has no equivalent. There is no disconnect event &mdash; only a write that fails later.
*/
@Component
public class PresenceListener {
private final List<String> log = new CopyOnWriteArrayList<>();
@EventListener
public void onConnected(SessionConnectedEvent e) {
log.add("CONNECTED " + StompHeaderAccessor.wrap(e.getMessage()).getSessionId());
}
@EventListener
public void onSubscribe(SessionSubscribeEvent e) {
StompHeaderAccessor h = StompHeaderAccessor.wrap(e.getMessage());
log.add("SUBSCRIBE " + h.getSessionId() + " -> " + h.getDestination());
}
@EventListener
public void onDisconnect(SessionDisconnectEvent e) {
log.add("DISCONNECT " + e.getSessionId() + " status=" + e.getCloseStatus());
}
public List<String> events() {
return List.copyOf(log);
}
public void clear() {
log.clear();
}
}
@@ -0,0 +1,25 @@
package com.ankurm.ssews;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Both halves of the article in one application: a Server-Sent Events dashboard under
* {@code /sse/**} and a STOMP-over-WebSocket chat under {@code /ws}.
*
* <p>They coexist deliberately. The point of the companion article is that they are not
* competitors so much as different answers to "who needs to talk", and running them side by
* side makes the asymmetry visible: the SSE endpoints are plain MVC handler methods with a
* return type, while the chat needs a broker, a message-converter stack and a session registry.
*
* @see <a href="../../../../../docs/01-two-protocols.md">docs/01-two-protocols.md</a>
*/
@SpringBootApplication
@EnableScheduling
public class SseWebSocketApplication {
public static void main(String[] args) {
SpringApplication.run(SseWebSocketApplication.class, args);
}
}
@@ -0,0 +1,35 @@
package com.ankurm.ssews;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
/**
* The other half of the message size limit &mdash; the half that is not in Spring.
*
* <p>{@code WebSocketTransportRegistration.setMessageSizeLimit(64 * 1024)} is not the limit that
* bites first. Spring's {@code SubProtocolWebSocketHandler} does not support partial messages,
* so the servlet container has to buffer each WebSocket text message whole before handing it
* over &mdash; and Tomcat's buffer defaults to <strong>8&nbsp;192 bytes</strong>. Cross that and
* Tomcat closes the connection with WebSocket status <strong>1009</strong> before Spring ever
* sees the frame, so raising Spring's limit alone changes nothing.
*
* <p>Active under the {@code bigframes} profile so the companion test can show both behaviours
* from one build.
*
* @see <a href="../../../../../docs/05-limits.md">docs/05-limits.md</a>
*/
@Configuration
@Profile("bigframes")
public class WebSocketBufferConfig {
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer(
@org.springframework.beans.factory.annotation.Value("${ws.buffer-bytes:262144}") int bytes) {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(bytes);
container.setMaxBinaryMessageBufferSize(bytes);
return container;
}
}
@@ -0,0 +1,62 @@
package com.ankurm.ssews;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;
/**
* Four lines of configuration that decide more than they look like they do.
*
* @see <a href="../../../../../docs/04-stomp.md">docs/04-stomp.md</a>
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// No setAllowedOrigins call at all. The handshake then permits same-origin requests
// only -- and "same origin" is decided from the Origin header, which a non-browser
// client does not send, so curl and the Java client are allowed while a page on
// another site is not. Add setAllowedOriginPatterns("https://app.example.com") for a
// real cross-origin front end; setAllowedOrigins("*") is rejected outright when
// credentials are allowed.
registry.addEndpoint("/ws");
// The SockJS variant is a SECOND endpoint on its own path. Registering .withSockJS()
// on the same path does not give you both: SockJS wraps the URL in /{server}/{session}
// and a plain WebSocket client pointed at it fails the handshake.
registry.addEndpoint("/ws-sockjs").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// The simple broker. In-memory, single JVM, no persistence, no acknowledgement, and it
// drops everything on restart. enableStompBrokerRelay(..) swaps in RabbitMQ or ActiveMQ
// without touching a controller -- see ../rabbitmq for the broker side.
registry.enableSimpleBroker("/topic", "/queue");
// Destinations a CLIENT sends to. /app/chat.send is routed to @MessageMapping; anything
// starting with /topic or /queue goes straight to the broker and is NEVER seen by a
// controller. Getting this backwards produces a message that vanishes with no error.
registry.setApplicationDestinationPrefixes("/app");
// The prefix a client subscribes under for messages addressed to it alone. The client
// subscribes to /user/queue/whisper; the broker rewrites that to a session-scoped
// destination that no other session can subscribe to.
registry.setUserDestinationPrefix("/user");
}
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
// Defaults, restated so the numbers are visible in one place rather than discovered
// when a 70 KB frame disappears. WebSocketTransportRegistration's own defaults are
// 64 KB for both; see WebSocketLimitsTest for what exceeding them looks like.
registration.setMessageSizeLimit(64 * 1024);
registration.setSendBufferSizeLimit(512 * 1024);
registration.setSendTimeLimit(20_000);
}
}
@@ -0,0 +1,19 @@
spring:
application:
name: sse-websocket
# spring.mvc.async.request-timeout is deliberately ABSENT, not set to blank. When it is
# absent Spring never calls AsyncContext.setTimeout(..), so the servlet container's own
# default applies. /diag/async-timeout reports what that turns out to be on this container;
# an SseEmitter constructor argument overrides both.
server:
port: 8080
dashboard:
broadcast:
enabled: true
interval-ms: 500
logging:
level:
org.springframework.web.socket: INFO
@@ -0,0 +1,30 @@
package com.ankurm.ssews;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.web.servlet.client.RestTestClient;
import static org.assertj.core.api.Assertions.assertThat;
/** The property overrides the container default, in milliseconds, exactly as written. */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"dashboard.broadcast.enabled=false", "spring.mvc.async.request-timeout=5s"})
class AsyncTimeoutPropertyTest {
@LocalServerPort
int port;
@Test
void propertyOverridesTheContainer() {
RestTestClient client = RestTestClient.bindToServer()
.baseUrl("http://localhost:" + port).build();
client.get().uri("/diag/async-start").exchange().expectStatus().isOk();
String body = client.get().uri("/diag/async-timeout").exchange()
.expectStatus().isOk().expectBody(String.class).returnResult().getResponseBody();
System.out.println("=== spring.mvc.async.request-timeout=5s ===");
System.out.println(body);
assertThat(body).contains("\"effectiveAsyncTimeoutMs\":5000");
}
}
@@ -0,0 +1,35 @@
package com.ankurm.ssews;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.web.servlet.client.RestTestClient;
import static org.assertj.core.api.Assertions.assertThat;
/**
* With {@code spring.mvc.async.request-timeout} absent, the servlet container's default decides
* how long an SSE stream lives. This reads that number off the live {@code AsyncContext} rather
* than quoting it.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "dashboard.broadcast.enabled=false")
class AsyncTimeoutUnsetTest {
@LocalServerPort
int port;
@Test
void containerDefaultApplies() {
RestTestClient client = RestTestClient.bindToServer()
.baseUrl("http://localhost:" + port).build();
client.get().uri("/diag/async-start").exchange().expectStatus().isOk();
String body = client.get().uri("/diag/async-timeout").exchange()
.expectStatus().isOk().expectBody(String.class).returnResult().getResponseBody();
System.out.println("=== spring.mvc.async.request-timeout ABSENT ===");
System.out.println(body);
assertThat(body).contains("\"spring.mvc.async.request-timeout\":\"<unset>\"");
assertThat(body).contains("\"effectiveAsyncTimeoutMs\":30000");
}
}
@@ -0,0 +1,67 @@
package com.ankurm.ssews;
import java.time.Instant;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import jakarta.websocket.ContainerProvider;
import jakarta.websocket.WebSocketContainer;
import org.springframework.messaging.converter.JacksonJsonMessageConverter;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.messaging.WebSocketStompClient;
/**
* Sends one STOMP message of a given body size and reports whether it arrived.
*
* <p>The client's own buffers are raised to 1&nbsp;MB first. Without that the client hits the
* identical 8&nbsp;KB default and closes its own session, which looks exactly like the server
* rejecting the message &mdash; a false positive that cost a debugging round while writing this.
*/
final class SizeProbe {
private SizeProbe() {
}
static String attempt(int port, int size) throws Exception {
WebSocketContainer container = ContainerProvider.getWebSocketContainer();
container.setDefaultMaxTextMessageBufferSize(1024 * 1024);
WebSocketStompClient client = new WebSocketStompClient(new StandardWebSocketClient(container));
client.setMessageConverter(new JacksonJsonMessageConverter());
client.setInboundMessageSizeLimit(1024 * 1024);
StompSession session = client.connectAsync("ws://localhost:" + port + "/ws",
new StompSessionHandlerAdapter() { }).get();
BlockingQueue<ChatMessage> inbox =
StompTestSupport.subscribe(session, "/topic/room", ChatMessage.class);
Thread.sleep(200);
String result;
try {
session.send("/app/chat.send", new ChatMessage("bulk", "x".repeat(size), Instant.now()));
ChatMessage received = inbox.poll(4, TimeUnit.SECONDS);
result = received != null
? "delivered, body length " + received.text().length()
: "NOT delivered, connection dropped";
}
catch (Exception ex) {
Throwable root = ex;
while (root.getCause() != null) {
root = root.getCause();
}
result = "NOT delivered: " + root.getMessage();
}
client.stop();
return result;
}
/** Binary search for the largest body that still arrives. */
static int[] ceiling(int port, int lo, int hi) throws Exception {
while (hi - lo > 64) {
int mid = (lo + hi) / 2;
if (attempt(port, mid).startsWith("delivered")) { lo = mid; } else { hi = mid; }
}
return new int[] {lo, hi};
}
}
@@ -0,0 +1,71 @@
package com.ankurm.ssews;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* A raw-socket SSE client.
*
* <p>Deliberately not an HTTP client library: the article quotes the bytes on the wire, and any
* client that parses events for you hides exactly the thing being shown. It also lets a test
* abandon the socket mid-stream, which is how the disconnect case is reproduced.
*/
final class SseClient implements AutoCloseable {
private final Socket socket;
private final BufferedReader reader;
private final List<String> lines = new ArrayList<>();
SseClient(int port, String path) throws Exception {
this(port, path, null);
}
SseClient(int port, String path, String lastEventId) throws Exception {
this.socket = new Socket("127.0.0.1", port);
this.socket.setSoTimeout(15_000);
StringBuilder req = new StringBuilder()
.append("GET ").append(path).append(" HTTP/1.1\r\n")
.append("Host: 127.0.0.1:").append(port).append("\r\n")
.append("Accept: text/event-stream\r\n");
if (lastEventId != null) {
req.append("Last-Event-ID: ").append(lastEventId).append("\r\n");
}
req.append("Connection: close\r\n\r\n");
OutputStream out = socket.getOutputStream();
out.write(req.toString().getBytes(StandardCharsets.UTF_8));
out.flush();
this.reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
}
/** Read until the stream closes or {@code max} lines have arrived. Records everything. */
List<String> read(int max) throws Exception {
String line;
while (lines.size() < max && (line = reader.readLine()) != null) {
lines.add(line);
}
return List.copyOf(lines);
}
/** Read everything until the server closes the connection. */
List<String> readToEnd() throws Exception {
String line;
while ((line = reader.readLine()) != null) {
lines.add(line);
}
return List.copyOf(lines);
}
List<String> lines() {
return List.copyOf(lines);
}
@Override
public void close() throws Exception {
socket.close();
}
}
@@ -0,0 +1,95 @@
package com.ankurm.ssews;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.apache.tomcat.util.threads.ThreadPoolExecutor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.tomcat.TomcatWebServer;
import org.springframework.boot.web.server.context.WebServerApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Forty open SSE streams on a ten-thread Tomcat.
*
* <p>This is the test that answers the objection people raise first &mdash; "doesn't SSE pin a
* thread per client?" It does not. {@code SseEmitter} puts the request into asynchronous mode,
* the request thread returns to the pool, and the response stays open with no thread attached to
* it. Forty concurrent streams on a pool of ten is the cheapest way to show that; the same test
* with a blocking handler deadlocks at the eleventh client.
*
* <p>What SSE does cost is a <em>socket</em> per stream, plus one serialise-and-write per stream
* per broadcast, both of which the transcript makes visible.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"dashboard.broadcast.enabled=false", "server.tomcat.threads.max=10"})
class SseConcurrencyTest {
private static final int CLIENTS = 40;
@LocalServerPort
int port;
@Autowired
EmitterRegistry registry;
@Autowired
WebServerApplicationContext context;
@Test
void fortyStreamsOnTenThreads() throws Exception {
List<SseClient> clients = new ArrayList<>();
try {
for (int i = 0; i < CLIENTS; i++) {
clients.add(new SseClient(port, "/sse/metrics"));
}
for (int i = 0; i < 200 && registry.open() < CLIENTS; i++) {
Thread.sleep(25);
}
// Read the pool size off THIS context's own connector. Counting threads by name
// does not work here: other @SpringBootTest contexts in the same JVM have their own
// http-nio pools, and with a random port Tomcat names its threads http-nio-auto-N,
// not http-nio-<actual port>. That mismatch made this assertion pass alone and fail
// in the full suite.
TomcatWebServer server = (TomcatWebServer) context.getWebServer();
ThreadPoolExecutor pool = (ThreadPoolExecutor) server.getTomcat().getConnector()
.getProtocolHandler().getExecutor();
System.out.println("=== server.tomcat.threads.max=10, clients=" + CLIENTS + " ===");
System.out.println("emitters open : " + registry.open());
System.out.println("connector pool size : " + pool.getPoolSize()
+ " (max " + pool.getMaximumPoolSize() + ")");
System.out.println("connector active : " + pool.getActiveCount());
assertThat(registry.open()).isEqualTo(CLIENTS);
assertThat(pool.getMaximumPoolSize()).isEqualTo(10);
assertThat(pool.getPoolSize()).isLessThanOrEqualTo(10);
int delivered = registry.broadcast("metric",
new MetricSnapshot(1, "node-a", 0.5, 300, Instant.now()));
System.out.println("one broadcast reached: " + delivered + " streams");
assertThat(delivered).isEqualTo(CLIENTS);
int received = 0;
for (SseClient c : clients) {
if (String.join("\n", c.read(12)).contains("event:metric")) {
received++;
}
}
System.out.println("clients that read it : " + received);
assertThat(received).isEqualTo(CLIENTS);
}
finally {
for (SseClient c : clients) {
try { c.close(); } catch (Exception ignored) { }
}
}
}
}
@@ -0,0 +1,70 @@
package com.ankurm.ssews;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@code .data(object)} and {@code .data(object, APPLICATION_JSON)} are not the same call.
* This test prints both results side by side.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "dashboard.broadcast.enabled=false")
class SseDataConversionTest {
@LocalServerPort
int port;
@Test
void mediaTypeDecidesTheConverter() throws Exception {
List<String> lines;
try (SseClient client = new SseClient(port, "/sse/payload")) {
lines = client.readToEnd();
}
String withJson = dataAfter(lines, "event:with-json");
String noMediaType = dataAfter(lines, "event:no-media-type");
System.out.println("=== SseEmitter.event().data(record, APPLICATION_JSON) ===");
System.out.println(withJson);
System.out.println("=== SseEmitter.event().data(record) [no media type] ===");
System.out.println(noMediaType);
assertThat(withJson).startsWith("{").contains("\"seq\":7").contains("\"host\":\"node-a\"");
// The finding: for a POJO they are IDENTICAL. No media type does not mean toString().
// send() asks the configured HttpMessageConverters which one canWrite(type, null), and
// for a record the first (and only) answer is the Jackson converter.
System.out.println("=== POJO: identical? " + withJson.equals(noMediaType) + " ===");
assertThat(noMediaType).isEqualTo(withJson);
String stringPlain = dataAfter(lines, "event:string-plain");
String stringJson = dataAfter(lines, "event:string-json");
System.out.println("=== String payload, no media type -> " + stringPlain);
System.out.println("=== String payload, APPLICATION_JSON -> " + stringJson);
// The finding, and it is the opposite of what I expected when writing this test: for a
// String the media type argument changes NOTHING. StringHttpMessageConverter supports
// MediaType.ALL and sits ahead of the Jackson converter in the list, so it claims the
// write even when you ask for application/json. There is no way to make send() quote a
// String -- which is good news if you are streaming pre-rendered JSON, and a trap if you
// expected a String field to be escaped for you.
assertThat(stringPlain).isEqualTo("{\"already\":\"json\"}");
assertThat(stringJson).isEqualTo(stringPlain);
}
private static String dataAfter(List<String> lines, String eventLine) {
int i = lines.indexOf(eventLine);
assertThat(i).as("event line %s present", eventLine).isGreaterThanOrEqualTo(0);
for (int j = i; j < lines.size(); j++) {
if (lines.get(j).startsWith("data:")) {
return lines.get(j).substring("data:".length());
}
}
throw new AssertionError("no data line after " + eventLine);
}
}
@@ -0,0 +1,61 @@
package com.ankurm.ssews;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
/**
* A disconnected SSE client is discovered by a failed write, not by an event.
*
* <p>This test opens a stream, walks away from the socket, and then counts how many broadcasts
* it takes before the server notices. The number is not one, and that is the whole problem: for
* that many ticks the server serialised a payload and wrote it into a socket nobody was reading.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "dashboard.broadcast.enabled=false")
class SseDisconnectTest {
@LocalServerPort
int port;
@Autowired
EmitterRegistry registry;
@Test
void deadClientIsFoundOnTheNextWriteOrTheOneAfter() throws Exception {
SseClient client = new SseClient(port, "/sse/metrics");
// Wait for the emitter to be registered.
for (int i = 0; i < 100 && registry.open() == 0; i++) {
Thread.sleep(20);
}
assertThat(registry.open()).isEqualTo(1);
long failedBefore = registry.sendsFailed();
client.close(); // the browser tab closes
int broadcastsUntilNoticed = 0;
for (int i = 1; i <= 50 && registry.open() > 0; i++) {
registry.broadcast("metric", new MetricSnapshot(i, "node-a", 0.1, 256, Instant.now()));
broadcastsUntilNoticed = i;
Thread.sleep(50);
}
System.out.println("=== client closed the socket, then: ===");
System.out.println("broadcasts before the write failed : " + broadcastsUntilNoticed);
System.out.println("registry.sendsOk : " + registry.sendsOk());
System.out.println("registry.sendsFailed : " + failedBefore + " -> " + registry.sendsFailed());
System.out.println("registry.open : " + registry.open());
assertThat(registry.open()).isZero();
assertThat(registry.sendsFailed()).isGreaterThan(failedBefore);
// The interesting assertion: it took more than one write. TCP accepted the first
// payloads into the socket buffer and only reported the reset afterwards.
System.out.println("=== writes that appeared to succeed after the client was gone: "
+ (broadcastsUntilNoticed - 1) + " ===");
}
}
@@ -0,0 +1,60 @@
package com.ankurm.ssews;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import static org.assertj.core.api.Assertions.assertThat;
/**
* What a client sees when the async request times out mid-stream.
*
* <p>The answer is the point: nothing. No error event, no status code, no trailer &mdash; the
* response was committed with 200 when the first byte went out, so the only thing left to do is
* close the socket. A browser's EventSource treats that as a normal disconnect and reconnects,
* which is why an SSE endpoint with a 30-second container timeout looks like it works.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "dashboard.broadcast.enabled=false")
class SseTimeoutTest {
@LocalServerPort
int port;
@Autowired
EmitterRegistry registry;
@Test
void timeoutIsInvisibleToTheClient() throws Exception {
long before = registry.timedOut();
List<String> lines;
long start = System.nanoTime();
try (SseClient client = new SseClient(port, "/sse/silent?timeoutMs=1200")) {
lines = client.readToEnd();
}
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
System.out.println("=== GET /sse/silent?timeoutMs=1200 : whole response ===");
lines.forEach(l -> System.out.println("| " + l));
System.out.println("=== stream ended after ~" + elapsedMs + " ms ===");
String whole = String.join("\n", lines);
assertThat(whole).contains("HTTP/1.1 200");
assertThat(whole).contains("data:then silence");
// Nothing marks the timeout. No 503, no error event, no "AsyncRequestTimeoutException".
assertThat(whole).doesNotContain("503");
assertThat(whole).doesNotContain("event:error");
assertThat(whole).doesNotContainIgnoringCase("timeout");
// Server-side it is not invisible: onTimeout ran.
Thread.sleep(300);
System.out.println("=== registry.timedOut(): " + before + " -> " + registry.timedOut() + " ===");
assertThat(registry.timedOut()).isGreaterThan(before);
assertThat(elapsedMs).isBetween(900L, 6000L);
}
}
@@ -0,0 +1,53 @@
package com.ankurm.ssews;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import static org.assertj.core.api.Assertions.assertThat;
/**
* What SSE actually looks like on the wire. Everything in the article's "the format" section is
* a quote from this test's output.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "dashboard.broadcast.enabled=false")
class SseWireFormatTest {
@LocalServerPort
int port;
@Test
void wireFormat() throws Exception {
List<String> lines;
try (SseClient client = new SseClient(port, "/sse/raw")) {
lines = client.readToEnd();
}
System.out.println("=== GET /sse/raw : raw bytes, one line per row ===");
lines.forEach(l -> System.out.println("| " + l));
String head = String.join("\n", lines);
// Content-Type is text/event-stream, and there is NO Content-Length: the response is
// chunked because its length is unknowable when the headers are written.
assertThat(head).contains("HTTP/1.1 200");
assertThat(head.toLowerCase()).contains("content-type: text/event-stream");
assertThat(head.toLowerCase()).doesNotContain("content-length:");
assertThat(head.toLowerCase()).contains("transfer-encoding: chunked");
// The four field names, and the comment line that keeps proxies from reaping an idle
// stream. A line starting with ':' is a comment and is discarded by the client.
assertThat(lines).anyMatch(l -> l.equals(":stream open"));
assertThat(lines).anyMatch(l -> l.equals("id:1"));
assertThat(lines).anyMatch(l -> l.equals("event:metric"));
assertThat(lines).anyMatch(l -> l.equals("retry:3000"));
assertThat(lines).anyMatch(l -> l.startsWith("data:{"));
// Multi-line data becomes SEVERAL data: lines; the client rejoins them with "\n".
assertThat(lines).contains("data:line one");
assertThat(lines).contains("data:line two");
}
}
@@ -0,0 +1,74 @@
package com.ankurm.ssews;
import java.time.Instant;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.web.socket.messaging.WebSocketStompClient;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Two clients, one room. Everything a chat needs that SSE cannot do: the client speaks, and the
* server knows who is connected.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "dashboard.broadcast.enabled=false")
class StompChatTest {
@LocalServerPort
int port;
@Autowired
PresenceListener presence;
@Test
void twoClientsInOneRoom() throws Exception {
presence.clear();
WebSocketStompClient client = StompTestSupport.client();
StompSession alice = StompTestSupport.connect(client, port);
StompSession bob = StompTestSupport.connect(client, port);
BlockingQueue<ChatMessage> aliceInbox =
StompTestSupport.subscribe(alice, "/topic/room", ChatMessage.class);
BlockingQueue<ChatMessage> bobInbox =
StompTestSupport.subscribe(bob, "/topic/room", ChatMessage.class);
Thread.sleep(300);
alice.send("/app/chat.send", new ChatMessage("alice", "is this thing on?", Instant.now()));
ChatMessage atAlice = aliceInbox.poll(5, TimeUnit.SECONDS);
ChatMessage atBob = bobInbox.poll(5, TimeUnit.SECONDS);
System.out.println("=== SEND /app/chat.send -> @SendTo(\"/topic/room\") ===");
System.out.println("alice received : " + atAlice);
System.out.println("bob received : " + atBob);
// The sender gets its own message back. There is no echo suppression: a topic is a fan-out
// to every subscriber, and the sender is one of them.
assertThat(atAlice).isNotNull();
assertThat(atBob).isNotNull();
assertThat(atAlice.text()).isEqualTo("is this thing on?");
assertThat(atAlice).isEqualTo(atBob);
Thread.sleep(300);
System.out.println("=== presence events the server saw ===");
presence.events().forEach(e -> System.out.println("| " + e));
assertThat(presence.events()).anyMatch(e -> e.startsWith("CONNECTED"));
assertThat(presence.events()).anyMatch(e -> e.contains("SUBSCRIBE") && e.contains("/topic/room"));
alice.disconnect();
bob.disconnect();
Thread.sleep(500);
presence.events().stream().filter(e -> e.startsWith("DISCONNECT"))
.forEach(e -> System.out.println("| " + e));
assertThat(presence.events()).anyMatch(e -> e.startsWith("DISCONNECT"));
client.stop();
}
}
@@ -0,0 +1,76 @@
package com.ankurm.ssews;
import java.time.Instant;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.web.socket.messaging.WebSocketStompClient;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The two routing rules that eat an afternoon each.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "dashboard.broadcast.enabled=false")
class StompRoutingTest {
@LocalServerPort
int port;
/**
* A handler with no {@code @SendTo} is not private. Its return value goes to the broker
* prefix plus the mapping.
*/
@Test
void defaultDestinationIsTopicPlusMapping() throws Exception {
WebSocketStompClient client = StompTestSupport.client();
StompSession session = StompTestSupport.connect(client, port);
BlockingQueue<ChatMessage> guessed =
StompTestSupport.subscribe(session, "/topic/chat.echo", ChatMessage.class);
Thread.sleep(300);
session.send("/app/chat.echo", new ChatMessage("mallory", "who can see this?", Instant.now()));
ChatMessage received = guessed.poll(5, TimeUnit.SECONDS);
System.out.println("=== @MessageMapping(\"/chat.echo\") with NO @SendTo ===");
System.out.println("subscribed to /topic/chat.echo, received: " + received);
assertThat(received).isNotNull();
assertThat(received.text()).isEqualTo("who can see this?");
client.stop();
}
/**
* Sending straight to a broker destination bypasses every controller. No handler runs, no
* validation runs, and nothing reports that anything was skipped &mdash; subscribers just get
* whatever the client sent.
*/
@Test
void sendingToTopicSkipsTheController() throws Exception {
WebSocketStompClient client = StompTestSupport.client();
StompSession session = StompTestSupport.connect(client, port);
BlockingQueue<ChatMessage> inbox =
StompTestSupport.subscribe(session, "/topic/room", ChatMessage.class);
Thread.sleep(300);
// Note the destination: /topic/room, NOT /app/chat.send. The controller never sees it.
ChatMessage forged = new ChatMessage("not-checked-by-anyone", "unvalidated", Instant.parse("2000-01-01T00:00:00Z"));
session.send("/topic/room", forged);
ChatMessage received = inbox.poll(5, TimeUnit.SECONDS);
System.out.println("=== SEND straight to /topic/room ===");
System.out.println("received: " + received);
assertThat(received).isNotNull();
// The controller would have replaced the timestamp with Instant.now(). It did not run.
assertThat(received.at()).isEqualTo(Instant.parse("2000-01-01T00:00:00Z"));
assertThat(received.from()).isEqualTo("not-checked-by-anyone");
System.out.println("=== the controller's Instant.now() rewrite did NOT happen ===");
client.stop();
}
}
@@ -0,0 +1,39 @@
package com.ankurm.ssews;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Where a STOMP message really stops being deliverable, with nothing configured.
*
* <p>The documented knob is {@code WebSocketTransportRegistration.setMessageSizeLimit}, 64&nbsp;KB
* by default and set explicitly in {@link WebSocketConfig}. The limit that actually fires is the
* servlet container's WebSocket buffer, and it fires as a connection close (WebSocket status
* 1009) rather than as an error the application can see &mdash; so raising Spring's limit alone
* changes nothing at all.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "dashboard.broadcast.enabled=false")
class StompSizeLimitDefaultBufferTest {
@LocalServerPort
int port;
@Test
void theCeilingIsWellUnderSpringsSixtyFourKilobytes() throws Exception {
System.out.println("=== container buffer: Tomcat default (8192, see /diag) ===");
System.out.println(" 8 KB body -> " + SizeProbe.attempt(port, 8 * 1024));
System.out.println(" 16 KB body -> " + SizeProbe.attempt(port, 16 * 1024));
System.out.println(" 60 KB body -> " + SizeProbe.attempt(port, 60 * 1024));
int[] edge = SizeProbe.ceiling(port, 1024, 64 * 1024);
System.out.println("largest body delivered : " + edge[0] + " bytes");
System.out.println("smallest body rejected : " + edge[1] + " bytes");
// Measured, not remembered. Spring's messageSizeLimit is 65 536 and is never reached.
assertThat(edge[0]).isBetween(15_000, 17_000);
}
}
@@ -0,0 +1,33 @@
package com.ankurm.ssews;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import static org.assertj.core.api.Assertions.assertThat;
/**
* With the container buffer at 256&nbsp;KB, Spring's own 64&nbsp;KB limit is finally the one that
* decides &mdash; and it fails differently. The container's refusal is a bare close; Spring's is
* a STOMP {@code ERROR} frame, logged, with the byte count in it. Two limits, two symptoms, and
* only the second one tells you what happened.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"dashboard.broadcast.enabled=false", "spring.profiles.active=bigframes"})
class StompSizeLimitSpringLimitTest {
@LocalServerPort
int port;
@Test
void springsLimitDecidesOnceTheContainerIsOutOfTheWay() throws Exception {
System.out.println("=== bigframes profile: container buffer 256 KB, Spring limit 64 KB ===");
System.out.println(" 16 KB body -> " + SizeProbe.attempt(port, 16 * 1024));
System.out.println(" 60 KB body -> " + SizeProbe.attempt(port, 60 * 1024));
System.out.println(" 80 KB body -> " + SizeProbe.attempt(port, 80 * 1024));
System.out.println("200 KB body -> " + SizeProbe.attempt(port, 200 * 1024));
assertThat(SizeProbe.attempt(port, 60 * 1024)).startsWith("delivered");
assertThat(SizeProbe.attempt(port, 80 * 1024)).doesNotStartWith("delivered");
}
}
@@ -0,0 +1,31 @@
package com.ankurm.ssews;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The same binary search with the container buffer raised, which is what turns "raise the
* buffer" into a rule rather than folklore. Run with {@code -Dwsbuf=<bytes>} to move it; the
* committed transcript covers 8&nbsp;KB (untouched), 16&nbsp;KB, 32&nbsp;KB and 64&nbsp;KB.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"dashboard.broadcast.enabled=false",
"spring.profiles.active=bigframes", "ws.buffer-bytes=${wsbuf:32768}"})
class StompSizeLimitTunedBufferTest {
@LocalServerPort
int port;
@Test
void ceilingTracksTheContainerBuffer() throws Exception {
String buf = System.getProperty("wsbuf", "32768");
System.out.println("=== container buffer set to " + buf + " ===");
int[] edge = SizeProbe.ceiling(port, 1024, 200 * 1024);
System.out.println("largest body delivered : " + edge[0] + " bytes");
System.out.println("smallest body rejected : " + edge[1] + " bytes");
assertThat(edge[0]).isGreaterThan(30_000);
}
}
@@ -0,0 +1,58 @@
package com.ankurm.ssews;
import java.lang.reflect.Type;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import org.springframework.messaging.converter.JacksonJsonMessageConverter;
import org.springframework.messaging.simp.stomp.StompFrameHandler;
import org.springframework.messaging.simp.stomp.StompHeaders;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.messaging.WebSocketStompClient;
/**
* A real STOMP-over-WebSocket client, so the chat tests exercise the same code path a browser
* does &mdash; handshake, CONNECT/CONNECTED, SUBSCRIBE, SEND, MESSAGE.
*
* <p>Note the converter: {@link JacksonJsonMessageConverter} is the Jackson&nbsp;3 one.
* {@code MappingJackson2MessageConverter} is still on the classpath and is the Jackson&nbsp;2
* one; under Boot 4 a "2" in a Spring class name means the <em>previous</em> Jackson. Picking
* the wrong one here fails on the {@code Instant} field, exactly as it does for Kafka and AMQP
* in the sibling modules.
*/
final class StompTestSupport {
private StompTestSupport() {
}
static WebSocketStompClient client() {
WebSocketStompClient client = new WebSocketStompClient(new StandardWebSocketClient());
client.setMessageConverter(new JacksonJsonMessageConverter());
return client;
}
static StompSession connect(WebSocketStompClient client, int port) throws Exception {
return client.connectAsync("ws://localhost:" + port + "/ws",
new StompSessionHandlerAdapter() { }).get();
}
/** Subscribe and collect frames of one payload type into a queue the test can poll. */
static <T> BlockingQueue<T> subscribe(StompSession session, String destination, Class<T> type) {
BlockingQueue<T> queue = new LinkedBlockingQueue<>();
session.subscribe(destination, new StompFrameHandler() {
@Override
public Type getPayloadType(StompHeaders headers) {
return type;
}
@Override
@SuppressWarnings("unchecked")
public void handleFrame(StompHeaders headers, Object payload) {
queue.add((T) payload);
}
});
return queue;
}
}
@@ -0,0 +1,81 @@
package com.ankurm.ssews;
import java.time.Instant;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.web.socket.messaging.WebSocketStompClient;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Point-to-point delivery, and the thing that makes it work: {@code /user/**} is not a real
* destination. The broker rewrites it per session, so two clients subscribed to the same string
* are subscribed to different queues.
*
* <p>Without Spring Security in the picture, the "user" is the STOMP session id. That is enough
* to demonstrate the routing and is also a trap worth naming: with no {@code Principal}, a user
* destination is session-scoped, so a second browser tab is a different user and a reconnect
* loses the mailbox.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "dashboard.broadcast.enabled=false")
class StompUserDestinationTest {
@LocalServerPort
int port;
@Test
void onlyTheAddressedSessionReceives() throws Exception {
WebSocketStompClient client = StompTestSupport.client();
StompSession alice = StompTestSupport.connect(client, port);
StompSession bob = StompTestSupport.connect(client, port);
BlockingQueue<ChatMessage> aliceInbox =
StompTestSupport.subscribe(alice, "/user/queue/whisper", ChatMessage.class);
BlockingQueue<ChatMessage> bobInbox =
StompTestSupport.subscribe(bob, "/user/queue/whisper", ChatMessage.class);
Thread.sleep(400);
// Discovering Alice's session id is itself the lesson. StompSession.getSessionId() on
// the CLIENT returns the client's own identifier, which is NOT the id the server keeps
// in SimpMessageHeaderAccessor and NOT the one the user destination is built from.
// Whispering to it delivers nothing, silently. The server-side id has to come from the
// server: /app/chat.send echoes it back when "from" is null.
BlockingQueue<ChatMessage> aliceRoom =
StompTestSupport.subscribe(alice, "/topic/room", ChatMessage.class);
Thread.sleep(200);
alice.send("/app/chat.send", new ChatMessage(null, "who am i?", Instant.now()));
ChatMessage identity = aliceRoom.poll(5, TimeUnit.SECONDS);
assertThat(identity).isNotNull();
String aliceServerSession = identity.from();
System.out.println("alice.getSessionId() [client side] : " + alice.getSessionId());
System.out.println("server-side session id : " + aliceServerSession);
assertThat(aliceServerSession).isNotEqualTo(alice.getSessionId());
bob.send("/app/chat.whisper",
new ChatMessage(aliceServerSession, "just for you", Instant.now()));
ChatMessage atAlice = aliceInbox.poll(5, TimeUnit.SECONDS);
ChatMessage atBob = bobInbox.poll(1500, TimeUnit.MILLISECONDS);
System.out.println("=== convertAndSendToUser(aliceSession, \"/queue/whisper\", ..) ===");
System.out.println("both subscribed to the SAME string : /user/queue/whisper");
System.out.println("alice received : " + atAlice);
System.out.println("bob received : " + atBob);
assertThat(atAlice).isNotNull();
assertThat(atAlice.text()).isEqualTo("just for you");
assertThat(atBob).as("the other session must not see it").isNull();
alice.disconnect();
bob.disconnect();
client.stop();
}
}
@@ -0,0 +1,30 @@
package com.ankurm.ssews;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.web.servlet.client.RestTestClient;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Reads the container's own WebSocket buffer sizes rather than quoting a default from memory.
* Nothing in this application sets them.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "dashboard.broadcast.enabled=false")
class WebSocketContainerDefaultsTest {
@LocalServerPort
int port;
@Test
void printThem() {
String body = RestTestClient.bindToServer().baseUrl("http://localhost:" + port).build()
.get().uri("/diag").exchange().expectStatus().isOk()
.expectBody(String.class).returnResult().getResponseBody();
System.out.println("=== /diag with no bigframes profile ===");
System.out.println(body);
assertThat(body).contains("\"ws.container.defaultMaxTextMessageBufferSize\":8192");
}
}
@@ -0,0 +1,27 @@
package com.ankurm.ssews;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.web.servlet.client.RestTestClient;
import static org.assertj.core.api.Assertions.assertThat;
/** The {@code bigframes} profile's ServletServerContainerFactoryBean, verified to have landed. */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"dashboard.broadcast.enabled=false", "spring.profiles.active=bigframes"})
class WebSocketContainerRaisedTest {
@LocalServerPort
int port;
@Test
void printThem() {
String body = RestTestClient.bindToServer().baseUrl("http://localhost:" + port).build()
.get().uri("/diag").exchange().expectStatus().isOk()
.expectBody(String.class).returnResult().getResponseBody();
System.out.println("=== /diag with the bigframes profile ===");
System.out.println(body);
assertThat(body).contains("\"ws.container.defaultMaxTextMessageBufferSize\":262144");
}
}
@@ -0,0 +1,76 @@
package com.ankurm.ssews;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import static org.assertj.core.api.Assertions.assertThat;
/**
* What {@code registry.addEndpoint("/ws")} with no {@code setAllowedOrigins} actually permits.
*
* <p>This matters because the WebSocket handshake is a plain HTTP GET, and the browser's
* same-origin policy does <em>not</em> apply to it: a page on any site can open a WebSocket to
* your server, with the user's cookies attached, unless the server checks {@code Origin}. Spring
* checks it by default. A non-browser client sends no {@code Origin} at all and is allowed
* through, which is why the Java tests in this module connect without ceremony.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "dashboard.broadcast.enabled=false")
class WebSocketHandshakeOriginTest {
@LocalServerPort
int port;
@Test
void originIsCheckedByDefault() throws Exception {
System.out.println("=== GET /ws handshake, varying only the Origin header ===");
String noOrigin = handshake(null);
String sameOrigin = handshake("http://localhost:" + port);
String foreignOrigin = handshake("https://evil.example.com");
System.out.println("no Origin header -> " + noOrigin);
System.out.println("Origin: http://localhost -> " + sameOrigin);
System.out.println("Origin: https://evil... -> " + foreignOrigin);
assertThat(noOrigin).startsWith("HTTP/1.1 101");
assertThat(sameOrigin).startsWith("HTTP/1.1 101");
assertThat(foreignOrigin).startsWith("HTTP/1.1 403");
}
private String handshake(String origin) throws Exception {
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.setSoTimeout(10_000);
StringBuilder req = new StringBuilder()
.append("GET /ws HTTP/1.1\r\n")
.append("Host: localhost:").append(port).append("\r\n")
.append("Upgrade: websocket\r\n")
.append("Connection: Upgrade\r\n")
.append("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n")
.append("Sec-WebSocket-Version: 13\r\n");
if (origin != null) {
req.append("Origin: ").append(origin).append("\r\n");
}
req.append("\r\n");
OutputStream out = socket.getOutputStream();
out.write(req.toString().getBytes(StandardCharsets.UTF_8));
out.flush();
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
List<String> lines = new ArrayList<>();
String line;
while ((line = in.readLine()) != null && !line.isEmpty()) {
lines.add(line);
}
return lines.isEmpty() ? "<no response>" : lines.get(0).trim();
}
}
}