One profile screen. A name and photo, the last four posts with their titles and like counts, and the top comment under each post.
That screen is the whole argument, so keep it in mind.
What REST costs
A standard REST API gives you three shapes of address:
GET /users/42
GET /users/42/posts?limit=4
GET /posts/{postId}/comments?limit=1
The screen needs one profile, one list of posts, and one comment for each of the four posts. That is six requests.
Six requests is not the same as six waits, and this is the part that gets answered wrongly most often.
A request waits only when it needs a value from an earlier response. Your app already knows the user ID, so the profile call and the posts call go out together. The four comment calls need post IDs, and post IDs only arrive in the posts response, so those four wait for one thing and then go out together too.
Two waves, not six steps:
wave 1 profile |=====|
posts |=====|
wave 2 comment 9001 |=====|
comment 9002 |=====|
comment 9003 |=====|
comment 9004 |=====|
At 80 ms per round trip that is about 160 ms, not 480 ms. You only reach 480 ms if you send all six one after another, and that staircase is what people mean by a request waterfall.
What came back
The profile endpoint returned 24 fields. The screen uses two: the name and the avatar URL. Every post arrived with its full body text, and the screen shows a title and a number.
In a small model of that screen, with estimated HTTP headers included, those six calls came to about 9,400 bytes. The version shaped for what the screen actually shows came to about 1,100.
The obvious fix is to let the client name the fields it wants:
GET /users/42?fields=name,avatarUrl
That pattern is called sparse fieldsets, and it is real. It reduces the bytes. It does not reduce the trips, because the app still makes six requests.
The move that fixes both
A field can contain other fields.
{
user(id: 42) {
name
avatarUrl
posts(limit: 4) {
title
likeCount
topComment {
author
text
}
}
}
}
Read it from the outside in: each post should also include its top comment. Notice what is missing. There are no post IDs anywhere. The client never needed them, because the nesting already tells the server what to do with each post.
One request, about 1,100 bytes, one round trip. That is GraphQL: a query, sent to one address, against a schema that lists every field and its type.
Now the bill
Here is where most comparisons stop, and where the interesting part starts.
A GraphQL server answers a query with resolvers, and a resolver runs per field. The top comment resolver runs once for every post it receives.
So the naive server does one database lookup for the user, one for the posts, and four more for the comments. Six database queries inside the one request you were proud of.
With a hundred posts, the post and comment part becomes 101 queries. Add the user lookup and the total is 102.
That is the N+1 problem: load a list, then fire one more query for every row in it. The cure is to batch those post IDs into a single query, which is what DataLoader style batching does.
But look at what actually happened:
- GraphQL removed the waterfall on the client, where a round trip costs about 80 ms.
- A naive resolver created a fan-out on the server, where a round trip costs about 1 ms. That is still a large win. It is just a different claim from "GraphQL is faster". The honest version is: it moves the round trips to where they are cheap, and it deletes the bytes you never wanted.
Two more costs
Shared caching. REST APIs commonly use GET, and browsers, proxies and CDNs key their caches on method plus URL. A shared cache will not look inside a request body, and GraphQL usually posts its query in the body. So REST gets browser cache, proxies, CDNs and conditional requests for free, and with GraphQL you rebuild that yourself. Persisted queries get some of it back: register the query, then send a GET with a query ID.
Query complexity. One query can ask for posts, then the authors of their comments, then the posts by those authors. Every level multiplies, and it is all still one request. That is why services price queries instead of counting them. GitHub assigns point costs to GraphQL queries and gives a normal user 5,000 points per hour.
And the third door
Inside a company's network the two problems above matter much less. You control both sides, so choosing fields is a code change, not a negotiation, and a call between two services in the same data centre can be around 1 ms instead of 80.
What does cost you there is the text format itself. Every JSON call takes numbers the program already holds and writes them out as characters, with the field name spelled out in full every time, for a reader that is a program and not a person. At tens of thousands of calls a second that is real work, done twice, forever.
Binary formats can make those payloads smaller and faster to decode. In a small uncompressed test of the same payload, a custom binary version was about half the size. How much you gain depends on the codec and on the data, so treat that as a direction and not a universal number.
That is one reason teams reach for gRPC: a Protocol Buffers contract generates the code on both sides, field numbers replace field names on the wire, and it runs over HTTP/2, so multiplexing, streaming and deadlines come with it.
The trade-off is plain. Plain curl will not give you a readable response, and browsers cannot speak native gRPC, so you need gRPC-Web or a gateway in front.
They are layers
Companies rarely choose one of these for everything.
- REST for a broad public API, where familiar HTTP rules and tooling matter. You cannot coordinate with people you do not know, and constraints are what let every cache, proxy and browser help you without being asked.
- GraphQL for clients that need different, frequently changing data shapes. The server cannot know the shape of every screen, so the client defines it, and a UI change stops being a backend deploy.
- gRPC for services you control, where strong contracts and efficient calls matter. Nobody reads these bytes, so the cost of writing them as text is the cost that matters. The edge of the system talks to the outside world with REST or GraphQL. The services behind it often talk to each other with gRPC.
One more thing worth noticing: every call in this article assumes somebody is waiting for the answer right now. When nobody is waiting, the whole shape changes, and that is a different conversation.
The video builds all three doors from the same screen, with the waterfall, the query and the byte counts drawn out: https://youtu.be/HqWKl7wj_zI
Top comments (0)