RSocket vs gRPC vs WebSocket on Spring Boot 4.1: When Each One Wins
One Spring Boot 4.1 application serving the same two operations over gRPC, RSocket and a raw WebSocket, benchmarked in a single JVM. Raw WebSocket is the fastest and has the worst tail. protobuf is 35 bytes where JSON is 83. And for a consumer that asked for a hundred messages then went quiet, the three servers produced 100, 1,208,219 and 320,255 — which is the number that decides it.
Most comparisons of RSocket, gRPC and WebSocket are comparisons of three different applications. Three demos, three serialisation libraries, three JIT states, three thread pools — and then a throughput number presented as a property of the protocol.
This one is a single Spring Boot 4.1 application that serves the same two operations over all three: fetch one quote, and stream N quotes. Same JVM, same heap, same generator, same five fields. A difference in the numbers is a difference in the transports.
The results are not what the marketing for any of the three would lead you to expect. Raw WebSocket is the fastest and has by far the worst tail latency. gRPC and RSocket are indistinguishable for request/response. And then there is the measurement that actually decides the choice, which none of the speed benchmarks hint at: for a consumer that asked for a hundred messages and then went quiet for two seconds, the three servers produced 100, 1,208,219 and 320,255 messages respectively.
Everything below was run against a live server on Spring Boot 4.1.1; the companion module re-measures all of it with one command.
If you want…
Read
what each one is, and what the code looks like
Part 1 — three protocols, one use case
the numbers, and why a loopback benchmark flatters the wrong one
Part 2 — latency, throughput, payload
the measurement that decides it
Part 3 — back-pressure
Versions (September 2026): Verified against Spring Boot 4.1.1, Spring Framework 7.0.9, grpc-java 1.83.1, protobuf-java 4.35.1, rsocket-java 1.1.5, Spring gRPC 1.1.1, Jackson 3.1.5 and JDK 25.0.4.1. Note that Boot 4.1.0 pinned grpc-java 1.80.0 and protobuf-java 4.34.2 — those moved inside the 4.1 line, so a guide written a few months ago is describing different jars. Versions read from maven-metadata.xml and Boot's own spring-boot-dependencies POM.
Part 1 — Three protocols, one use case
gRPC
RSocket
Raw WebSocket
Transport
HTTP/2
TCP, WebSocket, others
HTTP/1.1 Upgrade
Schema
.proto, mandatory
none required
none
Default encoding here
protobuf
CBOR
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
absolute, propagating
none built in
none
Browser client
grpc-web + a proxy
yes, over WebSocket
yes, natively
Boot 4 starter
-grpc-server / -grpc-client
-rsocket
-websocket
The three server sides are worth reading next to each other, because the shape of the code is most of the argument.
gRPC is generated-class inheritance, with no registration code — the generated ImplBase is a BindableService and Boot registers every such bean:
RSocket is annotation routing, and the interaction model is the return type:
@MessageMapping("quote")
public Mono<Quote> quote(String symbol) { … } // request/response
@MessageMapping("quotes")
public Flux<Quote> quotes(StreamSpec spec) { … } // request/stream
Raw WebSocket is a switch on a string, because there is nothing else:
switch (parts[0]) {
case "QUOTE" -> send(session, source.at(parts[1], 0));
case "STREAM" -> { for (int i = 0; i < count; i++) { send(session, source.at(parts[1], i)); } }
default -> session.sendMessage(new TextMessage("ERR unknown command"));
}
That third block is the honest summary of a raw WebSocket. You get a bidirectional pipe for text or bytes. Routing, correlation, error shape, versioning and demand are all yours to invent — and inventing them is how a team ends up with a private, undocumented protocol that only its authors can debug. If you find yourself designing a text protocol with a command word at the front, as the module above deliberately does, that is the moment to stop and use STOMP instead. The companion article on SSE and STOMP is the browser-facing half of this decision.
Part 2 — The numbers, and how to read them
Read this before the tables. Client and server are the same process on one machine, over loopback: two cores, 3.9 GB, JDK 25. Three consequences. (1) There is no network — on a real link a 35-byte message and an 83-byte message differ by more than framing cost, and protobuf's size advantage starts paying; here it cannot. (2) A protocol that does less looks faster — raw WebSocket wins both speed benchmarks partly because it has no flow control, no deadlines, no schema and no per-call metadata, which is a real cost difference and also exactly what you are giving up. (3) Ratios, not absolutes — microsecond figures from a two-core sandbox mean nothing for your hardware; the ordering and the rough factors are what transfer.
Request/response
5,000 sequential calls after 2,000 warm-up:
gRPC n=5000 p50= 302.0 us p99= 994.7 us mean= 335.1 us ~2,984 calls/s
RSocket n=5000 p50= 265.9 us p99= 1166.2 us mean= 311.9 us ~3,206 calls/s
WebSocket n=5000 p50= 122.8 us p99= 1893.9 us mean= 184.8 us ~5,410 calls/s
gRPC and RSocket are the same speed. Across runs the two swap places; treat them as indistinguishable for request/response on a warm connection, and choose between them on something other than latency.
Raw WebSocket is about twice as fast at the median — and has the worst tail. A p50 of 123 µs against a p99 of 1,894 µs is a 15x spread. gRPC's is 3.3x. 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 lands on the request undiluted. If your SLO is a percentile rather than an average — and it should be — that table does not say what a first glance suggests.
Server streaming
50,000 messages on one connection:
gRPC 50,000 msgs in 1.341 s = 37,285 msgs/s ( 26.82 us each)
RSocket 50,000 msgs in 0.900 s = 55,580 msgs/s ( 17.99 us each)
WebSocket 50,000 msgs in 0.839 s = 59,630 msgs/s ( 16.77 us each)
Same ordering, wider gaps: across repeated runs WebSocket lands between 1.6x and 2.4x gRPC, and RSocket between 1.5x and 1.6x. The WebSocket figure is the least stable of the three, which is itself consistent with the tail-latency result above.
A note on how these were run, because it changed the answer. The first time I captured this table, the benchmark shared a JVM with the back-pressure tests from Part 3 — which leave unbounded producers spinning. Every throughput number came out roughly half what it should have been: gRPC read 18,477 msgs/s instead of 37,285. The committed transcripts now come from a separate mvn test invocation that runs the two timing benchmarks and nothing else. If you take one operational lesson from this module rather than a protocol one, let it be that: a benchmark sharing a machine with anything is measuring the machine.
Now read the WebSocket row again with its test 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 — 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 Part 3.
Payload: the one comparison that transfers
Five fields, three encodings. This is a property of the formats rather than of a machine, so unlike everything else above it is true on your hardware too:
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 — the name lives in the .proto that both sides compiled against. The saving and the coupling are the same fact.
CBOR is 67 bytes and still writes the field names.6673796d626f6c is a six-character text string, symbol. CBOR is binary JSON, not a schema format: it drops the punctuation and encodes numbers compactly, and keeps every key. So its advantage over JSON is real but modest — and it does not require the coupling.
Spring's RSocket default is CBOR, not JSON. That is why spring-boot-starter-rsocket drags in jackson-dataformat-cbor, and it is why your payloads are unreadable when you go looking for them in tcpdump. The mechanism is codec ordering, and it is worth printing once:
CBOR sits ahead of JSON in the Boot-configured list, which is the whole of why it wins — and a bare RSocketStrategies.create() has neither, so an RSocketRequester you build without injecting Boot's strategies cannot encode your objects at all. (Note the class names, too: JacksonCborEncoder, not Jackson2… — under Boot 4 the unnumbered name is the current Jackson.) Note also that the encoding and the protocol are independent choices — you can put protobuf on RSocket. The table above is what each stack does by default, not what it is capable of.
Part 3 — Back-pressure, and the number that decides it
Every protocol comparison eventually reduces to one question, and it is not throughput:
When the consumer stops keeping up, what happens?
The companion module 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.
RSocket produced exactly one hundred, at every quiet period. gRPC produced over a million in two seconds. Raw WebSocket, three hundred thousand. These are re-measured on every run and the absolute counts move by tens of percent between runs; the three orders of magnitude between the rows do not. (Growth is faster than linear because the loop is still being JIT-compiled during the shortest run; the shape, not the exponent, is the finding.)
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 generator is invoked exactly a hundred times and then not again. Nothing blocks. There is no buffer to size and no thread parked — the producer is simply not called.
That is what “Reactive Streams over the network” means, and it is the entire reason RSocket exists. It is also why RSocket's streaming throughput above is respectable rather than the highest: it is doing real work per batch that the other two 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 — a socket buffer, Spring's sendBufferSizeLimit, the heap. Every one of those 320,255 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(), so rather than cite it, the companion module runs the identical scenario against a handler that checks it before every write:
It did not bound the producer. The isReady() row is not lower than the plain one in any useful sense — it produced 667 thousand messages in half a second for a client that wanted a hundred — and across runs it has come out both far above and far below the plain loop, which is its own kind of answer. 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: a paged API, an explicit “send me more” message on a bidirectional stream, or a count in the request.
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 — by blocking a thread or by filling a buffer, but they would stop.
That does not rescue them, because the failure 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, as a number, that the producer can act on before anything fills up.
Choosing
If
Pick
Because
Service-to-service RPC, polyglot team, you want a schema
gRPC
The .proto is a contract other languages generate from, and deadlines are absolute and propagate
A stream whose consumer can fall behind
RSocket
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's browser ecosystem is thin
Server pushes, client never speaks
none of these — SSE
One HTTP response and a return type
Lowest median latency, you own both ends
raw WebSocket
Fastest because it does the least — read the p99 first
What each really costs:
gRPC costs a build step, a schema you must version, and an operational story for HTTP/2 through your proxies. In exchange: the best-supported cross-language RPC there is, and deadlines — 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, client libraries outside Java and JavaScript are thin, and Mono and Flux in your service signatures is a real commitment if you are not already reactive. In exchange: the only demand signalling in the list, plus resumption and leasing.
Raw WebSocket costs everything you then invent, including the demand mechanism you will eventually need.
Three cases where the answer is none of them.(1) Request/response between two services that already speak HTTP. 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 what you need, not because it benchmarks faster. (2) 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. (3) Events that must outlive the connection. None of these three persist anything; a dropped connection loses whatever was in flight. If that matters, the answer is a broker.
A few things that did not fit
Abandoning a gRPC stream logs IllegalStateException: call already closed. 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. Check Context.current().isCancelled() and return without calling onCompleted.
Boot's BOM pins grpc-java and protobuf-java deliberately older than Maven Central. Overriding them independently is how you get a NoSuchMethodError between grpc-java and its shaded Netty.
protoc needs no local install. The compiler and the gRPC codegen plugin resolve as Maven artifacts for your OS and architecture, so the companion module builds anywhere with a JDK.
RSocket has four interaction models and Spring picks by return type.Mono is request/response, Flux is request/stream, void plus a Mono<Void> return is fire-and-forget, and a Flux parameter makes it a channel.
Both benchmarks are re-measured on every run. They are not committed constants, and the numbers in this article will not reproduce exactly on your machine. The ordering should.
Reproducing this
git clone https://ankurm.com/git.app/asmhatre/spring-messaging-demo.git
cd spring-messaging-demo/protocol-comparison
./scripts/run-all.sh
Five tests, about a minute, nothing to install but a JDK. All three servers start inside the test JVM and every transcript quoted above is regenerated into docs/output/.
No Comments yet!