From REST to gRPC: Real-Time Java Streaming with Quarkus and Mutiny
Discover how to move beyond REST and build high-performance streaming APIs in Java with Quarkus, Mutiny, and gRPC — including a working leaderboard demo.
When you score in a game, the leaderboard updates before you even reload. That kind of responsiveness feels magical. But behind the magic are streaming systems designed to push updates instantly to connected players.
In this hands-on tutorial, you’ll build one yourself: A real-time leaderboard powered by Quarkus and gRPC.
You’ll start with a streaming backend that manages player scores and broadcasts updates live. Then you’ll create a standalone Java client with @QuarkusMain that connects, submits scores, and listens to the stream.
Everything runs locally. Everything’s reactive. And you’ll walk away understanding the gRPC fundamentals: unary calls, server streaming, and how Quarkus makes it all ridiculously simple.
Prerequisites
You’ll need:
Java 21+
Quarkus CLI (
quarkus --version)Optional:
grpcurlfor testing (brew install grpcurlor via package manager)15 minutes and a few open terminals
What We’re Building
We’re building two Quarkus apps:
Leaderboard Server – exposes gRPC APIs:
submitScore()– client sends a player name and score (unary RPC)watchLeaderboard()– client subscribes to live score updates (server streaming)
Leaderboard Client – a command-line app using
@QuarkusMainConnects to the server
Submits random scores
Prints leaderboard updates in real time
This setup mirrors a real multiplayer game backend. The server keeps state, the clients react instantly.
If you do not want to follow along and build this yourself, feel free to just clone my Github repository which has a lot of examples including this project.
Project Setup
We will be creating server and client in
mkdir leaderboardLet’s start with the server.
In your terminal, create a new Quarkus project:
cd leaderboard
quarkus create app com.example:leaderboard-grpc \
--extension=grpc
cd leaderboard-grpcThis generates a Quarkus skeleton with gRPC support. You’ll see a src/main/proto folder with a hello.proto file and a target/generated-sources/grpc folder where Quarkus generates Java stubs from your service definition.
Make sure to add the following line into application.properties
quarkus.grpc.server.use-separate-server=falseThat’s all you need. Quarkus handles the gRPC server bootstrap automatically. Ahhh, before I forget: Delete the tests :-)
Define the API
The contract comes first. gRPC uses Protocol Buffers to define the structure of your messages and RPCs.
Rename hello.proto to src/main/proto/leaderboard.proto: and change the content:
syntax = “proto3”;
import “google/protobuf/empty.proto”;
option java_multiple_files = true;
option java_package = “com.example.leaderboard”;
option java_outer_classname = “LeaderboardProto”;
message SubmitScoreRequest {
string player = 1;
int32 score = 2;
}
message ScoreResponse {
bool success = 1;
string message = 2;
}
message LeaderboardEntry {
string player = 1;
int32 score = 2;
}
service LeaderboardService {
rpc submitScore(SubmitScoreRequest) returns (ScoreResponse);
rpc watchLeaderboard(google.protobuf.Empty) returns (stream LeaderboardEntry);
}What’s Happening Here
Messages are data contracts, similar to DTOs in REST.
Services define callable RPCs.
submitScore()is unary — one request, one response.watchLeaderboard()is server streaming — one request, many responses over time.google.protobuf.Emptyis a standard message for “no input required.”
Quarkus compiles this into Java classes automatically. You’ll get LeaderboardServiceGrpc and message classes under target/generated-sources/grpc.
Implement the gRPC Service
Now for the fun part — the backend logic.
Create src/main/java/com/example/leaderboard/LeaderboardGrpcService.java:
package com.example.leaderboard;
import java.util.Comparator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.atomic.AtomicReference;
import io.grpc.stub.StreamObserver;
import io.quarkus.grpc.GrpcService;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.subscription.MultiEmitter;
@GrpcService
public class LeaderboardGrpcService extends LeaderboardServiceGrpc.LeaderboardServiceImplBase {
private final Map<String, Integer> scores = new ConcurrentHashMap<>();
private final ConcurrentSkipListMap<String, Integer> leaderboard = new ConcurrentSkipListMap<>(
Comparator.reverseOrder());
private final AtomicReference<MultiEmitter<? super LeaderboardEntry>> broadcasterRef = new AtomicReference<>();
private final Multi<LeaderboardEntry> broadcaster = Multi.createFrom()
.emitter(emitter -> broadcasterRef.set(emitter));
@Override
public void submitScore(SubmitScoreRequest request, StreamObserver<ScoreResponse> responseObserver) {
if (request.getPlayer().isBlank() || request.getScore() < 0) {
responseObserver.onError(io.grpc.Status.INVALID_ARGUMENT
.withDescription(”Invalid player name or score”)
.asRuntimeException());
return;
}
scores.put(request.getPlayer(), request.getScore());
leaderboard.put(request.getPlayer(), request.getScore());
// Broadcast the new entry to all subscribers
MultiEmitter<? super LeaderboardEntry> emitter = broadcasterRef.get();
if (emitter != null) {
emitter.emit(
LeaderboardEntry.newBuilder()
.setPlayer(request.getPlayer())
.setScore(request.getScore())
.build());
}
responseObserver.onNext(
ScoreResponse.newBuilder()
.setSuccess(true)
.setMessage(”Score submitted for “ + request.getPlayer())
.build());
responseObserver.onCompleted();
}
@Override
public void watchLeaderboard(com.google.protobuf.Empty request, StreamObserver<LeaderboardEntry> responseObserver) {
broadcaster.subscribe().with(responseObserver::onNext, responseObserver::onError,
responseObserver::onCompleted);
}
}Code Breakdown
@GrpcServicetells Quarkus to expose this as a gRPC service.We keep scores in
ConcurrentHashMap(thread-safe for concurrent clients).BroadcastProcessorfrom Mutiny acts as a live stream of updates — every time a new score arrives, it publishes to all connected clients.submitScore()handles a unary RPC call and triggers a broadcast.watchLeaderboard()subscribes clients to the live event stream.
This is the reactive heart of your leaderboard.
Test the Live Stream
Let’s run it in dev mode:
quarkus devYou’ll see something like:
2025-10-21 16:20:01,891 INFO [io.qua.grp.run.GrpcServerRecorder] (Quarkus Main Thread) Starting new Quarkus gRPC server (using Vert.x transport)...
2025-10-21 16:20:01,937 INFO [io.quarkus] (Quarkus Main Thread) leaderboard-grpc 1.0.0-SNAPSHOT on JVM (powered by Quarkus 3.28.4) started in 0.848s. Listening on: http://localhost:8080Send a Score
Open a new terminal and run:
grpcurl -plaintext -d ‘{”player”:”Alice”,”score”:150}’ \
localhost:8080 LeaderboardService/submitScore Output:
{
“success”: true,
“message”: “Score submitted for Alice”
}Watch the Leaderboard
In another terminal:
grpcurl -plaintext -d '{}' \
localhost:8080 LeaderboardService/watchLeaderboardKeep this terminal open.
Now submit more scores — you’ll see them stream in live:
{
“player”: “Alice”,
“score”: 150
}
{
“player”: “Bob”,
“score”: 250
}You’ve just built a real-time leaderboard with server streaming. No polling. No delay. Just data flowing as it happens.
Build the Java Client (Separate App)
Now let’s create a dedicated Quarkus client that connects to the leaderboard and streams updates.
Create a new project:
cd ..
quarkus create app com.example:leaderboard-client \
--extension=quarkus-grpc \
--no-code
cd leaderboard-clientCopy the leaderboard.proto file from your server’s src/main/proto/ into the client’s src/main/proto/ folder.
This ensures both share the same API contract.
Configure the Connection
In src/main/resources/application.properties:
quarkus.grpc.clients.leaderboard.host=localhost
quarkus.grpc.clients.leaderboard.port=8080
quarkus.http.port=8081
quarkus.grpc.server.use-separate-server=falseThis tells the client where the gRPC server lives. And gives our client a different http port.
Implement the @QuarkusMain Client
Your client will now use Mutiny, Quarkus’s reactive programming library, for elegant streaming.
Instead of juggling callbacks with StreamObserver, we’ll subscribe to a Multi<LeaderboardEntry>, a reactive stream representing continuous leaderboard updates.
Create src/main/java/com/example/leaderboard/client/LeaderboardClientApp.java:
package com.example.leaderboard.client;
import java.util.Random;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import com.example.leaderboard.LeaderboardEntry;
import com.example.leaderboard.LeaderboardServiceGrpc;
import com.example.leaderboard.MutinyLeaderboardServiceGrpc;
import com.example.leaderboard.SubmitScoreRequest;
import com.google.protobuf.Empty;
import io.quarkus.grpc.GrpcClient;
import io.quarkus.logging.Log;
import io.quarkus.runtime.QuarkusApplication;
import io.quarkus.runtime.annotations.QuarkusMain;
import io.smallrye.mutiny.Multi;
import jakarta.inject.Inject;
@QuarkusMain
public class LeaderboardClientApp implements QuarkusApplication {
@Inject
@GrpcClient(”leaderboard”)
LeaderboardServiceGrpc.LeaderboardServiceBlockingStub blockingStub;
@Inject
@GrpcClient(”leaderboard”)
MutinyLeaderboardServiceGrpc.MutinyLeaderboardServiceStub reactiveStub;
@Override
public int run(String... args) throws Exception {
System.out.println(”Connecting to leaderboard service...”);
listenToLeaderboard();
String[] players = { “Alice”, “Bob”, “Carol”, “Dave” };
var random = new Random();
for (String player : players) {
int score = random.nextInt(500);
blockingStub.submitScore(
SubmitScoreRequest.newBuilder()
.setPlayer(player)
.setScore(score)
.build());
Log.infof(”Submitted score for %s: %d%n”, player, score);
Thread.sleep(1000);
}
Thread.sleep(5000);
Log.infof(”Done sending scores. Press Ctrl+C to exit.”);
return 0;
}
private void listenToLeaderboard() {
CountDownLatch latch = new CountDownLatch(1);
Multi<LeaderboardEntry> leaderboardStream = reactiveStub.watchLeaderboard(Empty.getDefaultInstance());
leaderboardStream
.onItem()
.invoke(entry -> Log.infof(”LIVE UPDATE: %s => %d%n”, entry.getPlayer(), entry.getScore()))
.onFailure().invoke(throwable -> {
Log.errorf(”Stream error: “ + throwable.getMessage());
latch.countDown();
})
.onCompletion().invoke(() -> {
Log.infof(”Stream closed.”);
latch.countDown();
})
.subscribe().with(
entry -> {}, // handled above
throwable -> {} // handled above
);
new Thread(() -> {
try {
latch.await(60, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
}
Code Explanation
Let’s unpack this version carefully. It’s a great example of idiomatic Quarkus reactive style.
@QuarkusMain Entry Point
The @QuarkusMain annotation turns this class into a command-line entry point.
When you run ./mvnw quarkus:run, Quarkus executes the run() method directly — no web server required.
This makes it perfect for CLI tools, agents, or data pipelines.
Two gRPC Clients, Two Styles
@GrpcClient(”leaderboard”)
LeaderboardServiceGrpc.LeaderboardServiceBlockingStub blockingStub;
@GrpcClient(”leaderboard”)
MutinyLeaderboardServiceGrpc.MutinyLeaderboardServiceStub reactiveStub;
Quarkus generates two client flavors from your .proto definition:
Blocking Stub → classic, synchronous RPC style for unary calls (
submitScore)Mutiny Stub → reactive, non-blocking RPC style for streaming (
watchLeaderboard)
You can freely mix both in the same application which is a powerful pattern for hybrid workloads.
Submitting Scores (Unary RPC)
blockingStub.submitScore(
SubmitScoreRequest.newBuilder()
.setPlayer(player)
.setScore(score)
.build());Each player submits a score using the blocking stub.
This is a one-request-one-response RPC call. Simple and predictable.
We loop through a few player names, generate random scores, and push them to the server.
The log output makes it feel alive:
INFO Submitted score for Alice: 438
INFO Submitted score for Bob: 212Listening to the Leaderboard (Server Streaming RPC)
Multi<LeaderboardEntry> leaderboardStream = reactiveStub.watchLeaderboard(Empty.getDefaultInstance());This returns a Mutiny Multi. Think of it as a reactive pipeline that emits items over time. Each new leaderboard entry triggers an event downstream.
The fluent chain below defines how we respond to each lifecycle event:
leaderboardStream
.onItem()
.invoke(entry -> Log.infof(”LIVE UPDATE: %s => %d%n”, entry.getPlayer(), entry.getScore()))
.onFailure()
.invoke(throwable -> Log.errorf(”Stream error: “ + throwable.getMessage()))
.onCompletion()
.invoke(() -> Log.infof(”Stream closed.”))
.subscribe().with(entry -> {}, throwable -> {});Let’s break that down:
onItem().invoke(...)– executes when a new update arrives from the stream.
Each broadcast from the server appears instantly here.onFailure().invoke(...)– handles network or server errors gracefully.onCompletion().invoke(...)– runs when the stream closes, either normally or due to timeout..subscribe().with(...)– activates the stream. Without subscribing, nothing flows.
This reactive syntax eliminates manual threading or callback juggling.
It’s clean, declarative, and reads like a story.
Keeping the Client Alive
Since the stream is asynchronous, we use a CountDownLatch to keep the JVM running:
CountDownLatch latch = new CountDownLatch(1);
...
new Thread(() -> {
try {
latch.await(60, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();This keeps the app listening for up to 60 seconds which is plenty for a quick demo.
In production, you’d run it indefinitely or manage lifecycle events more robustly.
Why This Pattern Rocks
Zero boilerplate: No manual observers or blocking loops.
Reactive elegance: Streams behave like Java Streams — but alive.
Native-ready: Works perfectly in Quarkus native images.
You now have a lightweight CLI client that behaves like a real-time dashboard.
Run It All Together
Start the server first:
cd ../leaderboard-grpc
quarkus devThen launch the client:
cd ../leaderboard-client
quarkus devYou’ll see something like:
INFO Creating Netty gRPC channel ...
Connecting to leaderboard service...
INFO Submitted score for Alice: 227
INFO LIVE UPDATE: Bob => 471
INFO Submitted score for Bob: 471
INFO LIVE UPDATE: Carol => 81
INFO Submitted score for Carol: 81
INFO LIVE UPDATE: Dave => 156
INFO Submitted score for Dave: 156
INFO Done sending scores. Press Ctrl+C to exit.
INFO(Quarkus Main Thread) Shutting down gRPC channel ManagedChannelOrphanWrapper{delegate=ManagedChannelImpl{logId=1, target=dns:///localhost:8080}}
ERROR Stream error: UNAVAILABLE: Channel shutdownNow invoked [Error Occurred After Shutdown]
INFO leaderboard-client stopped in 0.020sEvery update printed in real time: No polling, no web sockets.
Just pure gRPC streaming over HTTP/2.
What’s Going On Here
Let’s break down what these log lines tell you:
Creating Netty gRPC channel...
Quarkus initializes a Netty-based HTTP/2 channel to connect to the leaderboard service running onlocalhost:8080. This happens automatically when the@GrpcClientis first used.Connecting to leaderboard service...
That’s your application booting up — the@QuarkusMainentry point prints this before subscribing to the leaderboard stream.Submitted score for Alice: 227
Each player submits a random score via the blocking stub. These messages come from the main thread because unary calls are synchronous.LIVE UPDATE: Bob => 471
These messages arrive from the reactive stream, handled on a Vert.x event loop thread (vert.x-eventloop-thread-4).
You can see the reactive and synchronous flows happening side by side — one thread sends scores, another listens to updates.Done sending scores. Press Ctrl+C to exit.
After all players have submitted their scores, the client waits a few seconds before shutting down. The leaderboard keeps streaming until the channel closes.Channel shutdown...
When the client exits, Quarkus tears down the gRPC channel cleanly. This triggers aUNAVAILABLEerror in the reactive stream — not a real failure, just a signal that the connection was closed intentionally.leaderboard-client stopped in 0.020s
That’s Quarkus showing off: lightning-fast shutdown even in dev mode.
A Note on Mutiny
If you’re new to Mutiny, Quarkus with Mutiny embraces reactive programming while freeing up resources with non-blocking, event-driven flows. Learn more about it with this article.
It uses Uni (for single results) and Multi (for streams of results).
In this example:
submitScore()is a Unary RPC – handled by the blocking stub.watchLeaderboard()is a Server Streaming RPC – handled reactively viaMulti.
This combination is ideal for microservices or event-driven systems.
The same code scales beautifully to thousands of concurrent connections.
The Result
You now have:
A gRPC server streaming leaderboard updates using Mutiny
A Client implemented as a Quarkus main app
Real-time streaming with reactive backpressure and structured logging
You didn’t need Kafka.
You didn’t need WebSockets.
You didn’t even need a database.
Just Java, Quarkus, and gRPC doing what they do best: Fast, reactive, type-safe communication.
Next step: Extend this example to include player ranking, persistence with PostgreSQL, or broadcast updates via WebSockets to a frontend.
Your leaderboard already speaks gRPC — the rest is just plumbing.




Thanks for sharing Markus. Beyond the example, which is perhaps a bit playful, this software architecture consisting in a Quarkus stand-alone application issuing gRPC requests is a very robust and powerful one. Much more robust and powerful, in my opinion, than the REST services based one. And adding reactive async processing with Mutiny to the whole picture can only improve it and makes it even more innovative.