Salta al contenuto principale

01 - Il Panorama delle API nel 2026: REST, GraphQL, gRPC, tRPC - Quando Usare Cosa

Quale protocollo scegliere per la tua API? REST, GraphQL, gRPC o tRPC: dipende da chi la chiama, con misure su traffico e dimensione dei messaggi.

Cosa ne pensi di questo articolo?

01 - Il Panorama delle API nel 2026: REST, GraphQL, gRPC, tRPC - Quando Usare Cosa
API DesignFoto:Jakub PabisPexels

In Breve

Quale protocollo scegliere per la tua API? REST, GraphQL, gRPC o tRPC: dipende da chi la chiama, con misure su traffico e dimensione dei messaggi.

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.

StileMetodo HTTP per leggereCache condivisa (CDN, proxy)
RESTGET sull'URL della risorsaDisponibile con le regole standard della RFC 9111
GraphQLPOST con la query nel corpo; la specifica GraphQL-over-HTTP consente anche GET per le querySolo se usi GET per le query e controlli le intestazioni di cache
gRPCSempre 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.

SituazionePunto di partenzaPerchéRischio da tenere d'occhio
API pubblica o per partner esterniREST con documento OpenAPIQualsiasi client HTTP la usa; cache e codici di stato standardOver-fetching e proliferazione delle versioni
Più client (web, mobile, dashboard) con bisogni diversi sugli stessi datiGraphQLIl client sceglie i campi: 299 byte contro 1079 nell'esempioQuery N+1, costo delle query da limitare, cache da progettare
Servizi interni che si chiamano spessogRPCContratto .proto, codice generato, streaming, messaggi più piccoliNiente cache HTTP condivisa, debug meno immediato
Frontend e backend TypeScript nello stesso repositorytRPCTipi inferiti senza schemi né codegenClient e server accoppiati; solo client TypeScript
Flussi continui di datigRPC con streamingLo streaming fa parte del modello di chiamataServe 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.

  1. Chi chiama l'API? Sviluppatori esterni: REST. Solo il tuo frontend TypeScript: tRPC. Altri servizi tuoi: gRPC.
  2. I client hanno bisogni di dati diversi? Se sì e sono più di uno, GraphQL entra in gioco.
  3. Le risposte si possono mettere in cache pubblica? Se sì, tieni letture su GET con URL stabili.
  4. Hai flussi continui? Con gRPC lo streaming è nativo.
  5. 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

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.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti