The idea: one screen, three ways to ask for it

A phone app opens a post. The screen shows the post's title and text, the author (name and avatar) and the 3 newest comments. The page loads that same screen through three APIs at once, one lane each, and counts what it costs: requests, round trips (how many times the app must wait for the network before the screen is complete; one round trip is 100 ms here) and bytes of request and response bodies, with the part of them the screen never shows (unused).

Three sequence diagrams side by side. REST: GET /posts/101 and GET /posts/101/comments in parallel, then GET /users/42 after the post arrives: 3 requests, 2 round trips. GraphQL: one POST /graphql with a query, the server's resolvers fetch post, author and comments, one response: 1 request, 1 round trip. gRPC: GetPost and ListComments as two streams on one HTTP/2 connection, then GetUser: 3 calls, 2 round trips, protobuf bodies
REST and gRPC ask per resource or method, so a dependent call (the author) costs a second round trip; GraphQL sends the whole screen as one query.

Each Step moves every lane by one unit of work: a request leaves, an answer comes back. The client box is the app's screen and fills in as data arrives; under the server, the last body on the wire is shown as it really is: JSON text, or protobuf bytes in hex.

REST: one URL per resource

REST models the data as resources with URLs: /posts/101, /posts/101/comments, /users/42. The app GETs each one and gets JSON back. Two things follow (Demo: the post screen):

  • Several requests, and waterfalls. The post and its comments can be fetched in parallel, but the author's id is inside the post, so GET /users/42 can only start after the first answer: 3 requests, 2 round trips.
  • Over-fetching. Every answer is the whole resource. The user object has an e-mail, a bio, a location, follower counts and a creation date; the screen shows only the name and the avatar. On this screen 527 of the 973 bytes are never shown. The opposite problem, under-fetching, is the extra request for the author.

Teams fix both by adding endpoints shaped like screens (/screens/post/101, a "backend for frontend"), by ?fields= and ?include=author parameters, or by an API gateway that aggregates calls on the server side, where round trips are cheap.

GraphQL: the client writes the shape

GraphQL exposes one endpoint and a typed schema. The app sends a query that names exactly the fields the screen needs:

{ post(id: 101) {
    title  body
    author { name avatarUrl }
    comments(first: 3) { text author { name } }
} }

The server runs a resolver per field: post loads the post, author loads the user from the post's authorId, comments loads the comments. The dependency still exists, but it is resolved inside the data centre, so the app waits for one round trip and gets no unused bytes. The costs: the query text travels up with every request (98 bytes here), the server must guard against expensive queries (depth and cost limits), and a naive resolver per item turns a list into N+1 database queries (batched with a DataLoader). Demo: only the author's name is where exact fields matter most: GraphQL sends 72 bytes in total, REST 285.

gRPC: methods and protobuf

gRPC defines services and messages in a .proto file and generates client stubs. The app calls GetPost, ListComments and GetUser like local functions; each call is an HTTP/2 stream on one shared connection (see How a gRPC Call Travels over HTTP/2). Like REST, it returns whole messages and needs a second round trip for the author. Unlike REST, the bytes are Protocol Buffers:

The same comment encoded twice. JSON: the text with every field name in quotes, the numbers as digits and the time as a 20-character string. Protobuf: a one-byte tag per field (field number and wire type), varint numbers and a 6-byte timestamp, behind the 5-byte gRPC prefix; the protobuf version is less than half the size
JSON repeats every field name as text; protobuf sends a one-byte field number, so the same comment is less than half the size.

Each field is a tag (field number × 8 + wire type, usually one byte), then the value: numbers as varints (101 is one byte, 1 280 two), strings as length + bytes, times as a google.protobuf.Timestamp. Field names never go on the wire; both sides know them from the .proto. Even on the post screen gRPC sends the fewest bytes, 553 against GraphQL's 570 and REST's 973, although 197 of its bytes are fields the screen never shows. And it is why gRPC wins Demo: 50 comments, every field: 2 702 bytes against 6 373 for GraphQL and 6 905 for REST. When the screen needs only a few fields of a big message, GraphQL's exact JSON can still be smaller than a whole protobuf message (Demo: only the author's name); gRPC answers that with a FieldMask in the request.

Caching and live updates

A REST GET answer carries Cache-Control: max-age=60 and an ETag, so the browser, a CDN or a proxy can keep it. In Demo: open it again the second load happens 5 s later: REST draws the screen from the HTTP cache with 0 requests and 0 bytes, while GraphQL and gRPC pay the full price again, because their calls are POSTs that HTTP caches do not store. GraphQL clients make up for it with their own normalized cache (Apollo, Relay) and with persisted queries sent as GET; gRPC leaves caching to the application.

Demo: live comments keeps the screen open for 6 s while someone posts a comment at t = 3 s. A server cannot call a REST client, so the app polls every 2 s with If-None-Match: two answers are 304 Not Modified (a round trip for nothing), one is the whole list again, and the comment shows up 1 100 ms late. A GraphQL subscription (over a WebSocket) and a gRPC server-streaming call keep a channel open, and the server pushes the comment the moment it is saved: 50 ms, one way.

When to pick which

RESTGraphQLgRPC
shape of the APIresources and URLsone schema, client-written queriesservices and methods in .proto
wire formatJSON text (any)JSON textprotobuf binary
transportHTTP/1.1 or laterHTTP POST, WebSocket for subscriptionsHTTP/2 only
round trips for a screenone per dependency level1one per dependency level
unused byteswhole resourcesnonewhole messages (unless a FieldMask)
HTTP cachingyes, GET + ETag, CDNsnot for POSTno
pushpolling, SSE, webhookssubscriptionsserver, client and bidirectional streaming
browserseverywhereeverywhereneeds gRPC-Web and a proxy
good forpublic APIs, cacheable data, simple CRUDmany different screens over one data graph (mobile and web clients)service-to-service calls, low latency, streaming, strict contracts

Many systems use more than one: gRPC between internal services, and a REST or GraphQL layer (often in a gateway) for browsers and mobile apps. See also HTTP/1.1 vs HTTP/2 vs HTTP/3 for what HTTP/2 multiplexing does underneath.

What the page leaves out

Headers (HTTP/1.1 sends them as text on every request; HTTP/2 compresses them with HPACK), TCP and TLS handshakes (all connections are warm), compression (gzip shrinks JSON a lot and narrows the gap to protobuf), JSON parsing cost on the phone, HTTP/2 for REST and GraphQL, server-side cost of resolvers and the N+1 problem, query cost limits, pagination cursors, errors (HTTP status codes vs GraphQL's errors array with 200 vs grpc-status), versioning and schema evolution, Server-Sent Events, gRPC-Web and JSON transcoding.