REST vs GraphQL vs gRPC: Which API Style Should Your Java Microservices Use?
REST vs GraphQL vs gRPC compares the three most popular ways for programs to talk to each other over a network. REST exposes data as resources at URLs and uses standard HTTP methods, GraphQL lets the client ask for exactly the fields it needs through a single endpoint, and gRPC calls methods on a remote service using compact binary messages over HTTP/2.
None of them is "the best", and many real systems use all three. This tutorial explains how each works internally, measures the differences with Java 21 code, and shows you how to choose.
1. Why the API Style Matters
Picture a music-streaming app. A phone app shows playlists, a web dashboard shows play statistics, and behind them a Playlist service calls a Track service thousands of times per second.
These conversations have very different needs:
- The phone is on a slow mobile network, so it wants few requests and small responses.
- The web dashboard changes often and needs different data each time.
- The services inside the data center care about speed, strict contracts and streaming data.
The API style decides how many round trips happen, how many bytes travel, and how errors are reported.
2. Understanding REST, GraphQL and gRPC
2.1 REST
REST (Representational State Transfer) is an architectural style, not a library. You model your data as resources, give each resource a URL, and act on it with HTTP methods: GET to read, POST to create, PUT/PATCH to update and DELETE to remove. Responses are usually JSON, and the HTTP status code (200, 404, 500) tells the client what happened.
GET /playlists/7 -> the playlist
GET /tracks/11 -> one track
DELETE /playlists/7/tracks/12
The server decides the shape of each response. That is simple and predictable, but it leads to two common problems:
- Over-fetching: you get 20 fields when the screen needs 2.
- Under-fetching: one response isn't enough, so you make extra calls.
2.2 GraphQL
GraphQL is a query language for APIs, created at Facebook and open-sourced in 2015. The server publishes a schema, a typed description of all available data. The client sends a query naming exactly the fields it wants, usually as a POST to one URL such as /graphql.
type Query { playlist(id: ID!): Playlist }
type Playlist { title: String! tracks: [Track!]! }
type Track { title: String! artist: String! }
# client query
{ playlist(id: 7) { title tracks { title artist } } }
On the server, each field is filled in by a resolver, a function that knows how to fetch that one piece of data. Reads are called queries, writes are mutations, and live updates are subscriptions.
2.3 gRPC
gRPC is a remote procedure call (RPC) framework from Google. RPC means you call a method on another machine as if it were a local method. You describe services and messages in a .proto file using Protocol Buffers (protobuf), and a code generator creates Java classes for both sides.
syntax = "proto3";
message Book { int64 id = 1; string title = 2; int32 price_cents = 3; bool in_stock = 4; }
message BookRequest { int64 id = 1; }
service Catalog {
rpc GetBook (BookRequest) returns (Book);
}
In Java, you then call catalogStub.getBook(request) and get a typed Book object back. gRPC always runs over HTTP/2 and sends messages in protobuf's compact binary format.
3. How Each One Works on the Wire
Three internal details explain most of the differences:
- Text vs binary. JSON repeats every field name as text in every message. Protobuf replaces names with small field numbers and stores numbers as varints, a format where small numbers take fewer bytes. Example 3 shows the exact bytes.
- HTTP/1.1 vs HTTP/2. HTTP/2 can carry many requests at once over one TCP connection, which is called multiplexing. gRPC requires HTTP/2 and keeps one connection open for a long time. REST and GraphQL work over either version.
- Where errors live. REST uses HTTP status codes. GraphQL usually replies
200 OKand puts problems in anerrorsarray. gRPC replies with HTTP200and sends its own status code (such asNOT_FOUND) in a trailer, a header sent after the response body.
4. Code Examples
These programs use only the JDK (Java 21) to show the ideas. Real projects would use Spring Web, Spring for GraphQL or grpc-java.
4.1 Example 1: Over-Fetching and Under-Fetching, Measured
The playlist screen needs the playlist title plus each track's title and artist.
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class FetchShapeDemo {
record Track(int id, String title, String artist, String album, int durationSec, String isrc, int popularity) {
String toJson() { // what a REST endpoint returns: the whole resource
return """
{"id":%d,"title":"%s","artist":"%s","album":"%s","durationSec":%d,"isrc":"%s","popularity":%d}"""
.formatted(id, title, artist, album, durationSec, isrc, popularity);
}
}
record Playlist(int id, String title, String owner, String description, int followers, List<Integer> trackIds) {
String toJson() {
return """
{"id":%d,"title":"%s","owner":"%s","description":"%s","followers":%d,"trackIds":%s}"""
.formatted(id, title, owner, description, followers, trackIds);
}
}
static final Map<Integer, Track> TRACKS = Map.of(
11, new Track(11, "Midnight Compile", "The Semicolons", "Build Green", 214, "INX012600011", 71),
12, new Track(12, "Null Pointer Blues", "Ada & The Lambdas", "Stack Traces", 187, "INX012600012", 64),
13, new Track(13, "Garbage Collector", "The Semicolons", "Build Green", 242, "INX012600013", 58));
static final Playlist PLAYLIST = new Playlist(7, "Late Night Coding", "priya",
"Focus music for long debugging sessions", 1834, List.of(11, 12, 13));
static int bytes(String json) {
return json.getBytes(StandardCharsets.UTF_8).length;
}
public static void main(String[] args) {
// REST: one call for the playlist, then one call per track
int restCalls = 1, restBytes = bytes(PLAYLIST.toJson());
for (int id : PLAYLIST.trackIds()) {
restCalls++;
restBytes += bytes(TRACKS.get(id).toJson());
}
// GraphQL: the client names exactly the fields the screen needs
String query = "{ playlist(id: 7) { title tracks { title artist } } }";
String tracksJson = PLAYLIST.trackIds().stream()
.map(TRACKS::get)
.map(t -> "{\"title\":\"%s\",\"artist\":\"%s\"}".formatted(t.title(), t.artist()))
.collect(Collectors.joining(","));
String graphqlResponse = "{\"data\":{\"playlist\":{\"title\":\"%s\",\"tracks\":[%s]}}}"
.formatted(PLAYLIST.title(), tracksJson);
System.out.println("Screen needs: playlist title + each track's title and artist");
System.out.printf("REST : %d calls, %d bytes received%n", restCalls, restBytes);
System.out.printf("GraphQL : 1 call, %d bytes sent, %d bytes received%n",
bytes(query), bytes(graphqlResponse));
System.out.println(graphqlResponse);
}
}
Screen needs: playlist title + each track's title and artist
REST : 4 calls, 576 bytes received
GraphQL : 1 call, 53 bytes sent, 233 bytes received
{"data":{"playlist":{"title":"Late Night Coding","tracks":[{"title":"Midnight Compile","artist":"The Semicolons"},{"title":"Null Pointer Blues","artist":"Ada & The Lambdas"},{"title":"Garbage Collector","artist":"The Semicolons"}]}}}
REST made 4 round trips; GraphQL made 1. On a mobile network, where each round trip can cost 100 ms or more, the number of calls matters even more than the bytes.
To be fair to REST, you could add ?fields= support or a dedicated "playlist screen" endpoint. GraphQL gives you this flexibility without a new endpoint for each screen.
4.2 Example 2: What HTTP Gives REST for Free
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
public class HttpSemanticsDemo {
static final String BOOK_JSON = "{\"id\":42,\"title\":\"Kafka in Practice\",\"priceCents\":49900}";
static final String BOOK_ETAG = "\"v3\""; // version tag of the current representation
static void send(HttpExchange ex, int status, String body) throws IOException {
if (body == null) {
ex.sendResponseHeaders(status, -1); // -1 = no body
ex.close();
return;
}
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json");
ex.sendResponseHeaders(status, bytes.length);
try (OutputStream out = ex.getResponseBody()) {
out.write(bytes);
}
}
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
// REST: one URL per resource, so standard HTTP caching works
server.createContext("/books/42", ex -> {
ex.getResponseHeaders().set("ETag", BOOK_ETAG);
if (BOOK_ETAG.equals(ex.getRequestHeaders().getFirst("If-None-Match"))) {
send(ex, 304, null); // client copy is still valid
} else {
send(ex, 200, BOOK_JSON);
}
});
server.createContext("/books/99", ex -> send(ex, 404, "{\"error\":\"book 99 not found\"}"));
// GraphQL: one URL for everything; errors travel inside a 200 response
server.createContext("/graphql", ex -> {
ex.getRequestBody().readAllBytes();
send(ex, 200, "{\"data\":{\"book\":null},\"errors\":[{\"message\":\"book 99 not found\",\"path\":[\"book\"]}]}");
});
server.start();
String base = "http://localhost:" + server.getAddress().getPort();
try (HttpClient client = HttpClient.newHttpClient()) {
var first = get(client, base + "/books/42", null);
System.out.println("REST GET /books/42 -> " + first.statusCode() + " ETag=" + first.headers().firstValue("ETag").orElse("-"));
var second = get(client, base + "/books/42", BOOK_ETAG);
System.out.println("REST GET again with ETag -> " + second.statusCode() + " body bytes=" + second.body().length());
var missing = get(client, base + "/books/99", null);
System.out.println("REST GET /books/99 -> " + missing.statusCode() + " " + missing.body());
var gql = client.send(HttpRequest.newBuilder(URI.create(base + "/graphql"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"query\":\"{ book(id: 99) { title } }\"}"))
.build(),
HttpResponse.BodyHandlers.ofString());
System.out.println("GraphQL POST /graphql -> " + gql.statusCode() + " " + gql.body());
} finally {
server.stop(0);
}
}
static HttpResponse<String> get(HttpClient client, String url, String etag) throws Exception {
var builder = HttpRequest.newBuilder(URI.create(url));
if (etag != null) {
builder.header("If-None-Match", etag); // conditional request
}
return client.send(builder.build(), HttpResponse.BodyHandlers.ofString());
}
}
REST GET /books/42 -> 200 ETag="v3"
REST GET again with ETag -> 304 body bytes=0
REST GET /books/99 -> 404 {"error":"book 99 not found"}
GraphQL POST /graphql -> 200 {"data":{"book":null},"errors":[{"message":"book 99 not found","path":["book"]}]}
Two things to notice:
- Caching: the second REST call sent
If-None-Matchand got304 Not Modifiedwith an empty body. Browsers, CDNs and proxies understand this automatically, because every resource has its own URL. GraphQL sends different queries to one URL viaPOST, so these standard HTTP caches cannot help without extra work. - Errors: REST reported the missing book as
404. GraphQL reported it as200with anerrorsarray. A dashboard that counts only HTTP 5xx and 4xx responses will show GraphQL as 100% healthy while users see failures.
4.3 Example 3: Inside a gRPC Message
This program encodes a Book message by hand, exactly as protobuf does, and then wraps it the way gRPC does before sending it over HTTP/2.
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
public class ProtobufWireDemo {
// message Book { int64 id = 1; string title = 2; int32 price_cents = 3; bool in_stock = 4; }
record Book(long id, String title, int priceCents, boolean inStock) { }
static void writeVarint(ByteArrayOutputStream out, long value) {
while ((value & ~0x7FL) != 0) {
out.write((int) ((value & 0x7F) | 0x80)); // 7 data bits + "more bytes follow" bit
value >>>= 7;
}
out.write((int) value);
}
static void writeTag(ByteArrayOutputStream out, int fieldNumber, int wireType) {
writeVarint(out, ((long) fieldNumber << 3) | wireType); // field number + type, no field name
}
static byte[] encode(Book b) {
var out = new ByteArrayOutputStream();
if (b.id() != 0) { writeTag(out, 1, 0); writeVarint(out, b.id()); }
if (!b.title().isEmpty()) {
byte[] text = b.title().getBytes(StandardCharsets.UTF_8);
writeTag(out, 2, 2); // wire type 2 = length-delimited
writeVarint(out, text.length);
out.writeBytes(text);
}
if (b.priceCents() != 0) { writeTag(out, 3, 0); writeVarint(out, b.priceCents()); }
if (b.inStock()) { writeTag(out, 4, 0); writeVarint(out, 1); } // proto3 skips default values
return out.toByteArray();
}
static String toJson(Book b) {
return "{\"id\":%d,\"title\":\"%s\",\"priceCents\":%d,\"inStock\":%b}"
.formatted(b.id(), b.title(), b.priceCents(), b.inStock());
}
static byte[] grpcFrame(byte[] message) {
var out = new ByteArrayOutputStream();
out.write(0); // compressed flag: 0 = not compressed
out.writeBytes(new byte[] {
(byte) (message.length >>> 24), (byte) (message.length >>> 16),
(byte) (message.length >>> 8), (byte) message.length}); // 4-byte big-endian length
out.writeBytes(message);
return out.toByteArray();
}
public static void main(String[] args) {
HexFormat hex = HexFormat.ofDelimiter(" ").withUpperCase();
var kafka = new Book(300, "Kafka", 49_900, false);
byte[] wire = encode(kafka);
System.out.println("JSON : " + toJson(kafka).length() + " bytes");
System.out.println("Protobuf : " + wire.length + " bytes -> " + hex.formatHex(wire));
System.out.println("gRPC frame (5-byte prefix + message): " + hex.formatHex(grpcFrame(wire)));
// Edge case: a free book and a book whose price was never set look identical
var freeBook = new Book(301, "Kafka", 0, false);
System.out.println("Price 0 : " + hex.formatHex(encode(freeBook)) + " (price field is missing)");
}
}
JSON : 61 bytes
Protobuf : 14 bytes -> 08 AC 02 12 05 4B 61 66 6B 61 18 EC 85 03
gRPC frame (5-byte prefix + message): 00 00 00 00 0E 08 AC 02 12 05 4B 61 66 6B 61 18 EC 85 03
Price 0 : 08 AD 02 12 05 4B 61 66 6B 61 (price field is missing)
Reading the bytes:
08is the tag for field 1 with wire type 0 (varint), calculated as(1 << 3) | 0.AC 02is 300 as a varint.12 05means field 2, length-delimited, 5 bytes long, followed by the UTF-8 bytes of "Kafka".18 EC 85 03is field 3 holding 49,900.in_stock = falseis not sent at all, because proto3 skips fields that have their default value.
The same data is 14 bytes in protobuf versus 61 in JSON. gRPC then adds a 5-byte prefix: one byte saying whether the message is compressed, and four bytes for its length (00 00 00 0E = 14).
The last line shows an edge case that many tutorials miss. A book with price 0 has no price field on the wire, so the receiver cannot tell "free" from "price never set". If that difference matters, mark the field optional int32 price_cents = 3; (allowed again since protobuf 3.15), or use a wrapper type.
5. REST vs GraphQL vs gRPC: Comparison Table
| Feature | REST | GraphQL | gRPC |
|---|---|---|---|
| Main idea | Resources at URLs | Client picks fields from a schema | Call remote methods |
| Typical format | JSON (text) | JSON (text) | Protocol Buffers (binary) |
| Transport | HTTP/1.1 or HTTP/2 | HTTP/1.1 or HTTP/2, usually POST /graphql | HTTP/2 only |
| Contract | Optional (OpenAPI) | Required schema | Required .proto file |
| HTTP caching | Built in (ETag, Cache-Control) | Hard, needs extra tooling | Not applicable |
| Streaming | Limited (SSE, chunked responses) | Subscriptions | Client, server and two-way streaming |
| Browser support | Native | Native | Needs gRPC-Web and a proxy |
| Error reporting | HTTP status codes | errors array, usually HTTP 200 | gRPC status codes in trailers |
| Best fit | Public APIs, simple CRUD | Varied front-end screens | Fast internal service-to-service calls |
6. Common Mistakes and Pitfalls
1. The GraphQL N+1 problem. If the tracks resolver fetches each track's artist with a separate database query, a playlist with 50 tracks triggers 1 + 50 queries. GraphQL moved the extra calls from the network to your server; it didn't remove them. Use batching (DataLoader, or @BatchMapping in Spring for GraphQL).
2. Load balancing gRPC like REST. gRPC keeps one long-lived HTTP/2 connection and sends every call through it. A classic connection-level (Layer 4) load balancer picks a pod once per connection, so one pod may receive all the traffic while new pods sit idle. Use a request-aware (Layer 7) proxy such as Envoy, a service mesh, or client-side load balancing.
3. Reusing protobuf field numbers. The field number, not the name, is what travels on the wire. If you delete field 3 and later reuse number 3 for a different field, old clients will read the new data as the old field. Mark removed numbers with reserved 3;.
4. Trusting HTTP 200 from GraphQL. As Example 2 showed, a GraphQL response can be 200 OK and still contain errors. Monitor the errors array, not just status codes.
5. Choosing gRPC for a public or browser API. Browsers cannot call gRPC directly, because JavaScript has no low-level control over HTTP/2 framing and trailers. You need gRPC-Web plus a proxy, which adds moving parts.
7. Best Practices: Choosing the Right Style
- Pick REST for public APIs, simple create-read-update-delete services, and anything that benefits from HTTP caching. Every language and tool understands it.
- Pick GraphQL when many different screens or clients need different slices of the same data, especially as a gateway layer in front of several services.
- Pick gRPC for internal service-to-service calls where speed, strict typing, streaming or many programming languages matter.
- Mix them. A common design is gRPC between internal services, with a REST or GraphQL layer at the API gateway facing browsers and mobile apps.
- Always set timeouts. For gRPC, set a deadline on every call. For REST and GraphQL clients, set connect and read timeouts.
- Treat the contract as code. Version your OpenAPI files, GraphQL schemas and
.protofiles, and check every change for breaking changes.
8. Summary
- REST vs GraphQL vs gRPC is a choice between resources at URLs, client-selected fields, and remote method calls.
- REST is simple, universal and gets HTTP caching and status codes for free.
- GraphQL removes over-fetching and under-fetching for clients, but moves complexity to the server (N+1, query cost) and usually reports errors inside HTTP 200.
- gRPC sends compact binary protobuf messages over HTTP/2, supports streaming, and needs request-level load balancing.
- In proto3, default values such as
0andfalseare not sent; useoptionalwhen "not set" must be detectable. - Many production systems use gRPC internally and REST or GraphQL at the edge.
Understanding REST vs GraphQL vs gRPC at the wire level lets you pick the right tool for each conversation in your system, and explain that choice clearly in an interview.
REST vs gRPC vs GraphQL: Interview Questions
Common interview questions on REST vs gRPC vs GraphQL, from basic to scenario-based, with short answers.
Basic questions
Q. Is REST a protocol?
Answer: No. REST is an architectural style described by Roy Fielding in his 2000 doctoral dissertation. It defines constraints such as client-server separation, statelessness, cacheability and a uniform interface. HTTP is the protocol REST APIs usually run on.
Q. Which HTTP methods are idempotent, and why does it matter?
Answer: GET, PUT, DELETE, HEAD and OPTIONS are idempotent: repeating them has the same effect as sending them once. POST is not, and PATCH is not guaranteed to be. This matters for retries: a client can safely retry a timed-out PUT, but retrying a POST /orders may create two orders.
Q. What are the four kinds of gRPC calls?
Answer: Unary (one request, one response), server streaming, client streaming and bidirectional streaming.
rpc GetBook (BookRequest) returns (Book); // unary
rpc ListBooks (Filter) returns (stream Book); // server streaming
rpc UploadReviews (stream Review) returns (Summary); // client streaming
rpc Chat (stream ChatMessage) returns (stream ChatMessage); // bidirectional
Q. Can a REST API use HTTP/2?
Answer: Yes. REST doesn't depend on a particular HTTP version, so the same endpoints can run over HTTP/2 and gain multiplexing. In Spring Boot, set server.http2.enabled=true; browsers support HTTP/2 only over TLS. The messages are still JSON text, so the size advantage of protobuf remains.
Intermediate questions
Q. What is a gRPC deadline, and what happens if you don't set one?
Answer: A deadline is the point in time by which the call must finish, after which it fails with DEADLINE_EXCEEDED. grpc-java sets no deadline by default, so a call to a stuck server can wait forever. Deadlines can also be passed along to downstream calls, so the whole chain gives up together.
Book book = catalogStub.withDeadlineAfter(300, TimeUnit.MILLISECONDS).getBook(request);
Q. How do you version a GraphQL API?
Answer: GraphQL usually avoids version numbers. You add new fields freely, and old fields stay in the schema, marked @deprecated(reason: "Use priceInfo") so tools warn clients. Because clients only receive the fields they ask for, adding fields never breaks them.
Q. How does Spring for GraphQL solve the N+1 problem?
Answer: Use @BatchMapping. Spring collects all parent objects in one request and calls your method once with the whole list, using a DataLoader internally.
@BatchMapping
Map<Track, Artist> artist(List<Track> tracks) {
return artistService.findByTracks(tracks); // one query for all tracks
}
Q. How do you protect a GraphQL API from very expensive queries?
Answer: A client can write a deeply nested query that makes the server do enormous work. Common defences are a maximum query depth, a complexity or cost limit per query, timeouts, and persisted queries, where only pre-approved queries can run. Many teams also turn off schema introspection for public production APIs.
Q. How are errors reported in gRPC?
Answer: gRPC has its own status codes, such as NOT_FOUND (5), DEADLINE_EXCEEDED (4) and UNAVAILABLE (14). The server sends them in the grpc-status trailer, while the HTTP status is normally 200. In Java, the client receives a StatusRuntimeException, and you check e.getStatus().getCode().
Q. How does each style document its contract?
Answer: REST uses OpenAPI (formerly Swagger); in Spring Boot, the springdoc-openapi library can generate it from your controllers. GraphQL's schema is the contract, and clients can read it with introspection queries. gRPC's .proto files are the contract, and servers can enable server reflection so tools like grpcurl can discover services at runtime.
Tricky and scenario questions
Q. What is the difference between how GraphQL runs queries and mutations?
Answer: The top-level fields of a query may be resolved in parallel, because reads don't change anything. The top-level fields of a mutation are run one after another, in order, so that a later field sees the result of an earlier write. This rule comes from the GraphQL specification.
Q. What happens when an old protobuf reader receives a field it doesn't know?
Answer: It skips the unknown field using the wire type to know how many bytes to jump over, and reads the rest normally. Since protobuf 3.5, unknown fields are also kept when the message is re-serialised. This is why adding new fields is a backward-compatible change.
Q. Scenario: A mobile screen makes 6 REST calls and feels slow on 3G. What are your options?
Answer: Add a Backend-for-Frontend (BFF) service that gathers the data server-side and returns one tailored response. Or support field selection (?fields=) and include related data in one response. Or put a GraphQL layer in front of the services so the app sends one query. All three cut the round trips that dominate latency on slow networks.
Q. Scenario: An Order service must stream live rider locations to the Delivery service. Which style fits?
Answer: gRPC server streaming fits well: one call opens a stream, and the server pushes a small binary message for each location update over the same HTTP/2 connection. Polling a REST endpoint every second would waste requests and still add delay. If a browser must show the map, use Server-Sent Events, WebSockets or GraphQL subscriptions at the edge instead.