00:00

REST vs GraphQL vs gRPC: Which API Style Should Your Java Microservices Use?

Last updated:

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.

HTTP requests
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.

schema.graphqls
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.

catalog.proto
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

How REST, GraphQL and gRPC exchange messagesREST: the client sends GET /playlists/7 and receives the full playlist as JSON, then sends GET /tracks/11 and receives the full track, with one more call for each track. GraphQL: the client sends one POST to /graphql with a query and receives only the chosen fields in one response; errors go in an errors array. gRPC: the client calls Catalog/GetBook with a binary request and receives a binary Book message followed by a trailer with grpc-status 0; many calls share one long-lived HTTP/2 connection. REST HTTP + JSON, one URL per resource Client Server GET /playlists/7 200 + full playlist JSON GET /tracks/11 200 + full track JSON ... one more call per track GraphQL usually POST + JSON, one URL Client Server POST /graphql { query } 200 { data: chosen fields } one round trip, errors go in an errors array gRPC HTTP/2 + binary protobuf Client Server Catalog/GetBook (binary) Book (binary) trailer: grpc-status 0 (OK) many calls share one long-lived HTTP/2 connection
REST needs a request per resource, GraphQL answers a field-selected query in one request, and gRPC sends binary messages to a named method over HTTP/2.

Three internal details explain most of the differences:

  1. 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.
  2. 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.
  3. Where errors live. REST uses HTTP status codes. GraphQL usually replies 200 OK and puts problems in an errors array. gRPC replies with HTTP 200 and sends its own status code (such as NOT_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.

FetchShapeDemo.java
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);
    }
}
Output
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

HttpSemanticsDemo.java
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());
    }
}
Output
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-Match and got 304 Not Modified with an empty body. Browsers, CDNs and proxies understand this automatically, because every resource has its own URL. GraphQL sends different queries to one URL via POST, so these standard HTTP caches cannot help without extra work.
  • Errors: REST reported the missing book as 404. GraphQL reported it as 200 with an errors array. 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.

ProtobufWireDemo.java
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)");
    }
}
Output
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:

  • 08 is the tag for field 1 with wire type 0 (varint), calculated as (1 << 3) | 0. AC 02 is 300 as a varint.
  • 12 05 means field 2, length-delimited, 5 bytes long, followed by the UTF-8 bytes of "Kafka".
  • 18 EC 85 03 is field 3 holding 49,900.
  • in_stock = false is 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

FeatureRESTGraphQLgRPC
Main ideaResources at URLsClient picks fields from a schemaCall remote methods
Typical formatJSON (text)JSON (text)Protocol Buffers (binary)
TransportHTTP/1.1 or HTTP/2HTTP/1.1 or HTTP/2, usually POST /graphqlHTTP/2 only
ContractOptional (OpenAPI)Required schemaRequired .proto file
HTTP cachingBuilt in (ETag, Cache-Control)Hard, needs extra toolingNot applicable
StreamingLimited (SSE, chunked responses)SubscriptionsClient, server and two-way streaming
Browser supportNativeNativeNeeds gRPC-Web and a proxy
Error reportingHTTP status codeserrors array, usually HTTP 200gRPC status codes in trailers
Best fitPublic APIs, simple CRUDVaried front-end screensFast 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 .proto files, 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 0 and false are not sent; use optional when "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.