You are about to start a project and need to decide how clients will talk to the server: REST, GraphQL, gRPC or tRPC. The short answer is that the choice depends on who consumes the API, not on how popular the protocol is. For a public API used by developers you do not know, REST is almost always the least risky choice. For different clients that ask for different slices of the same data, GraphQL moves the choice of fields from the server to the client. For internal services that call each other thousands of times, gRPC offers a binary contract and streaming. For a TypeScript frontend and backend that live in the same repository, tRPC removes the layer of schemas and generated code.
This is the first article in a series: here you build the selection criterion, and in the other two you go deeper into REST and GraphQL. A note on method: on this page you will find no borrowed adoption percentages or latency benchmarks. The only figures are measurements taken with the listings you see, on declared test data, and every listing was executed. Where a comparison depends on your machine or your data, the text says so.
What the four candidates really are
The four names are not on the same level, and confusing them is the first source of wrong choices. REST is not a protocol: it is
an architectural style, described by Roy Fielding in chapter 5 of his doctoral dissertation, that exploits the
semantics of HTTP (methods, status codes, caching) now defined by RFC 9110. GraphQL is a query language with
its own specification: the client describes the shape of the response and the server builds it by resolving one field at a time.
gRPC is a remote procedure call system that runs over HTTP/2, with Protocol Buffers as both the service definition language
and the message format (the content type can also be application/grpc+json, but the
typical case is Protobuf). Finally, tRPC is a TypeScript library: it does not define a new wire format, it uses the type
of the server-side router as the contract for the client.
REST and GraphQL on the same data
The most concrete way to see the difference is to ask for the same data in two ways. Imagine a screen that shows a user's name and
email together with their last five orders (id, total, status). With REST there are two resources, /users/123
and /users/123/orders, and each answers with all of its fields, whether you need them or not. With GraphQL there is a single
request in which the client lists the fields it needs.
The listing below opens no ports: the REST handlers are (Request) => Response functions called directly
with Request objects (available in Node 24), and GraphQL runs in process with Apollo Server. The data is the same: a user
with eight fields and twenty orders with seven fields each. The lines that matter are the /users/123/orders route, which accepts
limit but still returns all the order fields, and the GraphQL query, in which orders(last: 5)
limits the rows and { id total status } limits the columns.
// rest-vs-graphql.ts - same data, two ways of asking for it. No server listening:
// the REST handlers are (Request) => Response functions, GraphQL runs in process.
import { ApolloServer } from "@apollo/server";
const user = {
id: 123, name: "Federico", email: "federico@example.com", phone: "+39 333 0000000",
city: "Lecce", bio: "Software developer", plan: "pro", createdAt: "2025-01-15T10:30:00Z",
};
const orders = Array.from({ length: 20 }, (_, i) => ({
id: 1000 + i, userId: 123, total: 20 + i, status: "SHIPPED", createdAt: `2026-01-${String(i + 1).padStart(2, "0")}`,
shippingAddress: "Via Roma 1, Lecce", note: "", items: [{ sku: "A1", qty: 1 }, { sku: "B2", qty: 2 }],
}));
// --- REST: two resources, two requests, every response carries ALL the fields
function rest(req: Request): Response {
const url = new URL(req.url);
if (url.pathname === "/users/123") return Response.json(user);
if (url.pathname === "/users/123/orders") {
const limit = Number(url.searchParams.get("limit") ?? 20);
return Response.json(orders.slice(-limit));
}
return Response.json({ title: "Not Found", status: 404 }, { status: 404 });
}
let restBytes = 0;
let restCalls = 0;
for (const path of ["/users/123", "/users/123/orders?limit=5"]) {
const res = rest(new Request(`http://api.test${path}`));
const bytes = Buffer.byteLength(await res.text());
restBytes += bytes;
restCalls++;
console.log(`REST GET ${path.padEnd(28)} -> ${res.status}, ${bytes} bytes`);
}
// --- GraphQL: one request, the client lists the fields it needs
const typeDefs = `#graphql
type Order { id: ID! total: Float! status: String! }
type User { name: String! email: String! orders(last: Int = 20): [Order!]! }
type Query { user(id: ID!): User }
`;
const resolvers = {
Query: { user: () => user },
User: { orders: (_: unknown, { last }: { last: number }) => orders.slice(-last) },
};
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();
const query = `{ user(id: "123") { name email orders(last: 5) { id total status } } }`;
const res = await server.executeOperation({ query });
if (res.body.kind !== "single") throw new Error("unexpected response");
const gql = Buffer.from(JSON.stringify(res.body.singleResult));
console.log(`GraphQL POST /graphql (1 query) -> 200, ${gql.length} bytes`);
console.log(`\nREST: ${restCalls} requests, ${restBytes} bytes | GraphQL: 1 request, ${gql.length} bytes`);
await server.stop();
// Output (node rest-vs-graphql.ts):
// REST GET /users/123 -> 200, 175 bytes
// REST GET /users/123/orders?limit=5 -> 200, 901 bytes
// GraphQL POST /graphql (1 query) -> 200, 299 bytes
//
// REST: 2 requests, 1076 bytes | GraphQL: 1 request, 299 bytes
On the test data the REST path costs two requests and 1,076 bytes, the GraphQL one a single request and 299 bytes. This is what is
meant by over-fetching (fields arrive that you do not need: phone, city, shipping address) and under-fetching
(a single resource is not enough and you need more round trips). The ratio between 1,076 and 299 is not a law: it depends on how wide the objects are,
and REST can narrow the gap with parameters such as ?limit= (in the listing) or with endpoints designed for the screen. What
GraphQL adds is that the choice of fields is standard and does not have to be agreed endpoint by endpoint. In exchange the work does not
go away: the server has to resolve every field, and how to do that without multiplying queries is covered in the article on the
N+1 problem.
HTTP caching: where REST has a concrete advantage
The advantage most often cited for REST is caching, and it deserves a precise statement. A GET is a safe method:
RFC 9110 (section 9.2.1) defines it as a request whose expected effect is read-only, and for this reason browsers, CDNs and
reverse proxies can store the response and reuse it according to the rules of RFC 9111. A response to POST, on the other hand, can be
stored only if it carries explicit freshness information and a Content-Location equal to the request URL
(RFC 9110, section 9.3.3): in practice, almost never.
| Style | HTTP method to read | Shared cache (CDN, proxy) |
|---|---|---|
| REST | GET on the resource URL | Available with the standard rules of RFC 9111 |
| GraphQL | POST with the query in the body; the GraphQL-over-HTTP specification also allows GET for queries | Only if you use GET for queries and control the cache headers |
| gRPC | Always POST (the protocol specification fixes :method POST) | Not usable as a shared HTTP cache |
The table corrects a widespread idea, namely that with GraphQL HTTP caching is impossible: the draft GraphQL-over-HTTP specification
allows GET requests for queries (and forbids using them for mutations, with a 4xx response and 405 recommended). The
cost is that the URL contains the entire query, and that every client must write the same query in the same terms for the cache
to work. With gRPC the situation is different: there is no shared HTTP cache, because every call is a POST, and caching has to be built inside the
application. If your API serves public content that is read many times, this row of the table alone can decide the matter.
gRPC: a binary contract for internal services
With gRPC the contract is a .proto file from which client and server code is generated in several languages. The service
below declares two methods: GetUser, a simple call, and ListUsers, which returns
stream User, that is a sequence of messages sent one at a time. The number next to each field (id = 1,
name = 2) is not a display order: it is the field's identifier on the wire, and changing it breaks compatibility
with messages already written.
syntax = "proto3";
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc ListUsers (ListUsersRequest) returns (stream User);
}
message User {
int64 id = 1;
string name = 2;
string email = 3;
bool active = 4;
}
message GetUserRequest { int64 id = 1; }
message ListUsersRequest { int32 page_size = 1; }
message UserList { repeated User users = 1; }
To measure how much Protobuf weighs compared with JSON I use the protobufjs library, which loads the same
.proto file (if the syntax were wrong, protobuf.load would fail immediately) and encodes a thousand synthetic users. The
listing compares three things: the raw size, the size after gzip (the compression HTTP uses out of the box) and the
round trip, that is, that all thousand users come out of the decoder.
// proto-size.ts - how much does the SAME list of 1000 users weigh in JSON and in Protobuf?
import protobuf from "protobufjs";
import { gzipSync } from "node:zlib";
const root = await protobuf.load("users.proto"); // if the .proto is wrong, it fails here
const UserList = root.lookupType("UserList");
const service = root.lookupService("UserService");
console.log("UserService methods:", service.methodsArray.map((m) => `${m.name}${m.responseStream ? " (stream)" : ""}`).join(", "));
const users = Array.from({ length: 1000 }, (_, i) => ({
id: i + 1, name: `User ${i + 1}`, email: `user${i + 1}@example.com`, active: i % 3 !== 0,
}));
const json = Buffer.byteLength(JSON.stringify(users));
const message = UserList.create({ users });
const proto = UserList.encode(message).finish().length;
console.log(`JSON : ${json} bytes`);
console.log(`Protobuf : ${proto} bytes`);
console.log(`ratio : ${(proto / json * 100).toFixed(1)}% of the JSON`);
// With gzip compression (which HTTP uses out of the box) the gap narrows
const jsonGz = gzipSync(JSON.stringify(users)).length;
const protoGz = gzipSync(UserList.encode(message).finish()).length;
console.log(`gzip : JSON ${jsonGz} bytes, Protobuf ${protoGz} bytes`);
// round trip: what comes out of the decoder is what went in
const back = UserList.toObject(UserList.decode(UserList.encode(message).finish())) as { users: unknown[] };
console.log("users after decode:", back.users.length);
// Output (node proto-size.ts):
// UserService methods: GetUser, ListUsers (stream)
// JSON : 73014 bytes
// Protobuf : 36991 bytes
// ratio : 50.7% of the JSON
// gzip : JSON 8273 bytes, Protobuf 7023 bytes
// users after decode: 1000
On the test data Protobuf takes up 50.7% of the JSON (36,991 bytes against 73,014), because it does not repeat the field names (it replaces them with their number) and encodes integers as varints, which use from one to ten bytes and less space for small values, as the encoding documentation explains. After gzip the gap narrows a lot: 7,023 bytes against 8,273, about 15% less. The reason is that the synthetic data is repetitive and compression handles it very well, so the number should be read as an order of magnitude for a constructed case, not as a forecast for your payloads. I did not measure latency, because it depends on network, languages and hardware: if your bottleneck is serialization, measure on your own load before choosing.
The reasons gRPC wins in internal services are others, and more structural: the contract is mandatory and typed, the code is generated, and streaming (from server, from client or bidirectional) is part of the call model, as the page on gRPC's core concepts describes. The price is a code-generation step in the build and less immediate debugging, because the messages cannot be read by eye.
tRPC: the server's types inside the client
tRPC was born for one specific case: TypeScript frontend and backend in the same repository. According to its documentation it lets you
build fully typed APIs "without schemas or code generation", because it directly uses TypeScript's type inference
and reports problems at compile time. In the router below the getUser procedure declares its input with Zod
(z.object({ id: z.number() })) and its return value is inferred from the body of the function.
The last line exports the type of the router, AppRouter: this, and not a schema file, is the contract
a client imports.
// trpc-server.ts - the tRPC router; the TYPE of appRouter is what the client imports
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const users = [
{ id: 1, name: "Anna", email: "anna@example.com" },
{ id: 2, name: "Bruno", email: "bruno@example.com" },
];
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.number() }))
.query(({ input }) => users.find((u) => u.id === input.id) ?? null),
});
export type AppRouter = typeof appRouter;
export const createCaller = t.createCallerFactory(appRouter);
The client in the next listing calls the procedure in process with createCaller, so with no network and no ports.
In production the router is exposed through an HTTP adapter (I did not run it here, because it would require a listening server). The line
with @ts-expect-error passes a string where a number is required: tsc accepts it as an expected error, and at runtime it is Zod that
rejects the input with a TRPCError. The two defences are different and do not replace each other: the types protect you while
you write the code, validation protects the server from whoever does not use your types.
// trpc-client.ts - in-process call (no network) and type inference
import { createCaller } from "./trpc-server.ts";
const caller = createCaller({});
const user = await caller.getUser({ id: 1 });
console.log("result:", JSON.stringify(user));
try {
// @ts-expect-error: the input must be a number, and tsc knows it without generating code
await caller.getUser({ id: "1" });
} catch (e) {
console.log("at runtime, wrong input ->", (e as Error).constructor.name);
}
// The type of `user` is { id: number; name: string; email: string } | null
const mail: string = user?.email ?? "";
console.log("email:", mail);
// Uncomment the line below and tsc reports the error BEFORE running:
// const n: number = user?.name;
// Output (node trpc-client.ts):
// result: {"id":1,"name":"Anna","email":"anna@example.com"}
// at runtime, wrong input -> TRPCError
// email: anna@example.com
If you uncomment the line const n: number = user?.name; at the bottom of the file, tsc 7.0.2 reports the problem without running anything:
error TS2322: Type 'string | undefined' is not assignable to type 'number'. The type of user was inferred from the server code,
and if you change the server, the clients notice the change at the next type check. The limit lies in the same
sentence: the contract is a TypeScript type, so a client in Java or Swift cannot see it. For a public API this
is enough to rule tRPC out; for the internal dashboard of a single team it is exactly the advantage.
The decision matrix
Putting together what you have measured and read, the choice comes down to a few questions about your situation. The table turns them into a starting recommendation with the risk it carries: it is not a rule, it is the point from which to start the discussion.
| Situation | Starting point | Why | Risk to watch |
|---|---|---|---|
| Public API or for external partners | REST with an OpenAPI document | Any HTTP client can use it; standard caching and status codes | Over-fetching and proliferation of versions |
| Several clients (web, mobile, dashboard) with different needs on the same data | GraphQL | The client chooses the fields: 299 bytes against 1,076 in the example | N+1 queries, query cost to be limited, cache to be designed |
| Internal services that call each other often | gRPC | .proto contract, generated code, streaming, smaller messages | No shared HTTP cache, less immediate debugging |
| TypeScript frontend and backend in the same repository | tRPC | Types inferred without schemas or codegen | Client and server coupled; TypeScript clients only |
| Continuous data flows | gRPC with streaming | Streaming is part of the call model | A gRPC client is needed on every side |
Read the fourth column before the third: that is where wrong choices are paid for. Whoever picks GraphQL to "be modern" and has no different clients
ends up with the cost of N+1 without having the advantage. Whoever picks gRPC for a public API discovers that partners do not have your
.proto. The page that compares the options at greater length is REST vs GraphQL vs tRPC,
and the trade-off between the first two is taken up again in GraphQL vs REST: real-world trade-offs.
Combining several protocols without regret
In a medium-sized system you do not have to choose just one. The most common combination is REST towards the outside, for clients you do not control, gRPC between internal services, and tRPC (or GraphQL) for the frontend you own. It works if you respect two conditions: the boundary between protocols is a single service, which translates (a gateway or a BFF), and every extra protocol has a reason you can write in one line. Each addition multiplies tools, logging, authentication and tests: three protocols in a team of three people are a cost that usually exceeds the gain.
Five questions before choosing
If you want a procedure instead of a table, answer in this order: the first question cuts more options than the others.
- Who calls the API? External developers: REST. Only your TypeScript frontend: tRPC. Other services of yours: gRPC.
- Do the clients need different data? If so and there is more than one, GraphQL comes into play.
- Can the responses be put in a public cache? If so, keep reads on
GETwith stable URLs. - Do you have continuous flows? With gRPC streaming is native.
- What can the team do today? A new protocol costs weeks of learning and tooling.
The order here really matters, and it is the reason this is a numbered list: if you swap the first and the last question you end up choosing what the team already knows even when the clients are external. The first three questions concern the API and its consumers, the last two your organisation: if they do not line up, the choice that is simplest to run wins, because operational complexity is the cost that shows up later.
What you can do now
You can tell an architectural style (REST) from a query language (GraphQL), from an RPC system (gRPC) and from a type library (tRPC), and you know why HTTP caching depends on the method used and not on the name of the protocol. You have seen with reproducible measurements how much the traffic changes between REST and GraphQL in a concrete case, and how much the compactness of Protobuf is worth (and how little, after gzip). The next step is to go deeper into the protocol you chose: if it is REST, continue with best practices and versioning; if it is GraphQL, with resolvers and the N+1 problem.
Frequently asked questions
When is GraphQL a better fit than REST?
When several clients (for example web, mobile and a dashboard) read the same data in very different shapes and you want the client to choose the fields. If you have a single client or a public API to put in a cache, REST costs less to run.
Is gRPC always faster than REST?
There is no answer that holds everywhere. In the example on this page Protobuf takes up 50.7% of the JSON and after gzip the advantage drops to about 15%, but latency was not measured and depends on network, languages and hardware. Measure on your own load before deciding.
Can tRPC be used for a public API?
As a rule no: the tRPC contract is a TypeScript type, so it serves TypeScript clients that share code with the server. For external developers, with different languages, REST with OpenAPI is the most portable choice.
Sources
- RFC 9110, HTTP Semantics - safe methods (section 9.2.1) and the conditions for storing responses to POST (section 9.3.3), consulted on 2026-11-12
- RFC 9111, HTTP Caching - shared cache rules for responses to GET, consulted on 2026-11-12
- Roy Fielding, Architectural Styles and the Design of Network-based Software Architectures, chapter 5 - REST as an architectural style, consulted on 2026-11-12
- GraphQL Specification, October 2021 - the client describes the shape of the response, the server resolves the fields, consulted on 2026-11-12
- GraphQL over HTTP (draft) - GET requests allowed for queries and forbidden for mutations, consulted on 2026-11-12
- gRPC over HTTP2 - fixed POST method and content type
application/grpcwith the+protoor+jsonsuffix, consulted on 2026-11-12 - gRPC: core concepts - call types, including bidirectional streaming, consulted on 2026-11-12
- Protocol Buffers: Encoding - varints and field numbers in the wire format, consulted on 2026-11-12
- tRPC: Introduction - typed APIs without schemas or code generation, consulted on 2026-11-12
The five listings were run with Node 24.11.0 (@apollo/server 5.5.2, graphql 16.15.0, @trpc/server 11.28.0, zod 4.6.7, protobufjs 8.8.1, tsc 7.0.2) and the output shown is the real one, identical across two consecutive runs. The bytes of the first and third listing depend on the test data written in the listing.

Commenti
Caricamento commenti...
Sign in to comment