Salta al contenuto principale

04 - GraphQL vs REST: trade-offs reali

REST e GraphQL misurati sulla stessa pagina: richieste, istruzioni SQL e byte, con DataLoader, cache HTTP e un limite di profondità che da solo non basta.

Cosa ne pensi di questo articolo?

04 - GraphQL vs REST: trade-offs reali
Sviluppo Web Fullstackfedericocalo.dev

In Breve

REST e GraphQL misurati sulla stessa pagina: richieste, istruzioni SQL e byte, con DataLoader, cache HTTP e un limite di profondità che da solo non basta.

«GraphQL è meglio di REST» e «REST è più semplice» sono due frasi che si usano per non guardare i numeri. La domanda utile è un'altra: che cosa chiede il tuo client, e dove vuoi pagare il costo? Un client che deve mostrare titolo e autore di venti articoli può farlo in sei richieste, in una, o in una con una sola query sul database, a seconda di come hai disegnato l'API, sia che sia REST sia che sia GraphQL. In questo capitolo costruisco la stessa pagina con entrambi gli stili, su dati miei, e misuro richieste, istruzioni SQL e byte. Segue il capitolo sul backend Spring Boot: lì costruisci l'API, qui scegli come esporla.

Il banco di prova è un progetto mio in Node.js 24.11, con Fastify 5.12.8, graphql 17.1.0 (la libreria di riferimento di GraphQL per JavaScript), dataloader 2.2.3 e il modulo node:sqlite con un database in memoria (versioni lette sul registro npm il 13 novembre 2026). Nessuna porta è aperta: le richieste passano da inject() di Fastify, che simula una richiesta HTTP dentro il processo. Il modulo SQLite è ancora sperimentale in questa versione di Node, quindi lancio i file con node --disable-warning=ExperimentalWarning. Non c'è nessun browser e nessuna rete.

I dati e lo scenario

Il banco di prova contiene cinque autori e venti articoli, con il corpo lungo circa 900-960 caratteri. La pagina che vuoi costruire mostra, per ogni articolo, il titolo e il nome dell'autore, e nient'altro. Il modulo data.ts crea le tabelle, inserisce i dati con una formula (così sono uguali a ogni esecuzione) e conta le istruzioni SELECT eseguite: è quel contatore a dire che cosa succede sul database.

import { DatabaseSync, type SQLInputValue } from 'node:sqlite';

const db = new DatabaseSync(':memory:');
db.exec(`
  CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
  CREATE TABLE posts (
    id INTEGER PRIMARY KEY,
    author_id INTEGER NOT NULL REFERENCES authors(id),
    title TEXT NOT NULL,
    body TEXT NOT NULL
  );
`);
const insertAuthor = db.prepare('INSERT INTO authors (id, name) VALUES (?, ?)');
const insertPost = db.prepare('INSERT INTO posts (id, author_id, title, body) VALUES (?, ?, ?, ?)');
for (let a = 1; a <= 5; a++) insertAuthor.run(a, `Autore ${a}`);
for (let p = 1; p <= 20; p++) {
  insertPost.run(p, ((p - 1) % 5) + 1, `Articolo ${p}`, `Testo dell'articolo ${p}. `.repeat(40));
}

let count = 0;
export const sql = {
  /** Quante istruzioni SELECT sono state eseguite dall'ultimo azzeramento */
  get count() {
    return count;
  },
  reset() {
    count = 0;
  },
};

export function all<T>(query: string, ...params: SQLInputValue[]): T[] {
  count++;
  return db.prepare(query).all(...params) as T[];
}

export function get<T>(query: string, ...params: SQLInputValue[]): T | undefined {
  count++;
  return db.prepare(query).get(...params) as T | undefined;
}

export interface Author { id: number; name: string }
export interface Post { id: number; author_id: number; title: string; body: string }

Le funzioni all e get incrementano il contatore a ogni chiamata, e sql.reset() lo azzera tra un esperimento e l'altro. Questo è il metodo di misura per tutto il capitolo: non il tempo, che dipende dalla macchina e da una base dati che sta in memoria, ma il numero di istruzioni, che non cambia da una macchina all'altra. Il numero di richieste HTTP e di byte si misura con inject(), e i byte sono la lunghezza del corpo della risposta.

REST: sotto-prelievo, sovra-prelievo e come lo si cura

Un client che conosce solo le risorse (/posts e /authors/:id) scarica la lista, trova gli identificativi degli autori e li chiede uno alla volta: è il sotto-prelievo (under-fetching), servono più richieste per una sola schermata. Ma la lista porta anche il corpo di ogni articolo, che la pagina non usa: è il sovra-prelievo (over-fetching). Il listato misura tre modi di costruire la stessa pagina e, in coda, la cache HTTP e un errore 404.

import Fastify from 'fastify';
import { createHash } from 'node:crypto';
import { all, get, sql, type Author, type Post } from './data.ts';

const app = Fastify();

app.get('/posts', async (req) => {
  const { fields, include } = req.query as { fields?: string; include?: string };
  const wanted = fields?.split(',');
  return all<Post>('SELECT * FROM posts ORDER BY id').map((p) => {
    const row: Record<string, unknown> = { ...p };
    if (include === 'author') row.author = get<Author>('SELECT * FROM authors WHERE id = ?', p.author_id);
    if (!wanted) return row;
    return Object.fromEntries(Object.entries(row).filter(([k]) => wanted.includes(k) || k === 'author'));
  });
});

app.get('/authors/:id', async (req, reply) => {
  const author = get<Author>('SELECT * FROM authors WHERE id = ?', Number((req.params as { id: string }).id));
  return author ?? reply.code(404).send({ title: 'Autore non trovato' });
});

app.get('/posts/:id', async (req, reply) => {
  const post = get<Post>('SELECT * FROM posts WHERE id = ?', Number((req.params as { id: string }).id));
  if (!post) return reply.code(404).send({ title: 'Articolo non trovato' });
  const body = JSON.stringify(post);
  const etag = `"${createHash('sha1').update(body).digest('hex').slice(0, 16)}"`;
  reply.header('ETag', etag).header('Cache-Control', 'public, max-age=60');
  if (req.headers['if-none-match'] === etag) return reply.code(304).send();
  return reply.type('application/json').send(body);
});

// Endpoint pensato per la pagina: una JOIN e solo i campi che servono
app.get('/post-cards', async () =>
  all('SELECT p.title, a.name AS author FROM posts p JOIN authors a ON a.id = p.author_id ORDER BY p.id'),
);

let requests = 0;
let bytes = 0;
async function call(url: string, headers: Record<string, string> = {}) {
  const res = await app.inject({ method: 'GET', url, headers });
  requests++;
  bytes += Buffer.byteLength(res.payload);
  return res;
}
function report(label: string, needed: number) {
  console.log(`${label.padEnd(34)} richieste: ${requests}, SELECT: ${sql.count}, byte ricevuti: ${bytes} (utili: ${needed})`);
  requests = 0;
  bytes = 0;
  sql.reset();
}

// Quello che serve alla pagina: titolo di ogni articolo e nome del suo autore
const needed = Buffer.byteLength(
  JSON.stringify(
    all<{ title: string; author: string }>('SELECT p.title, a.name AS author FROM posts p JOIN authors a ON a.id = p.author_id ORDER BY p.id'),
  ),
);
sql.reset();

// Strategia 1: due passaggi, come farebbe un client che conosce solo le risorse
const posts = (await call('/posts')).json() as Post[];
for (const id of new Set(posts.map((p) => p.author_id))) await call(`/authors/${id}`);
report('REST, due passaggi', needed);

// Strategia 2: lo stesso REST, ma con parametri che scelgono campi e relazioni
await call('/posts?fields=id,title&include=author');
report('REST con fields e include', needed);

// Strategia 3: un endpoint dedicato alla pagina
await call('/post-cards');
report('REST, endpoint dedicato (JOIN)', needed);

// Cache HTTP su una risorsa
const first = await call('/posts/1');
const etag = String(first.headers.etag);
const second = await call('/posts/1', { 'if-none-match': etag });
console.log('prima richiesta  :', first.statusCode, first.headers['cache-control'], 'corpo', Buffer.byteLength(first.payload), 'byte');
console.log('seconda richiesta:', second.statusCode, 'corpo', Buffer.byteLength(second.payload), 'byte');
const missing = await call('/posts/999');
console.log('articolo assente :', missing.statusCode, missing.payload);
await app.close();

// Output:
// REST, due passaggi                 richieste: 6, SELECT: 6, byte ricevuti: 20073 (utili: 872)
// REST con fields e include          richieste: 1, SELECT: 21, byte ricevuti: 1343 (utili: 872)
// REST, endpoint dedicato (JOIN)     richieste: 1, SELECT: 1, byte ricevuti: 872 (utili: 872)
// prima richiesta  : 200 public, max-age=60 corpo 973 byte
// seconda richiesta: 304 corpo 0 byte
// articolo assente : 404 {"title":"Articolo non trovato"}

I numeri raccontano tre storie diverse. Con due passaggi servono 6 richieste (una lista e cinque autori distinti) e arrivano 20.073 byte per 872 utili: il corpo degli articoli pesa quasi tutto. Con i parametri fields e include la richiesta è una, i byte scendono a 1.343, ma le SELECT sono 21: nel mio gestore ho caricato l'autore dentro un ciclo, e il problema dell'N+1 si è spostato dal client al server. Con un endpoint dedicato alla pagina (/post-cards, una JOIN e due campi) richieste e SELECT sono una e i byte coincidono con quelli utili. Il costo: un endpoint per ogni schermata, che il team del backend deve scrivere e mantenere.

La seconda parte dell'uscita riguarda la cache. La prima richiesta a /posts/1 restituisce 200 con Cache-Control: public, max-age=60 e un ETag; la seconda, con l'intestazione If-None-Match, ottiene 304 e un corpo di zero byte. Il 304 non lo calcola il framework: l'ho scritto io nel gestore, confrontando l'ETag. L'importante è che tutta la catena HTTP (browser, proxy, CDN) capisce quel dialogo senza saperne niente della tua applicazione. L'ultima riga mostra l'errore di una risorsa assente: lo stato 404 e un corpo con un titolo.

GraphQL: una richiesta, ma l'N+1 si sposta nei resolver

In GraphQL il client scrive la forma della risposta: chiede posts { title author { name } } e riceve esattamente quei campi, in una sola richiesta. Il server definisce uno schema (i tipi e i campi) e per ogni campo una funzione, il resolver, che sa dove andare a prendere il valore. Il listato seguente costruisce lo schema con gli oggetti della libreria e prepara due «contesti»: uno ingenuo, in cui ogni resolver fa la sua SELECT, e uno con DataLoader.

import Fastify from 'fastify';
import DataLoader from 'dataloader';
import {
  GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString, graphql,
} from 'graphql';
import { all, get, sql, type Author, type Post } from './data.ts';

interface Ctx {
  author: (id: number) => Promise<Author | undefined>;
  postsOf: (authorId: number) => Promise<Post[]>;
}

const AuthorType: GraphQLObjectType = new GraphQLObjectType({
  name: 'Author',
  fields: () => ({
    id: { type: new GraphQLNonNull(GraphQLInt) },
    name: { type: new GraphQLNonNull(GraphQLString) },
    posts: {
      type: new GraphQLList(PostType),
      resolve: (a: Author, _args, ctx: Ctx) => ctx.postsOf(a.id),
    },
  }),
});

const PostType: GraphQLObjectType = new GraphQLObjectType({
  name: 'Post',
  fields: () => ({
    id: { type: new GraphQLNonNull(GraphQLInt) },
    title: { type: new GraphQLNonNull(GraphQLString) },
    body: { type: new GraphQLNonNull(GraphQLString) },
    author: {
      type: AuthorType,
      resolve: (p: Post, _args, ctx: Ctx) => ctx.author(p.author_id),
    },
  }),
});

export const schema = new GraphQLSchema({
  query: new GraphQLObjectType({
    name: 'Query',
    fields: {
      posts: { type: new GraphQLList(PostType), resolve: () => all<Post>('SELECT * FROM posts ORDER BY id') },
    },
  }),
});

/** Contesto ingenuo: una SELECT per ogni campo che la chiede */
export const naiveContext = (): Ctx => ({
  author: async (id) => get<Author>('SELECT * FROM authors WHERE id = ?', id),
  postsOf: async (authorId) => all<Post>('SELECT * FROM posts WHERE author_id = ? ORDER BY id', authorId),
});

/** Contesto con DataLoader: creato PER OGNI richiesta, mai condiviso fra utenti */
export const batchedContext = (): Ctx => {
  const authors = new DataLoader<number, Author>(async (ids) => {
    const rows = all<Author>(`SELECT * FROM authors WHERE id IN (${ids.map(() => '?').join(',')})`, ...ids);
    const byId = new Map(rows.map((r) => [r.id, r]));
    return ids.map((id) => byId.get(id) ?? new Error(`autore ${id} assente`));
  });
  const posts = new DataLoader<number, Post[]>(async (ids) => {
    const rows = all<Post>(`SELECT * FROM posts WHERE author_id IN (${ids.map(() => '?').join(',')}) ORDER BY id`, ...ids);
    return ids.map((id) => rows.filter((r) => r.author_id === id));
  });
  return { author: (id) => authors.load(id), postsOf: (id) => posts.load(id) };
};

export async function run(source: string, contextValue: Ctx) {
  return graphql({ schema, source, contextValue });
}

Nel contesto ingenuo il resolver di Post.author esegue una SELECT per ogni articolo: con venti articoli sono venti SELECT, più quella della lista. Nel contesto con DataLoader le chiamate load(id) fatte nello stesso ciclo di eventi vengono raccolte e la funzione di batch riceve tutte le chiavi insieme: una sola SELECT con IN (...). Il listato crea i DataLoader dentro la funzione batchedContext, cioè per ogni richiesta: la documentazione di DataLoader spiega che ogni istanza è una cache e che di norma va creata per richiesta se utenti diversi possono vedere cose diverse.

Il DataLoader impone due vincoli alla funzione di batch, ed è lì che si sbaglia di solito:

  • l'array dei valori deve avere la stessa lunghezza dell'array delle chiavi;
  • ogni posizione dei valori deve corrispondere alla stessa posizione delle chiavi, anche se la base dati restituisce le righe in un altro ordine.

Nel codice il vincolo si vede nella riga ids.map((id) => byId.get(id) ?? new Error(...)): costruisco una mappa per id e riordino. Se tornassi le righe così come le restituisce il database, un autore finirebbe attaccato all'articolo sbagliato senza nessun errore: per questo la documentazione ufficiale mostra lo stesso passaggio di riordino e di segnaposto per le chiavi mancanti. Ora il listato di esecuzione, che lancia la stessa query nei due contesti e poi dentro una richiesta HTTP.

// ---- esecuzione --------------------------------------------------------------
if (process.argv[1]?.endsWith('graphql.ts')) {
  const query = '{ posts { title author { name } } }';

  for (const [label, ctx] of [['senza DataLoader', naiveContext()], ['con DataLoader', batchedContext()]] as const) {
    sql.reset();
    const result = await run(query, ctx);
    const rows = (result.data as { posts: unknown[] }).posts.length;
    console.log(`${label.padEnd(17)} SELECT: ${String(sql.count).padStart(2)}  articoli: ${rows}  byte: ${Buffer.byteLength(JSON.stringify(result))}`);
  }

  // Lo stesso, dentro una richiesta HTTP vera e propria (senza porta aperta)
  const app = Fastify();
  app.post('/graphql', async (req) => run((req.body as { query: string }).query, batchedContext()));
  const res = await app.inject({ method: 'POST', url: '/graphql', payload: { query } });
  console.log('POST /graphql    :', res.statusCode, res.headers['content-type'], 'cache-control =', res.headers['cache-control']);

  const bad = await app.inject({ method: 'POST', url: '/graphql', payload: { query: '{ posts { titel } }' } });
  console.log('campo inesistente:', bad.statusCode, JSON.stringify(JSON.parse(bad.payload).errors[0].message));
  await app.close();
}

// Output:
// senza DataLoader  SELECT: 21  articoli: 20  byte: 1071
// con DataLoader    SELECT:  2  articoli: 20  byte: 1071
// POST /graphql    : 200 application/json; charset=utf-8 cache-control = undefined
// campo inesistente: 200 "Cannot query field \"titel\" on type \"Post\". Did you mean \"title\"?"

L'uscita mostra le 21 SELECT contro le 2 con il DataLoader, con la stessa risposta (20 articoli, 1.071 byte). Poi mostra tre fatti che contano per chi gestisce l'API. Primo: POST /graphql risponde 200 senza alcuna intestazione Cache-Control, quindi nessuna cache HTTP lo riuserà. Secondo: la RFC 9110 dice che le risposte a una POST sono cacheabili solo con informazioni esplicite di freschezza e un Content-Location uguale all'URL della richiesta: non è il caso delle query normali. Terzo: un campo inesistente (titel) non produce un 404 ma un 200 con un array errors e un messaggio che suggerisce il nome giusto («Did you mean "title"?»).

Quest'ultimo punto dipende dalle mie scelte: il mio gestore risponde sempre 200 con application/json. La bozza della specifica GraphQL over HTTP definisce un tipo di contenuto più recente, application/graphql-response+json, che i server conformi devono supportare, e chiama «legacy» i server che non lo fanno: il mio gestore è di quel tipo. La stessa bozza prevede che il server possa rispondere anche a richieste GET, ma dice solo che può farlo: se vuoi la cache HTTP su GraphQL devi costruirla tu, per esempio con richieste GET e con documenti noti in anticipo.

Quello che GraphQL ti chiede di difendere

In REST la forma di ogni risposta è decisa dal server: un endpoint costa quanto lo hai scritto. In GraphQL la forma la sceglie il client, e se lo schema ha un ciclo (un articolo ha un autore, un autore ha articoli, un articolo ha un autore...) una query può annidare quanto vuole. Il listato misura quanto cresce la risposta con i livelli e prova una regola di validazione per limitare la profondità. La pagina sulla sicurezza di graphql.org elenca fra le difese proprio il limite di profondità, i limiti di ampiezza, l'analisi della complessità e i documenti fidati.

import { GraphQLError, parse, validate, specifiedRules, type ValidationRule } from 'graphql';
import { batchedContext, run, schema } from './graphql.ts';
import { sql } from './data.ts';

/** Regola ingenua: conta quanti campi sono annidati dentro un'operazione */
function depthLimit(max: number): ValidationRule {
  return (context) => {
    let depth = 0;
    return {
      Field: {
        enter() {
          depth++;
          if (depth > max) context.reportError(new GraphQLError(`profondità ${depth} oltre il limite ${max}`));
        },
        leave() {
          depth--;
        },
      },
    };
  };
}

// posts -> author -> posts -> author -> ...: ogni due livelli il risultato si moltiplica per 4
const nested = (levels: number): string => {
  let inner = 'name';
  for (let i = 0; i < levels; i++) inner = i % 2 === 0 ? `author { ${inner} }` : `posts { ${inner} }`;
  return `{ posts { ${inner} } }`;
};

console.log('livelli  byte della risposta  SELECT');
for (const levels of [1, 3, 5, 7]) {
  sql.reset();
  const result = await run(nested(levels), batchedContext());
  console.log(String(levels).padStart(7), String(Buffer.byteLength(JSON.stringify(result))).padStart(20), String(sql.count).padStart(7));
}

// Con il limite a 4 la richiesta profonda viene respinta PRIMA di eseguire una sola SELECT
sql.reset();
const deep = parse(nested(7));
const errors = validate(schema, deep, [...specifiedRules, depthLimit(4)]);
console.log('con limite 4:', errors.length, 'errori, primo:', JSON.stringify(errors[0]?.message), 'SELECT:', sql.count);

// ... ma i frammenti la aggirano: ogni definizione, da sola, è poco profonda
const viaFragments = `
  { posts { ...A } }
  fragment A on Post { author { posts { ...B } } }
  fragment B on Post { author { posts { ...C } } }
  fragment C on Post { author { name } }
`;
const bypass = validate(schema, parse(viaFragments), [...specifiedRules, depthLimit(4)]);
sql.reset();
const executed = await run(viaFragments, batchedContext());
console.log('con frammenti:', bypass.length, 'errori; eseguita comunque, SELECT:', sql.count, 'byte:', Buffer.byteLength(JSON.stringify(executed)));

// Output:
// livelli  byte della risposta  SELECT
//       1                  640       2
//       3                 2960       3
//       5                12240       3
//       7                49360       3
// con limite 4: 5 errori, primo: "profondità 5 oltre il limite 4" SELECT: 0
// con frammenti: 0 errori; eseguita comunque, SELECT: 3 byte: 12240

La tabella in uscita è la lezione più importante del capitolo. Con il DataLoader le SELECT restano due o tre a qualsiasi profondità, quindi il problema non è più il numero di query. È la dimensione della risposta, che passa da 640 a 49.360 byte quando i livelli salgono da 1 a 7: ogni due livelli, per via del ciclo autore-articoli, il risultato si moltiplica per circa quattro. La regola depthLimit(4) respinge la query a sette livelli prima di eseguirla (zero SELECT).

Ma la stessa riga finale dell'uscita mostra il limite della mia regola: con i frammenti (fragment A, fragment B...) la query passa con zero errori ed esegue per intero, con 12.240 byte. Il motivo è che il mio visitatore conta la profondità dentro ogni definizione e non segue le ...A da un frammento all'altro. È una regola da usare come esempio, non in produzione: una regola adatta alla produzione deve seguire anche i frammenti, e una difesa vera combina profondità, complessità e limite di frequenza.

Come scegliere, con i numeri in mano

I numeri di questo capitolo appartengono a un database da venti righe: non dimostrano prestazioni, mostrano meccanismi. Servono a formulare quattro domande da farti prima di scegliere:

  • Quanti client diversi consumano l'API, e quanto sono diverse le forme di cui hanno bisogno? Con un solo client e poche schermate, gli endpoint dedicati costano poco.
  • Quanto conta la cache HTTP condivisa (CDN, proxy)? Con REST sui GET ce l'hai, con GraphQL devi progettarla.
  • Chi controlla i client? Se sono pubblici e non fidati, ti servono limiti di profondità e di complessità; se sono interni, l'urgenza è minore.
  • Il team è pronto a gestire uno schema, i resolver e un caricamento a lotti? È lavoro che con REST non esiste, e con GraphQL non si evita.

Le quattro domande non hanno un ordine, ma una gerarchia pratica: se rispondi «molti client diversi e poca cache condivisa», GraphQL toglie dal backend la moltiplicazione degli endpoint; se rispondi «pochi client e molta cache», REST con qualche endpoint dedicato (come /post-cards) è più semplice e dà le stesse garanzie con meno parti mobili. Questa è una valutazione mia, costruita sui meccanismi misurati sopra, non un risultato empirico generale. In molti progetti la risposta onesta è «entrambi», con REST per le risorse pubbliche e cacheabili e GraphQL per l'interfaccia che compone dati.

Domande frequenti

GraphQL risolve il problema N+1?

No, lo rende più visibile. Nel test, la stessa query esegue 21 SELECT con resolver ingenui e 2 con un DataLoader; lo stesso problema esiste in REST se carichi le relazioni dentro un ciclo, come ha fatto il mio endpoint con include (21 SELECT).

Si può usare la cache HTTP con GraphQL?

Non per le POST normali: nel test la risposta non porta Cache-Control, e la RFC 9110 consente di riusare le risposte a una POST solo con informazioni di freschezza esplicite e un Content-Location coerente. Va progettata, per esempio con richieste GET e documenti noti in anticipo.

Perché il DataLoader va creato per ogni richiesta?

Perché ogni istanza contiene una cache dei valori già caricati. La documentazione di DataLoader indica di crearlo per richiesta quando utenti diversi possono vedere dati diversi, per non condividere risultati fra persone.

Che cosa sai fare adesso

Sai misurare un'API con tre numeri (richieste, istruzioni SQL, byte) invece di giudicarla a sensazione, e sai riconoscere il sotto-prelievo, il sovra-prelievo e l'N+1, in REST come in GraphQL. Sai scrivere un DataLoader che rispetta il vincolo di lunghezza e di ordine e crearlo per richiesta. Sai che cosa perdi lasciando la cache HTTP e che cosa devi difendere quando il client decide la forma della risposta, con un limite di profondità che da solo non basta. Il capitolo seguente passa al lato del browser, con Core Web Vitals; l'indice è nella pagina della serie.

I listati usano TypeScript, come quelli del primo articolo; la serie si chiude con CI/CD multi-ambiente, che ne raccoglie l'elenco degli otto articoli.

Fonti

  • RFC 9110, HTTP Semantics - sezione sul metodo POST: risposte cacheabili solo con informazioni di freschezza esplicite e Content-Location uguale all'URL, letta il 2026-11-13
  • GraphQL over HTTP (bozza) - media type application/graphql-response+json, server «legacy», POST obbligatoria e GET facoltativa, letta il 2026-11-13
  • GraphQL, Security - difese elencate: limite di profondità, ampiezza, complessità, documenti fidati; osservazione che il N+1 risolto non elimina il carico da query annidate, letta il 2026-11-13
  • DataLoader (README ufficiale) - funzione di batch con stessa lunghezza e stesso ordine delle chiavi, istanze per richiesta, cache come memoizzazione per singola richiesta, letta il 2026-11-13
  • Registro npm, pacchetto graphql - versione 17.1.0, interrogato con npm view il 2026-11-13; stesse interrogazioni per fastify (5.12.8) e dataloader (2.2.3)

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