Ditch JSON for Speed: Protobuf + Quarkus REST Explained for Java Developers
How to add binary serialization to your existing endpoints without rewriting your backend or adopting gRPC.
I recently read an interesting article titled “Better Than JSON?” by Aloïs Deniel. It raises a point many backend developers quietly agree with: JSON is convenient, but it’s not efficient. It is verbose, expensive to parse, imprecise in its typing, and poorly suited for high-throughput or mobile scenarios. The article walks through concrete problems we all hit sooner or later. JSON wastes bandwidth. It forces you to validate types at runtime. It leaves edge cases around numbers, dates, null handling, and schema drift entirely up to you. And while it is human-readable, machines spend a lot of time converting that readability into useful objects.
The takeaway is simple. JSON became the default because it was easy, not because it was the best tool for performance-critical systems. Once you care about efficiency, schema safety, or stable contracts, you need something better.
Protobuf solves these problems without forcing you into gRPC. It gives you a compact binary format, a real schema, type-safe generated code, and predictable performance. And you can use it in plain Quarkus REST endpoints without changing anything about your architecture.
This tutorial shows you exactly how to do that. You’ll build a Quarkus REST API that reads and writes Protobuf messages over standard HTTP while still supporting JSON for debugging and human-friendly workflows.
Let’s get started.
Quarkus doesn’t ship with Protobuf support out of the box (outside of gRPC!) because there is no official JAX-RS integration. The good news: adding support yourself is easy. You only need two providers and a .proto file.
Prerequisites
You need:
Java 17 or newer
Maven 3.9+
protoc binary installed (brew install protobuf on Mac)
A basic understanding of REST endpoints
Bootstrap the Project
Create a project with Quarkus REST:
mvn io.quarkus:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=quarkus-protobuf-demo \
-DclassName="com.example.UserResource" \
-Dpath="/api/users" \
-Dextensions="rest-jackson"
cd quarkus-protobuf-demoThis gives you a classic JSON REST service as a starting point. Next we turn it into a Protobuf service.
And, as usual, find the running example on my Github repository.
Add Protobuf Dependencies
Edit your pom.xml and add:
<dependencies>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>4.33.1</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
<version>4.33.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<executions>
<execution>
<id>compile-protobuf</id>
<phase>generate-sources</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>This installs Protobuf and configures Maven to generate Java classes from .proto.
Define the .proto Schema
Create the folder:
mkdir -p src/main/protoAdd src/main/proto/user.proto:
syntax = “proto3”;
package com.example;
option java_multiple_files = true;
option java_package = “com.example.proto”;
option java_outer_classname = “UserProto”;
message User {
int32 id = 1;
string name = 2;
string email = 3;
bool is_active = 4;
int64 created_at = 5;
}
message UserList {
repeated User users = 1;
int32 total_count = 2;
}
message CreateUserRequest {
string name = 1;
string email = 2;
}This contract is now the source of truth for your REST API.
Generate the Java Classes
Run:
mvn clean compileThe generated code lands under:
target/generated-sources/protobuf/java/com/example/protoThe user.proto file produces seven Java classes. Three of them are the concrete message classes (User, UserList, CreateUserRequest). These are large, immutable, generated types with getters, builders, and serialization methods like toByteArray() and parseFrom(). Three more files are the corresponding OrBuilder interfaces, which expose read-only access to fields and are implemented by both the message classes and their builders. The final file, UserProto, contains all schema metadata and descriptors used internally for reflection and serialization, created because of the java_outer_classname setting.
You get the strongly typed message classes for free.
Add Protobuf Support to JAX-RS
Quarkus needs to know how to read and write Protobuf payloads. That means implementing two providers.
Writer: Java → Protobuf
Create src/main/java/com/example/ProtobufMessageBodyWriter.java:
package com.example;
import java.io.IOException;
import java.io.OutputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import com.google.protobuf.Message;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.ext.MessageBodyWriter;
import jakarta.ws.rs.ext.Provider;
import org.jboss.logging.Logger;
@Provider
@Produces(”application/x-protobuf”)
public class ProtobufMessageBodyWriter implements MessageBodyWriter<Message> {
private static final Logger LOG = Logger.getLogger(ProtobufMessageBodyWriter.class);
@Override
public boolean isWriteable(Class<?> type, Type genericType,
Annotation[] annotations, MediaType mediaType) {
boolean writeable = Message.class.isAssignableFrom(type);
LOG.infof(”ProtobufMessageBodyWriter.isWriteable: type=%s, writeable=%s”, type.getSimpleName(), writeable);
return writeable;
}
@Override
public void writeTo(Message message, Class<?> type, Type genericType,
Annotation[] annotations, MediaType mediaType,
MultivaluedMap<String, Object> httpHeaders,
OutputStream entityStream)
throws IOException, WebApplicationException {
LOG.infof(”ProtobufMessageBodyWriter.writeTo: writing %s to OutputStream”, type.getSimpleName());
message.writeTo(entityStream);
LOG.infof(”ProtobufMessageBodyWriter.writeTo: successfully wrote %s”, type.getSimpleName());
}
}Reader: Protobuf → Java
Create src/main/java/com/example/ProtobufMessageBodyReader.java:
package com.example;
import java.io.IOException;
import java.io.InputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import com.google.protobuf.Message;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.ext.MessageBodyReader;
import jakarta.ws.rs.ext.Provider;
import org.jboss.logging.Logger;
@Provider
@Consumes(”application/x-protobuf”)
public class ProtobufMessageBodyReader implements MessageBodyReader<Message> {
private static final Logger LOG = Logger.getLogger(ProtobufMessageBodyReader.class);
@Override
public boolean isReadable(Class<?> type, Type genericType,
Annotation[] annotations, MediaType mediaType) {
boolean readable = Message.class.isAssignableFrom(type);
LOG.infof(”ProtobufMessageBodyReader.isReadable: type=%s, readable=%s”, type.getSimpleName(), readable);
return readable;
}
@Override
public Message readFrom(Class<Message> type, Type genericType,
Annotation[] annotations, MediaType mediaType,
MultivaluedMap<String, String> headers,
InputStream inputStream)
throws IOException, WebApplicationException {
LOG.infof(”ProtobufMessageBodyReader.readFrom: parsing %s from InputStream”, type.getSimpleName());
try {
Method parseFrom = type.getMethod(”parseFrom”, InputStream.class);
Message message = (Message) parseFrom.invoke(null, inputStream);
LOG.infof(”ProtobufMessageBodyReader.readFrom: successfully parsed %s”, type.getSimpleName());
return message;
} catch (Exception e) {
LOG.errorf(e, “ProtobufMessageBodyReader.readFrom: failed to parse %s”, type.getSimpleName());
throw new WebApplicationException(”Failed to parse protobuf message”, e);
}
}
}That’s the entire integration layer. Everything else works like any REST endpoint.
Implement the Protobuf REST API
Replace UserResource.java with:
package com.example;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import com.example.proto.CreateUserRequest;
import com.example.proto.User;
import com.example.proto.UserList;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path(”/api/users”)
public class UserResource {
private static final AtomicInteger idCounter = new AtomicInteger(1);
private static final List<User> users = new ArrayList<>();
static {
users.add(User.newBuilder()
.setId(idCounter.getAndIncrement())
.setName(”Alice”)
.setEmail(”alice@example.com”)
.setIsActive(true)
.setCreatedAt(System.currentTimeMillis())
.build());
users.add(User.newBuilder()
.setId(idCounter.getAndIncrement())
.setName(”Bob”)
.setEmail(”bob@example.com”)
.setIsActive(true)
.setCreatedAt(System.currentTimeMillis())
.build());
}
@GET
@Produces(”application/x-protobuf”)
public UserList getAll() {
return UserList.newBuilder()
.addAllUsers(users)
.setTotalCount(users.size())
.build();
}
@GET
@Path(”/{id}”)
@Produces(”application/x-protobuf”)
public User find(@PathParam(”id”) int id) {
return users.stream()
.filter(u -> u.getId() == id)
.findFirst()
.orElseThrow(() -> new NotFoundException(”User not found”));
}
@POST
@Consumes(”application/x-protobuf”)
@Produces(”application/x-protobuf”)
public User create(CreateUserRequest request) {
User newUser = User.newBuilder()
.setId(idCounter.getAndIncrement())
.setName(request.getName())
.setEmail(request.getEmail())
.setIsActive(true)
.setCreatedAt(System.currentTimeMillis())
.build();
users.add(newUser);
return newUser;
}
@GET
@Path(”/json”)
@Produces(MediaType.APPLICATION_JSON)
public List<UserJson> allAsJson() {
return users.stream()
.map(u -> new UserJson(u.getId(), u.getName(), u.getEmail(), u.getIsActive()))
.toList();
}
public record UserJson(int id, String name, String email, boolean isActive) {
}
}Run the Application
Start dev mode:
mvn quarkus:devYour Protobuf API is now live.
Test the Endpoints
Get all users via Protobuf
curl -H "Accept: application/x-protobuf" \
http://localhost:8080/api/users \
--output users.binWatch how both MessageBodyReader and MessageBodyWriter are invoked.
INFO [c.e.ProtobufMessageBodyWriter] ProtobufMessageBodyWriter.isWriteable: type=UserList, writeable=true
INFO [c.e.ProtobufMessageBodyWriter] ProtobufMessageBodyWriter.writeTo: writing UserList to OutputStream
INFO [c.e.ProtobufMessageBodyWriter]
ProtobufMessageBodyWriter.writeTo: successfully wrote UserListCheck the file size:
ls -lh users.binWrite a Test
Create src/test/java/com/example/ProtobufClientTest.java:
package com.example;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import com.example.proto.CreateUserRequest;
import com.example.proto.User;
import com.example.proto.UserList;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class ProtobufClientTest {
@Test
void testGetAllUsers() throws Exception {
byte[] responseBody = given()
.accept(”application/x-protobuf”)
.when()
.get(”/api/users”)
.then()
.statusCode(200)
.extract()
.body()
.asByteArray();
UserList userList = UserList.parseFrom(responseBody);
System.out.println(”Total users: “ + userList.getTotalCount());
for (User user : userList.getUsersList()) {
System.out.printf(”User: id=%d, name=%s, email=%s%n”,
user.getId(), user.getName(), user.getEmail());
}
assertTrue(userList.getTotalCount() > 0);
}
@Test
void testGetUserById() throws Exception {
byte[] responseBody = given()
.accept(”application/x-protobuf”)
.when()
.get(”/api/users/1”)
.then()
.statusCode(200)
.extract()
.body()
.asByteArray();
User user = User.parseFrom(responseBody);
System.out.printf(”User: id=%d, name=%s, email=%s%n”,
user.getId(), user.getName(), user.getEmail());
assertEquals(1, user.getId());
assertEquals(”Alice”, user.getName());
}
@Test
void testCreateUser() throws Exception {
CreateUserRequest request = CreateUserRequest.newBuilder()
.setName(”Charlie”)
.setEmail(”charlie@example.com”)
.build();
byte[] requestBody = request.toByteArray();
byte[] responseBody = given()
.contentType(”application/x-protobuf”)
.accept(”application/x-protobuf”)
.body(requestBody)
.when()
.post(”/api/users”)
.then()
.statusCode(200)
.extract()
.body()
.asByteArray();
User newUser = User.parseFrom(responseBody);
System.out.printf(”Created user: id=%d, name=%s, email=%s%n”,
newUser.getId(), newUser.getName(), newUser.getEmail());
assertEquals(”Charlie”, newUser.getName());
assertEquals(”charlie@example.com”, newUser.getEmail());
assertTrue(newUser.getId() > 0);
}
}Add a Size Comparison Endpoint
Add this endpoint to your UserResource to compare:
@GET
@Path(”/compare-sizes”)
@Produces(MediaType.TEXT_PLAIN)
public String compareSizes() throws Exception {
// Create a sample user
User user = User.newBuilder()
.setId(42)
.setName(”Alice”)
.setEmail(”alice@example.com”)
.setIsActive(true)
.setCreatedAt(System.currentTimeMillis())
.build();
// Protobuf size
byte[] protobufBytes = user.toByteArray();
int protobufSize = protobufBytes.length;
// JSON size (approximate)
String json = String.format(
“{\”id\”:%d,\”name\”:\”%s\”,\”email\”:\”%s\”,\”isActive\”:%b,\”createdAt\”:%d}”,
user.getId(), user.getName(), user.getEmail(),
user.getIsActive(), user.getCreatedAt()
);
int jsonSize = json.getBytes().length;
return String.format(
“Size Comparison:\n” +
“Protobuf: %d bytes\n” +
“JSON: %d bytes\n” +
“Savings: %.1f%% smaller with Protobuf\n”,
protobufSize, jsonSize,
(1 - (double)protobufSize / jsonSize) * 100
);
}Try:
curl http://localhost:8080/api/users/compare-sizesThe difference is obvious.
Size Comparison:
Protobuf: 37 bytes
JSON: 94 bytes
Savings: 60.6% smaller with ProtobufCommon Pitfalls
Binary formats are hard to debug. Keep one JSON endpoint for inspection. Never reuse field numbers in .proto. Use reserved markers. Avoid removing fields. Protobuf is built for schema evolution, but only if you follow the rules.
When to Use Protobuf
Use it when performance, payload size, schema consistency, or cross-platform contracts matter. Use JSON when readability or browser debugging is more important.
Quarkus makes it trivial to run Protobuf over plain REST, giving you the performance of a binary protocol without adopting a new RPC framework.




Solid tutorial with practical tradeoffs laid out clearly. The 60% payload reduction is meaningul in high-throughput scenarios, but what stood out was keeping the existing REST architecture intact rather than forcing a full gRPC migration. I've used a similar setup on microservices where mobile clients needed smaller payloads but debugging still required readable JSON endpoints. The reserved field number tip is crucial - saw that bite a team once when someone reused field 5 and broke backward compatibilty across versions.