Choosing an API style, REST vs GraphQL vs tRPC vs gRPC
Each style moves a cost: REST pays in round trips, GraphQL in server work and caching, tRPC in coupling, gRPC in browser reach.
Predict first
Before you read: predict
ExamHall's proctor console has two builds of the candidate card. The REST build sends three GETs (candidate, attempt, flags); the GraphQL build sends one POST query for the same fields. Every API answer carries Cache-Control: private, max-age=600. A proctor opens Ada's card 4 times in 5 minutes, and nothing changes on the server.
How many API requests reach the server in each build?
The 30-second version
REST gives resources URLs and fixed shapes: a view may need several GETs and receive unused fields, but HTTP caches reuse GETs. GraphQL POSTs one query whose selection is the reply's shape; caches skip POSTs, and resolvers run per field, so batching and cost limits become server work. tRPC shares types within one codebase. gRPC sends numbered fields.
Each style moves a cost: REST pays in round trips, GraphQL in server work and caching, tRPC in coupling, gRPC in browser reach.
How it works
Step 1 of 11
In the kitchen
- pre-cut bundles on hooks, each a fixed mix of pieces → REST resources (step 1)
Analogy: A fabric shop's cutting counter
A dressmaker can buy cloth in four ways.
| At the fabric shop | In the system |
|---|---|
| pre-cut bundles on hooks, each a fixed mix of pieces | REST resources (step 1) |
| a dress needing three bundles, each with pieces she will never sew | under- and over-fetching (step 2) |
| a cutting slip naming exact cloths and lengths, handed over once | a GraphQL selection (step 3) |
| the cutter unrolls and measures every line; a 500-line slip ties up the counter | resolvers per field, cost limits (steps 4–5) |
| bundles hang ready on hooks; a one-off slip is cut afresh each time, unless it is a numbered house kit | GETs reused, POSTed queries, persisted ids (steps 6–7) |
| the workroom's order pad, printed from the shop's own stock list | tRPC's shared type (step 8) |
| trade orders by cloth number; a renamed cloth keeps its number | protobuf field numbers (step 10) |
In real terms: pick the style by who shapes the data, where it can be cached, who shares the contract, and whether a browser must call it.
Breaks down when: you expect the cutter to refuse an absurd slip on sight. A GraphQL server refuses only what a validation rule you wrote checks (step 5).
Contrasting case: one dressmaker buying one bundle. Every style costs one request then; the differences show only with several resources, repeats, renames or streams.
Examples, run for real
4.1 ExamHall: the room card four ways
EXH-1390: the proctor console's room card sent 10 REST requests per open and carried candidate fields it never showed. MR !1590 moves the console's nested views to one GraphQL query and keeps the exam paper on REST.
4.1 proctor console room card (3 candidates): HTTP requests · response bytes · data-layer calls
REST, resource URLs 10 requests 1782 B 10 data-layer calls
GraphQL, one POST query 1 request 256 B 10 data-layer calls
tRPC, room.card procedure 1 request 192 B 10 data-layer calls (tRPC sent a GET)
gRPC, RoomCardOf unary call 1 request 81 B 10 data-layer calls
all four render the same card: true
REST pays in requests and bytes (step 2). GraphQL's one request still makes ten data-layer calls (step 4). The tRPC and gRPC procedures were written for this view. gRPC's figure is the protobuf message; the others are JSON bodies.
4.2 The exam paper and the browser cache
4.2 candidate app loads the ECON-201 paper 3 times; every answer carries Cache-Control: private, max-age=600
REST GET /api/exams/ECON-201/paper requests reaching the server: 1 (GET)
GraphQL POST /graphql requests reaching the server: 3 (POST)
GraphQL GET, persisted query id requests reaching the server: 1 (GET)
tRPC query paper.get requests reaching the server: 1 (GET)
gRPC puts each call's status in HTTP trailers; can a page's fetch() read trailers? false
Same header, different reuse: the method decides (steps 6, 7, 9). A page cannot read trailers, one reason gRPC needs a proxy (step 11).
4.3 A renamed field
4.3 the server renames candidate.seat to seatLabel; each client is unchanged, then built and run
REST, hand-written type tsc: 0 errors | runs: Ada Okafor · seat undefined
tRPC, imports the router type tsc: trpc-client.ts:7 TS2339 Property 'seat' does not exist on type '{ id: string; name: string; seatLabel: string; }'. | runs: not run: the build failed
GraphQL, query in a string tsc: 0 errors | runs: HTTP 200, errors: Cannot query field "seat" on type "Candidate".
gRPC, old .proto (3 = seat) server: seat_label = 3 | runs: Ada Okafor · seat B7
tRPC fails at build (step 8); REST silently shows undefined; GraphQL fails at run time, validated against the schema. gRPC keeps working: only field 3 travels (step 10).
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
A shop's storefront and its tRPC server live in one monorepo, and the storefront imports the server's type AppRouter. Yesterday's storefront build passed tsc and is live. Today the server renames the output field priceCents to unitPriceCents; the server's CI checks only the server package, and it deploys. The storefront is rebuilt tomorrow.
What does the live storefront show for the Tea tin, priced 1250?
Question 2 of 5
A streaming service's players fetch episodes over gRPC with version 1 of the .proto: message Episode { string id = 1; string title = 2; int32 runtime_s = 3; }. Version 2 adds each episode's season. Three drafts were reviewed and one shipped. Since the deploy, players still on version 1 show "Harbour Lights" (2700 s, season 2) as 2 s long.
Which draft shipped?
Question 3 of 5
A payouts screen shows three merchants, each with its name and latest payout. The client sends one GraphQL query, { m1: merchant(id: "m-1") { name payouts { amountCents } } m2: … m3: … }, with the same selection for m-2 and m-3. The merchants have 5, 3 and 4 payouts.
What comes back?
Question 4 of 5
A chat app's public GraphQL API has user(id) { name friends { … } }; friends can nest to any depth, and every user has 6 friends. Some clients now nest friends 5 and 6 levels deep. The team's budget is 300 runs of the friends resolver per request, whatever a client sends.
Which change keeps every request within it?
Question 5 of 5
A box office's kiosks are web pages in Chromium. Each must show seat holds live: a hold placed after the page connects should be pushed to it over one open connection. The hold service is one Node process with a plain HTTP port and a @grpc/grpc-js server whose WatchHolds streams holds; the team will not run another process.
How should the kiosk page receive the holds?
Go deeper
Pro tips
- Cache a GraphQL read by moving it into the URL. Serve hot, shared queries as persisted-query GETs, and keep POST for mutations. When: a GraphQL read is the same for many users (steps 6–7).
- Count a GraphQL query's cost in a test; never time it. Wrap each resolver with a counter and assert a ceiling for the deepest query a client may send. When: a field returns a list of the same type, such as a user's friends (steps 4–5). Verify: the counts repeat exactly: 259
friendsruns at depth 4, 9331 at depth 6. graphql.org also recommends query complexity analysis and trusted documents; not run here.
Source
- graphql.org, Serving over HTTP: https://graphql.org/learn/serving-over-http/ · fetched 2026-09-28. A server "must handle the HTTP
POSTmethod for query and mutation operations, and may also accept theGETmethod for query operations"; GET "may only be used forqueryoperations"; with persisted documents "the client [can] send a short document identifier instead of the full query text" (steps 3, 7). Valid as of 2026-09-28. - graphql.org, Caching: https://graphql.org/learn/caching/ · fetched 2026-09-28. In an endpoint-based API, "The URL in these APIs is a globally unique identifier that the client can leverage to build a cache"; "In GraphQL, there's no URL-like primitive" of that kind (step 6).
- graphql.org, Security: https://graphql.org/learn/security/ · fetched 2026-09-28. "it's a good idea to limit the maximum depth of fields that a single operation can have"; rate limits by request count need "a different approach" for GraphQL.
- RFC 9111 §2: https://www.rfc-editor.org/rfc/rfc9111#section-2 · the cache key "is composed from, at a minimum, the request method and target URI". RFC 9110 §9.3.3: https://www.rfc-editor.org/rfc/rfc9110#section-9.3.3 · "a POST request cannot be satisfied by a cached POST response because POST is potentially unsafe" (step 6).
- tRPC, Response caching: https://trpc.io/docs/server/caching · fetched 2026-09-28. "Since all tRPC queries are normal HTTP
GETrequests, you can use standard HTTP cache headers"; batching is on by default, so cache headers need care with personal data (step 9). tRPC introduction: https://trpc.io/docs/ · types come from "TypeScript's type inference directly, with no code generation step" (step 8). - Protocol Buffers, proto3 guide: https://protobuf.dev/programming-guides/proto3/ · fetched 2026-09-28. The field number "identifies the field in the message wire format" and "cannot be changed once your message type is in use"; a missing field returns "the default value for that field"; names are serialized only "when using TextProto or JSON encodings" (step 10).
- gRPC, Core concepts: https://grpc.io/docs/what-is-grpc/core-concepts/ · unary, server-streaming, client-streaming and bidirectional RPCs (step 11). gRPC blog, The state of gRPC in the browser: https://grpc.io/blog/state-of-grpc-web/ · re-fetched 2026-09-28: "there is simply no browser API with enough fine-grained control over the requests"; the design is to "have a small proxy in front of the gRPC server to translate the requests and responses to something the browser can use". Valid as of 2026-09-28.
- MDN, EventSource: https://developer.mozilla.org/en-US/docs/Web/API/EventSource · fetched 2026-09-28. "An
EventSourceinstance opens a persistent connection to an HTTP server, which sends events intext/event-streamformat. The connection remains open until closed by callingEventSource.close().". Valid as of 2026-09-28.
Related lessons
Checked against the tools and docs as of 2026-09-28.