Skip to main content

01 - The API Landscape in 2026: REST, GraphQL, gRPC, tRPC - When to Use What

Which protocol should your API use? REST, GraphQL, gRPC or tRPC: it depends on who calls it, with measurements of traffic and message size.

What do you think about this article?

01 - The API Landscape in 2026: REST, GraphQL, gRPC, tRPC - When to Use What
API DesignPhoto:Jakub PabisPexels

At a Glance

Which protocol should your API use? REST, GraphQL, gRPC or tRPC: it depends on who calls it, with measurements of traffic and message size.

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.

StyleHTTP method to readShared cache (CDN, proxy)
RESTGET on the resource URLAvailable with the standard rules of RFC 9111
GraphQLPOST with the query in the body; the GraphQL-over-HTTP specification also allows GET for queriesOnly if you use GET for queries and control the cache headers
gRPCAlways 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.

SituationStarting pointWhyRisk to watch
Public API or for external partnersREST with an OpenAPI documentAny HTTP client can use it; standard caching and status codesOver-fetching and proliferation of versions
Several clients (web, mobile, dashboard) with different needs on the same dataGraphQLThe client chooses the fields: 299 bytes against 1,076 in the exampleN+1 queries, query cost to be limited, cache to be designed
Internal services that call each other oftengRPC.proto contract, generated code, streaming, smaller messagesNo shared HTTP cache, less immediate debugging
TypeScript frontend and backend in the same repositorytRPCTypes inferred without schemas or codegenClient and server coupled; TypeScript clients only
Continuous data flowsgRPC with streamingStreaming is part of the call modelA 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.

  1. Who calls the API? External developers: REST. Only your TypeScript frontend: tRPC. Other services of yours: gRPC.
  2. Do the clients need different data? If so and there is more than one, GraphQL comes into play.
  3. Can the responses be put in a public cache? If so, keep reads on GET with stable URLs.
  4. Do you have continuous flows? With gRPC streaming is native.
  5. 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

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.

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects