Networking & Traffic · lesson 6 of 7 · about 30 min
REST, gRPC and GraphQL
How to choose between REST, gRPC and GraphQL by what each costs on the wire and in round trips: over-fetching, real protobuf bytes, streaming, pagination, changing a contract safely, and the query explosions GraphQL lets a client cause. Tap any for a plain-language card.
REST gives you whole resources. A client often wants three fields of each.
The sizes here are measured: sample user, order and item objects of realistic shape, serialised with JSON.stringify, against the same data with only the fields the screen needs. The latency counts round trips: the user and order list one after another, then item calls in parallel six at a time.
Set 10 orders at 150 ms: REST makes 12 calls and downloads most of what it never shows.
REST: downloaded
5.2 KB
GraphQL: downloaded
613 B
REST calls7
GraphQL calls1
REST time (round trips)300 ms
GraphQL time100 ms
To show 5 orders of 3 items, REST makes 7 calls, downloads 5.2 KB (the screen uses 613 B, 12%), and needs about 300 ms of round trips. One GraphQL query returns 613 B in about 100 ms. The price is a more complex server, harder caching and the risks in the last scene.
Sizes from JSON.stringify of sample objects (user 14 fields, order 12, item 9). REST time: two dependent calls, then item calls six at a time. Illustrative shapes, real arithmetic.
On the wire
JSON repeats every field name in every message. Protobuf sends a number.
This page contains a working protobuf encoder and decoder. Change the values and see the actual bytes (in hexadecimal), the size against JSON, and a round-trip decode. Large numbers cost more bytes than small ones.
Set the order id to 5 and the total to 100, then to a ten-digit number, and watch the byte count follow.
The same order is 113 bytes as JSON and 33 bytes as protobuf (71% smaller). At 50K messages a second that is 5.7 MB/s against 1.7 MB/s. Decoding the 33 bytes with the schema gives the original back. Without the schema they are just numbers: that is the trade-off.
Real encoder: varint integers, length-prefixed strings, tag = field number << 3 | wire type. The same order object is serialised by JSON.stringify for the comparison.
Call shapes
A request does not have to wait for the whole answer.
The numbers are straightforward arithmetic: a unary call returns when the last of n results is ready; a stream delivers each result after its own work and the client sees the first one after one result's time plus a round trip. Streaming also keeps one connection open instead of paying request overhead each time, and lets the client apply back-pressure.
With 100 results at 20 ms each, unary shows nothing for over two seconds. Streaming shows the first at about 100 ms.
Unary: first result visible
2080 ms
Streaming: first result visible
100 ms
Streaming: last result visible
2080 ms
First result, unary2080 ms
First result, streaming100 ms
Sooner by95%
100 results at 20 ms each: a unary call shows its first result after 2080 ms, because it waits for all of them. Streaming shows the first after 100 ms and the last at the same 2080 ms. Total work is identical; perceived speed is not.
Arithmetic: unary first = RTT + n × work; streaming first = RTT + work. Ignores batching of small messages and flow control.
Paging through results
“Page 3, please” means different things when the list moves.
Below, a client reads 10 pages of 10 from a feed of 100 items, newest first. Between pages, new items arrive at the top and already-seen items may be deleted. Count what each style does to it.
Set 3 new items per page and 0 deletions, then 0 new and 2 deletions.
Offset: duplicates shown
27
Offset: items never shown
27
Cursor: duplicates shown
0
Cursor: items never shown
0
Rows read, offset10K
Rows read, cursor10
Offset costs1001× more
With 3 new and 0 deleted items between pages, offset paging showed 27 items twice and never showed 27. Cursor paging showed 0 duplicates and missed 0. At page 1,000 offset also reads 10K rows to return 10; a cursor reads 10.
A seeded simulation of 10 pages over a 100-item feed. Row counts: offset reads page × 10 + 10 rows; a cursor uses an index seek and reads 10.
Evolving the contract
Servers update on Tuesday. Phones update whenever the user feels like it.
Below, a v1 reader (expects id as a number, name and email as text) decodes messages written by a changed schema. For protobuf the page runs a real encoder and decoder; for JSON it reads the real object by field name. The two formats fail in different ways: protobuf identifies fields by number, so a rename is harmless but reusing a number silently corrupts data; JSON identifies by name, so a rename breaks the reader but number reuse does not exist.
Try “Rename” and “Reuse number 3”: the two formats swap which one is dangerous.
Change
Old client reads protobuf
Old client reads JSON
Add an optional field (phone, number 4)
ok
ok
Stop sending a field (email)
missing email
missing email
Rename name to fullName
ok
missing name
Change id from a number to text
error: field 1: expected a number, got text
wrong type: id is text
Reuse number 3 for a different field (nickname)
wrong value: email reads "ash"
missing email
"Add an optional field (phone, number 4)": an old protobuf reader gets everything it expects; an old JSON reader gets everything it expects. Additive changes are safe in both: unknown fields are skipped.
Protobuf rows run through the real encoder/decoder on this page: a wire-type mismatch is an error, an unknown field number is skipped. JSON rows read the object by key.
Which API style?
Six systems, three styles.
No style is best everywhere: each fits some callers. Read who calls the API and what they need. Each answer is worth 20 XP.
Ask: who are the callers?Is it internal or public?Do screens need very different shapes of data?
Question 1 / 6
A public API for thousands of third-party developers, mostly create, read, update and delete, that must be easy to try with curl and cache at a CDN.
Tip: press 1 to 3 to answer, Enter for the next one.
Who lives by this
Every platform picked a style, and several picked two.
Four real APIs and what their owners have said about the choice.
A REST API people copyStripe
POST /v1/charges · Idempotency-Key header
Stripe's REST API is a model of the style: resource URLs, JSON, predictable errors, versioning by date, and an Idempotency-Key header so a retried POST does not charge twice. Its documentation is as much a product as the API.
From Stubby to open sourceGoogle gRPC
service A → protobuf over HTTP/2 → service B
Google used an internal RPC system called Stubby for years and open-sourced gRPC in 2015, built on HTTP/2 and Protocol Buffers, with code generation for many languages. It is now common for service-to-service traffic and is a CNCF project.
Born for mobileFacebook GraphQL
one query → exact fields → one response
Facebook created GraphQL internally around 2012 to fix its mobile apps' need for many REST calls and over-fetched data, and open-sourced it in 2015. The query names the fields it wants and the server returns that shape, in one round trip.
Both at onceGitHub
REST v3 and GraphQL v4
GitHub offers a REST API and a GraphQL API side by side. Its GraphQL API protects itself with a cost model: each query is scored in points based on the number of nodes it could return, against an hourly budget of 5,000 points, rather than a count of requests.
Interview angle
What interviewers actually ask.
Six questions, each worth 20 XP. “REST or gRPC?” and “how do you paginate?” come up in nearly every API design.
Name the callers, the payload shape, how you will version it, and how you will page.
Question 1 / 6
What is over-fetching?
Tip: press 1 to 3 to answer, Enter for the next one.
Break it
The client chooses the query, so the client can choose an expensive one.
The fixes are (collect all the ids needed at one level and fetch them in one query, so calls grow with depth, not with objects) and limits: a maximum depth, a query cost estimate that rejects expensive queries before running them (GitHub's GraphQL API charges “points” per query against an hourly budget), and persisted queries for known clients.
Start at fan-out 10, depth 3 with no protection: 1,111 calls. Then batch, then limit the depth.
Database calls, naive resolvers
1.11K
Database calls, batched
4
Objects in the response1.11K
Database calls now1.11K
Time in the database2.2 s
1.11K objects with one call each is 1.11K database calls (about 2.2 s at 2 ms each) from a single request.
Objects = 1 + f + f² + … + f^d. Naive: one call per object. Batched: one per level. The 2 ms per call is illustrative.