Stai per iniziare un progetto e devi scegliere come i client parleranno con il server: REST, GraphQL, gRPC o tRPC. La risposta breve è che la scelta dipende da chi consuma l'API e non dalla popolarità del protocollo. Per un'API pubblica, usata da sviluppatori che non conosci, REST è quasi sempre la scelta meno rischiosa. Per client diversi che chiedono pezzi diversi degli stessi dati, GraphQL sposta la scelta dei campi dal server al client. Per i servizi interni che si chiamano migliaia di volte, gRPC offre un contratto binario e lo streaming. Per un frontend e un backend TypeScript che vivono nello stesso repository, tRPC elimina lo strato di schemi e di codice generato.
Questo è il primo articolo di una serie: qui costruisci il criterio di scelta, negli altri due approfondisci REST e GraphQL. Una precisazione sul metodo: in questa pagina non trovi percentuali di adozione o benchmark di latenza presi a prestito. Le uniche cifre sono misure fatte con i listati che vedi, su dati di prova dichiarati, e ogni listato è stato eseguito. Dove un confronto dipende dalla tua macchina o dai tuoi dati, il testo lo dice.
Che cosa sono davvero i quattro candidati
I quattro nomi non stanno sullo stesso piano, e confonderli è la prima fonte di scelte sbagliate. REST non è un protocollo: è
uno stile architetturale, descritto da Roy Fielding nel capitolo 5 della sua tesi di dottorato, che sfrutta la
semantica di HTTP (metodi, codici di stato, cache) definita oggi dalla RFC 9110. GraphQL è un linguaggio di interrogazione con
una specifica propria: il client descrive la forma della risposta e il server la costruisce risolvendo un campo alla volta.
gRPC è un sistema di chiamate di procedura remota che viaggia su HTTP/2, con Protocol Buffers come linguaggio di definizione
dei servizi e come formato dei messaggi (il tipo di contenuto può anche essere application/grpc+json, ma il
caso tipico è Protobuf). tRPC, infine, è una libreria TypeScript: non definisce un formato di rete nuovo, usa il tipo
del router lato server come contratto per il client.
REST e GraphQL sullo stesso dato
Il modo più concreto di vedere la differenza è chiedere lo stesso dato in due modi. Immagina una schermata che mostra il nome e
l'email di un utente con i suoi ultimi cinque ordini (id, totale, stato). Con REST le risorse sono due, /users/123
e /users/123/orders, e ognuna risponde con tutti i suoi campi, che servano o no. Con GraphQL c'è una sola
richiesta in cui il client elenca i campi che gli servono.
Il listato qui sotto non apre porte: i gestori REST sono funzioni (Request) => Response chiamate direttamente
con oggetti Request (disponibili in Node 24), e GraphQL gira in processo con Apollo Server. Il dato è lo stesso: un utente
con otto campi e venti ordini con sette campi ciascuno. Le righe che contano sono la rotta /users/123/orders, che accetta
limit ma restituisce comunque tutti i campi degli ordini, e la query GraphQL, in cui orders(last: 5)
limita le righe e { id total status } limita le colonne.
// rest-vs-graphql.ts - stesso dato, due modi di chiederlo. Nessun server in ascolto:
// i gestori REST sono funzioni (Request) => Response, GraphQL gira in processo.
import { ApolloServer } from "@apollo/server";
const user = {
id: 123, name: "Federico", email: "federico@example.com", phone: "+39 333 0000000",
city: "Lecce", bio: "Sviluppatore software", 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: due risorse, due richieste, ogni risposta porta TUTTI i campi
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} byte`);
}
// --- GraphQL: una richiesta, il client elenca i campi che gli servono
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("risposta inattesa");
const gql = Buffer.from(JSON.stringify(res.body.singleResult));
console.log(`GraphQL POST /graphql (1 query) -> 200, ${gql.length} byte`);
console.log(`\nREST: ${restCalls} richieste, ${restBytes} byte | GraphQL: 1 richiesta, ${gql.length} byte`);
await server.stop();
// Output (node rest-vs-graphql.ts):
// REST GET /users/123 -> 200, 178 byte
// REST GET /users/123/orders?limit=5 -> 200, 901 byte
// GraphQL POST /graphql (1 query) -> 200, 299 byte
//
// REST: 2 richieste, 1079 byte | GraphQL: 1 richiesta, 299 byte
Sui dati di prova la strada REST costa due richieste e 1079 byte, quella GraphQL una richiesta e 299 byte. È questo ciò che si
intende per over-fetching (arrivano campi che non servi: telefono, città, indirizzo di spedizione) e under-fetching
(una risorsa sola non basta e servono più viaggi). Il rapporto tra 1079 e 299 non è una legge: dipende dalla larghezza degli oggetti,
e REST può ridurre la distanza con parametri come ?limit= (nel listato) o con endpoint pensati per la schermata. Quello che
GraphQL aggiunge è che la scelta dei campi è standard e non va concordata endpoint per endpoint. In cambio il lavoro non
sparisce: il server deve risolvere ogni campo, e come si fa senza moltiplicare le query lo vedi nell'articolo sul
problema N+1.
Cache HTTP: dove REST ha un vantaggio concreto
Il vantaggio più spesso citato per REST è la cache, e va detto con precisione. Una GET è un metodo sicuro: la
RFC 9110 (sezione 9.2.1) lo definisce come una richiesta il cui effetto atteso è di sola lettura, e per questo browser, CDN e
reverse proxy possono conservare la risposta e riusarla secondo le regole della RFC 9111. Una risposta a POST, invece, è
memorizzabile solo se porta informazioni esplicite di freschezza e un Content-Location uguale all'URL della richiesta
(RFC 9110, sezione 9.3.3): nella pratica, quasi mai.
| Stile | Metodo HTTP per leggere | Cache condivisa (CDN, proxy) |
|---|---|---|
| REST | GET sull'URL della risorsa | Disponibile con le regole standard della RFC 9111 |
| GraphQL | POST con la query nel corpo; la specifica GraphQL-over-HTTP consente anche GET per le query | Solo se usi GET per le query e controlli le intestazioni di cache |
| gRPC | Sempre POST (la specifica del protocollo fissa :method POST) | Non usabile come cache HTTP condivisa |
La tabella corregge un'idea diffusa, cioè che con GraphQL la cache HTTP sia impossibile: la bozza della specifica GraphQL-over-HTTP
ammette le richieste GET per le query (e vieta di usarle per le mutation, con risposta 4xx e 405 consigliato). Il
costo è che l'URL contiene l'intera query, e che ogni client deve scrivere la stessa query negli stessi termini perché la cache
funzioni. Con gRPC il discorso è diverso: nessuna cache HTTP condivisa, perché ogni chiamata è un POST, e la cache va costruita dentro
l'applicazione. Se la tua API serve contenuti pubblici letti molte volte, questa riga della tabella da sola può decidere.
gRPC: un contratto binario per i servizi interni
Con gRPC il contratto è un file .proto da cui si genera il codice di client e server in più linguaggi. Il servizio
qui sotto dichiara due metodi: GetUser, una chiamata semplice, e ListUsers, che restituisce
stream User, cioè una sequenza di messaggi inviati uno alla volta. Il numero accanto a ogni campo (id = 1,
name = 2) non è un ordine di visualizzazione: è l'identificativo del campo sul filo, e cambiarlo rompe la compatibilità
con i messaggi già scritti.
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; }
Per misurare quanto pesa Protobuf rispetto al JSON uso la libreria protobufjs, che carica lo stesso file
.proto (se la sintassi fosse sbagliata, protobuf.load fallirebbe subito) e codifica mille utenti sintetici. Il
listato confronta tre cose: la dimensione grezza, la dimensione dopo gzip (la compressione che HTTP usa di serie) e il
round trip, cioè che dal decoder escano tutti i mille utenti.
// proto-size.ts - quanto pesa la STESSA lista di 1000 utenti in JSON e in Protobuf?
import protobuf from "protobufjs";
import { gzipSync } from "node:zlib";
const root = await protobuf.load("users.proto"); // se il .proto è sbagliato, fallisce qui
const UserList = root.lookupType("UserList");
const service = root.lookupService("UserService");
console.log("metodi di UserService:", service.methodsArray.map((m) => `${m.name}${m.responseStream ? " (stream)" : ""}`).join(", "));
const users = Array.from({ length: 1000 }, (_, i) => ({
id: i + 1, name: `Utente ${i + 1}`, email: `utente${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} byte`);
console.log(`Protobuf : ${proto} byte`);
console.log(`rapporto : ${(proto / json * 100).toFixed(1)}% del JSON`);
// Con la compressione gzip (che HTTP usa di serie) la distanza si accorcia
const jsonGz = gzipSync(JSON.stringify(users)).length;
const protoGz = gzipSync(UserList.encode(message).finish()).length;
console.log(`gzip : JSON ${jsonGz} byte, Protobuf ${protoGz} byte`);
// round trip: ciò che esce dal decoder è ciò che è entrato
const back = UserList.toObject(UserList.decode(UserList.encode(message).finish())) as { users: unknown[] };
console.log("utenti dopo decode:", back.users.length);
// Output (node proto-size.ts):
// metodi di UserService: GetUser, ListUsers (stream)
// JSON : 77014 byte
// Protobuf : 40991 byte
// rapporto : 53.2% del JSON
// gzip : JSON 8469 byte, Protobuf 7178 byte
// utenti dopo decode: 1000
Sui dati di prova Protobuf occupa il 53,2% del JSON (40991 byte contro 77014), perché non ripete i nomi dei campi (li sostituisce con il loro numero) e codifica gli interi con i varint, che usano da uno a dieci byte e meno spazio per i valori piccoli, come spiega la documentazione sulla codifica. Dopo gzip la distanza si accorcia molto: 7178 byte contro 8469, circa il 15% in meno. Il motivo è che i dati sintetici sono ripetitivi e la compressione li comprime benissimo, quindi il numero va letto come ordine di grandezza di un caso costruito, non come previsione per i tuoi payload. Non ho misurato la latenza, perché dipende da rete, linguaggi e hardware: se il tuo collo di bottiglia è la serializzazione, misura sul tuo carico prima di scegliere.
Le ragioni per cui gRPC vince nei servizi interni sono altre, e più strutturali: il contratto è obbligatorio e tipizzato, il codice si genera, e lo streaming (da server, da client o bidirezionale) fa parte del modello di chiamata, come descrive la pagina sui concetti fondamentali di gRPC. Il prezzo è un passaggio di generazione del codice nella build e un debug meno immediato, perché i messaggi non si leggono a occhio.
tRPC: i tipi del server dentro il client
tRPC nasce per un caso preciso: frontend e backend in TypeScript nello stesso repository. Secondo la sua documentazione permette di
costruire API completamente tipizzate «senza schemi né generazione di codice», perché usa direttamente l'inferenza di tipo di TypeScript
e segnala i problemi in compilazione. Nel router qui sotto la procedura getUser dichiara l'input con Zod
(z.object({ id: z.number() })) e il suo valore di ritorno viene inferito dal corpo della funzione.
L'ultima riga esporta il tipo del router, AppRouter: è questo, e non un file di schema, il contratto
che un client importa.
// trpc-server.ts - il router tRPC; il TIPO di appRouter è ciò che il client importa
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const utenti = [
{ 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 }) => utenti.find((u) => u.id === input.id) ?? null),
});
export type AppRouter = typeof appRouter;
export const createCaller = t.createCallerFactory(appRouter);
Il client del listato successivo chiama la procedura in processo con createCaller, quindi senza rete e senza porte.
In produzione il router si espone con un adattatore HTTP (non l'ho eseguito qui, perché richiederebbe un server in ascolto). La riga
con @ts-expect-error passa una stringa dove serve un numero: tsc la accetta come errore atteso, e a runtime è Zod a
rifiutare l'input con un TRPCError. Le due difese sono diverse e non si sostituiscono: i tipi proteggono te mentre
scrivi il codice, la validazione protegge il server da chi non usa i tuoi tipi.
// trpc-client.ts - chiamata in processo (nessuna rete) e inferenza del tipo
import { createCaller } from "./trpc-server.ts";
const caller = createCaller({});
const user = await caller.getUser({ id: 1 });
console.log("risultato:", JSON.stringify(user));
try {
// @ts-expect-error: l'input deve essere un numero, e tsc lo sa senza generare codice
await caller.getUser({ id: "1" });
} catch (e) {
console.log("a runtime, input errato ->", (e as Error).constructor.name);
}
// Il tipo di `user` è { id: number; name: string; email: string } | null
const mail: string = user?.email ?? "";
console.log("email:", mail);
// Togliendo il commento qui sotto, tsc segnala l'errore PRIMA di eseguire:
// const n: number = user?.name;
// Output (node trpc-client.ts):
// risultato: {"id":1,"name":"Anna","email":"anna@example.com"}
// a runtime, input errato -> TRPCError
// email: anna@example.com
Se togli il commento dalla riga const n: number = user?.name; in fondo al file, tsc 7.0.2 segnala il problema senza eseguire nulla:
error TS2322: Type 'string | undefined' is not assignable to type 'number'. Il tipo di user è stato dedotto dal codice del server,
e se cambi il server, i client si accorgono del cambiamento al prossimo controllo di tipo. Il limite sta nella stessa
frase: il contratto è un tipo TypeScript, quindi un client in Java o in Swift non lo vede. Per un'API pubblica questo
basta a escludere tRPC; per il dashboard interno di una stessa squadra è esattamente il vantaggio.
La matrice decisionale
Rimettendo insieme quello che hai misurato e letto, la scelta si riduce a poche domande sulla situazione. La tabella le traduce in una raccomandazione di partenza con il rischio che porta con sé: non è una regola, è il punto da cui iniziare a discutere.
| Situazione | Punto di partenza | Perché | Rischio da tenere d'occhio |
|---|---|---|---|
| API pubblica o per partner esterni | REST con documento OpenAPI | Qualsiasi client HTTP la usa; cache e codici di stato standard | Over-fetching e proliferazione delle versioni |
| Più client (web, mobile, dashboard) con bisogni diversi sugli stessi dati | GraphQL | Il client sceglie i campi: 299 byte contro 1079 nell'esempio | Query N+1, costo delle query da limitare, cache da progettare |
| Servizi interni che si chiamano spesso | gRPC | Contratto .proto, codice generato, streaming, messaggi più piccoli | Niente cache HTTP condivisa, debug meno immediato |
| Frontend e backend TypeScript nello stesso repository | tRPC | Tipi inferiti senza schemi né codegen | Client e server accoppiati; solo client TypeScript |
| Flussi continui di dati | gRPC con streaming | Lo streaming fa parte del modello di chiamata | Serve un client gRPC su ogni lato |
Leggi la quarta colonna prima della terza: è lì che le scelte sbagliate si pagano. Chi sceglie GraphQL per «essere moderno» e non ha client diversi
si ritrova con il costo del N+1 senza averne il vantaggio. Chi sceglie gRPC per un'API pubblica scopre che i partner non hanno il tuo
.proto. La pagina che confronta le opzioni in modo più esteso è REST vs GraphQL vs tRPC,
e il compromesso tra le prime due è ripreso in GraphQL vs REST: trade-offs reali.
Combinare più protocolli senza pentirsene
In un sistema di dimensioni medie non devi sceglierne uno solo. La combinazione più comune è REST verso l'esterno, per i client che non controlli, gRPC tra i servizi interni, e tRPC (o GraphQL) per il frontend che possiedi. Funziona se rispetti due condizioni: il confine tra i protocolli è un servizio solo, che traduce (un gateway o un BFF), e ogni protocollo in più ha una ragione che sai scrivere in una riga. Ogni aggiunta moltiplica strumenti, logging, autenticazione e test: tre protocolli in una squadra di tre persone sono un costo che di solito supera il guadagno.
Cinque domande prima di scegliere
Se vuoi una procedura invece di una tabella, rispondi in quest'ordine: la prima domanda taglia più opzioni delle altre.
- Chi chiama l'API? Sviluppatori esterni: REST. Solo il tuo frontend TypeScript: tRPC. Altri servizi tuoi: gRPC.
- I client hanno bisogni di dati diversi? Se sì e sono più di uno, GraphQL entra in gioco.
- Le risposte si possono mettere in cache pubblica? Se sì, tieni letture su
GETcon URL stabili. - Hai flussi continui? Con gRPC lo streaming è nativo.
- Che cosa sa fare la squadra oggi? Un protocollo nuovo costa settimane di apprendimento e strumenti.
L'ordine qui conta davvero, ed è il motivo per cui questa è una lista numerata: se inverti la prima e l'ultima domanda finisci per scegliere ciò che la squadra conosce anche quando i client sono esterni. Le prime tre domande riguardano l'API e i suoi consumatori, le ultime due la tua organizzazione: se non tornano insieme, vale la scelta più semplice da gestire, perché la complessità operativa è il costo che si scopre dopo.
Che cosa sai fare adesso
Sai distinguere uno stile architetturale (REST) da un linguaggio di query (GraphQL), da un sistema di RPC (gRPC) e da una libreria di tipi (tRPC), e sai perché la cache HTTP dipende dal metodo usato e non dal nome del protocollo. Hai visto con misure riproducibili quanto cambia il traffico tra REST e GraphQL su un caso concreto, e quanto vale (e quanto poco, dopo gzip) la compattezza di Protobuf. Il passo successivo è approfondire il protocollo che hai scelto: se è REST, continua con le best practice e il versionamento; se è GraphQL, con i resolver e il problema N+1.
Domande frequenti
Quando conviene GraphQL invece di REST?
Quando più client (per esempio web, mobile e dashboard) leggono gli stessi dati in forme molto diverse e vuoi che sia il client a scegliere i campi. Se hai un solo client o un'API pubblica da mettere in cache, REST costa meno da gestire.
gRPC è sempre più veloce di REST?
Non c'è una risposta valida ovunque. Nell'esempio di questa pagina Protobuf occupa il 53,2% del JSON e dopo gzip il vantaggio scende a circa il 15%, ma la latenza non è stata misurata e dipende da rete, linguaggi e hardware. Misura sul tuo carico prima di decidere.
tRPC si può usare per un'API pubblica?
Di norma no: il contratto di tRPC è un tipo TypeScript, quindi serve a client TypeScript che condividono il codice con il server. Per sviluppatori esterni, con linguaggi diversi, REST con OpenAPI è la scelta più portabile.
Fonti
- RFC 9110, HTTP Semantics - metodi sicuri (sezione 9.2.1) e condizioni per memorizzare le risposte a POST (sezione 9.3.3), consultata il 2026-11-12
- RFC 9111, HTTP Caching - regole delle cache condivise per le risposte a GET, consultata il 2026-11-12
- Roy Fielding, Architectural Styles and the Design of Network-based Software Architectures, capitolo 5 - REST come stile architetturale, consultata il 2026-11-12
- GraphQL Specification, October 2021 - il client descrive la forma della risposta, il server risolve i campi, consultata il 2026-11-12
- GraphQL over HTTP (bozza) - richieste GET ammesse per le query e vietate per le mutation, consultata il 2026-11-12
- gRPC over HTTP2 - metodo POST fisso e tipo di contenuto
application/grpccon suffisso+protoo+json, consultata il 2026-11-12 - gRPC: core concepts - tipi di chiamata, incluso lo streaming bidirezionale, consultata il 2026-11-12
- Protocol Buffers: Encoding - varint e numeri di campo nel formato sul filo, consultata il 2026-11-12
- tRPC: Introduction - API tipizzate senza schemi né generazione di codice, consultata il 2026-11-12
I cinque listati sono stati eseguiti con 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) e l'uscita riportata è quella reale, identica in due esecuzioni consecutive. I byte del primo e del terzo listato dipendono dai dati di prova scritti nel listato.

Commenti
Caricamento commenti...
Accedi per commentare