"GraphQL is better than REST" and "REST is simpler" are two sentences people use so as not to look at the numbers. The useful question is a different one: what does your client ask for, and where do you want to pay the cost? A client that has to show the title and author of twenty articles can do it in six requests, in one, or in one with a single query on the database, depending on how you designed the API, whether it is REST or GraphQL. In this chapter I build the same page with both styles, on data of mine, and measure requests, SQL statements and bytes. It follows the chapter on the Spring Boot backend: there you build the API, here you choose how to expose it.
The test bench is a project of mine in Node.js 24.11, with Fastify 5.12.8, graphql 17.1.0 (the reference GraphQL library for JavaScript), dataloader 2.2.3 and the node:sqlite module with an in-memory database (versions read on the npm registry on 13 November 2026). No port is open: requests go through Fastify's inject(), which simulates an HTTP request inside the process. The SQLite module is still experimental in this version of Node, so I launch the files with node --disable-warning=ExperimentalWarning. There is no browser and no network.
The data and the scenario
The test bench contains five authors and twenty articles, with a body about 900-960 characters long. The page you want to build shows, for each article, the title and the author's name, and nothing else. The data.ts module creates the tables, inserts the data with a formula (so they are the same on every run) and counts the SELECT statements executed: it is that counter that tells you what happens on the database. The sample strings are in Italian ("Autore" is author, "Articolo" is article, "Testo dell'articolo" is article text): I left them as they are because they enter the byte counts, which therefore stay identical to those of the Italian edition.
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 = {
/** How many SELECT statements have been run since the last reset */
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 }
The all and get functions increment the counter at every call, and sql.reset() zeroes it between one experiment and the next. This is the measuring method for the whole chapter: not time, which depends on the machine and on a database that lives in memory, but the number of statements, which does not change from one machine to another. The number of HTTP requests and of bytes is measured with inject(), and the bytes are the length of the response body.
REST: under-fetching, over-fetching and how to cure it
A client that knows only the resources (/posts and /authors/:id) downloads the list, finds the authors' identifiers and asks for them one at a time: this is under-fetching, several requests are needed for a single screen. But the list also carries the body of every article, which the page does not use: this is over-fetching. The listing measures three ways of building the same page and, at the end, HTTP caching and a 404 error.
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: 'Author not found' });
});
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: 'Article not found' });
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 designed for the page: one JOIN and only the fields that are needed
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)} requests: ${requests}, SELECT: ${sql.count}, bytes received: ${bytes} (useful: ${needed})`);
requests = 0;
bytes = 0;
sql.reset();
}
// What the page needs: the title of each article and the name of its author
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();
// Strategy 1: two passes, as a client that knows only the resources would do
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, two passes', needed);
// Strategy 2: the same REST, but with parameters that choose fields and relations
await call('/posts?fields=id,title&include=author');
report('REST with fields and include', needed);
// Strategy 3: an endpoint dedicated to the page
await call('/post-cards');
report('REST, dedicated endpoint (JOIN)', needed);
// HTTP cache on a resource
const first = await call('/posts/1');
const etag = String(first.headers.etag);
const second = await call('/posts/1', { 'if-none-match': etag });
console.log('first request :', first.statusCode, first.headers['cache-control'], 'body', Buffer.byteLength(first.payload), 'bytes');
console.log('second request:', second.statusCode, 'body', Buffer.byteLength(second.payload), 'bytes');
const missing = await call('/posts/999');
console.log('absent article:', missing.statusCode, missing.payload);
await app.close();
// Output:
// REST, two passes requests: 6, SELECT: 6, bytes received: 20073 (useful: 872)
// REST with fields and include requests: 1, SELECT: 21, bytes received: 1343 (useful: 872)
// REST, dedicated endpoint (JOIN) requests: 1, SELECT: 1, bytes received: 872 (useful: 872)
// first request : 200 public, max-age=60 body 973 bytes
// second request: 304 body 0 bytes
// absent article: 404 {"title":"Article not found"}
The numbers tell three different stories. With two passes you need 6 requests (one list and five distinct authors) and 20,073 bytes arrive for 872 useful ones: the articles' body weighs almost all of it. With the fields and include parameters the request is one, the bytes drop to 1,343, but the SELECTs are 21: in my handler I loaded the author inside a loop, and the N+1 problem has moved from the client to the server. With an endpoint dedicated to the page (/post-cards, one JOIN and two fields) requests and SELECTs are one and the bytes coincide with the useful ones. The cost: one endpoint for every screen, which the backend team has to write and maintain.
The second part of the output concerns the cache. The first request to /posts/1 returns 200 with Cache-Control: public, max-age=60 and an ETag; the second, with the If-None-Match header, gets 304 and a body of zero bytes. The 304 is not computed by the framework: I wrote it in the handler, comparing the ETag. What matters is that the whole HTTP chain (browser, proxy, CDN) understands that dialogue without knowing anything about your application. The last line shows the error for an absent resource: the 404 status and a body with a title.
GraphQL: one request, but the N+1 moves into the resolvers
In GraphQL the client writes the shape of the response: it asks for posts { title author { name } } and receives exactly those fields, in a single request. The server defines a schema (the types and the fields) and, for each field, a function, the resolver, which knows where to go and get the value. The following listing builds the schema with the library's objects and prepares two "contexts": a naive one, in which every resolver makes its own SELECT, and one with 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') },
},
}),
});
/** Naive context: one SELECT for every field that asks for it */
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),
});
/** Context with DataLoader: created FOR EVERY request, never shared between users */
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(`author ${id} missing`));
});
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 });
}
In the naive context the Post.author resolver runs one SELECT for every article: with twenty articles that is twenty SELECTs, plus the one for the list. In the context with DataLoader the load(id) calls made in the same event-loop cycle are collected and the batch function receives all the keys together: a single SELECT with IN (...). The listing creates the DataLoaders inside the batchedContext function, that is, for every request: DataLoader's documentation explains that each instance is a cache and that it should normally be created per request if different users may see different things.
DataLoader imposes two constraints on the batch function, and that is where mistakes usually happen:
- the array of values must have the same length as the array of keys;
- every position of the values must correspond to the same position of the keys, even if the database returns the rows in a different order.
In the code the constraint can be seen in the line ids.map((id) => byId.get(id) ?? new Error(...)): I build a map by id and reorder. If I returned the rows as the database returns them, an author would end up attached to the wrong article with no error at all: that is why the official documentation shows the same reordering step and a placeholder for missing keys. Now the execution listing, which runs the same query in the two contexts and then inside an HTTP request.
// ---- execution ---------------------------------------------------------------
if (process.argv[1]?.endsWith('graphql.ts')) {
const query = '{ posts { title author { name } } }';
for (const [label, ctx] of [['no DataLoader', naiveContext()], ['with 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)} articles: ${rows} bytes: ${Buffer.byteLength(JSON.stringify(result))}`);
}
// The same, inside a real HTTP request (with no port open)
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('unknown field :', bad.statusCode, JSON.stringify(JSON.parse(bad.payload).errors[0].message));
await app.close();
}
// Output:
// no DataLoader SELECT: 21 articles: 20 bytes: 1071
// with DataLoader SELECT: 2 articles: 20 bytes: 1071
// POST /graphql : 200 application/json; charset=utf-8 cache-control = undefined
// unknown field : 200 "Cannot query field \"titel\" on type \"Post\". Did you mean \"title\"?"
The output shows 21 SELECTs against 2 with DataLoader, with the same response (20 articles, 1,071 bytes). Then it shows three facts that matter for whoever runs the API. First: POST /graphql answers 200 without any Cache-Control header, so no HTTP cache will reuse it. Second: RFC 9110 says that responses to a POST are cacheable only with explicit freshness information and a Content-Location equal to the request URL: this is not the case for normal queries. Third: a nonexistent field (titel) does not produce a 404 but a 200 with an errors array and a message that suggests the right name ("Did you mean "title"?").
This last point depends on my choices: my handler always answers 200 with application/json. The draft of the GraphQL over HTTP specification defines a newer content type, application/graphql-response+json, which conforming servers must support, and calls "legacy" the servers that do not: my handler is of that kind. The same draft provides that the server may also answer GET requests, but says only that it may do so: if you want HTTP caching on GraphQL you have to build it yourself, for example with GET requests and with documents known in advance.
What GraphQL asks you to defend
In REST the shape of every response is decided by the server: an endpoint costs as much as you wrote it to. In GraphQL the shape is chosen by the client, and if the schema has a cycle (an article has an author, an author has articles, an article has an author...) a query can nest as much as it wants. The listing measures how much the response grows with the levels and tries a validation rule to limit the depth. The security page of graphql.org lists among the defences precisely the depth limit, breadth limits, complexity analysis and trusted documents.
import { GraphQLError, parse, validate, specifiedRules, type ValidationRule } from 'graphql';
import { batchedContext, run, schema } from './graphql.ts';
import { sql } from './data.ts';
/** Naive rule: counts how many fields are nested inside an operation */
function depthLimit(max: number): ValidationRule {
return (context) => {
let depth = 0;
return {
Field: {
enter() {
depth++;
if (depth > max) context.reportError(new GraphQLError(`depth ${depth} over the limit ${max}`));
},
leave() {
depth--;
},
},
};
};
}
// posts -> author -> posts -> author -> ...: every two levels the result is multiplied by 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('levels'.padStart(7), 'response bytes'.padStart(20), 'SELECT'.padStart(7));
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));
}
// With the limit at 4 the deep request is rejected BEFORE running a single SELECT
sql.reset();
const deep = parse(nested(7));
const errors = validate(schema, deep, [...specifiedRules, depthLimit(4)]);
console.log('with limit 4:', errors.length, 'errors, first:', JSON.stringify(errors[0]?.message), 'SELECT:', sql.count);
// ... but fragments get around it: each definition, on its own, is shallow
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('with fragments:', bypass.length, 'errors; executed anyway, SELECT:', sql.count, 'bytes:', Buffer.byteLength(JSON.stringify(executed)));
// Output:
// levels response bytes SELECT
// 1 640 2
// 3 2960 3
// 5 12240 3
// 7 49360 3
// with limit 4: 5 errors, first: "depth 5 over the limit 4" SELECT: 0
// with fragments: 0 errors; executed anyway, SELECT: 3 bytes: 12240
The table in the output is the most important lesson of the chapter. With DataLoader the SELECTs stay at two or three at any depth, so the problem is no longer the number of queries. It is the size of the response, which goes from 640 to 49,360 bytes when the levels rise from 1 to 7: every two levels, because of the author-articles cycle, the result is multiplied by about four. The depthLimit(4) rule rejects the seven-level query before running it (zero SELECTs).
But the same last line of the output shows the limit of my rule: with the fragments (fragment A, fragment B...) the query passes with zero errors and runs in full, with 12,240 bytes. The reason is that my visitor counts the depth inside each definition and does not follow the ...A from one fragment to another. It is a rule to use as an example, not in production: a rule suitable for production must follow the fragments too, and a real defence combines depth, complexity and rate limiting.
How to choose, with the numbers in hand
The numbers in this chapter belong to a twenty-row database: they do not prove performance, they show mechanisms. They serve to formulate four questions to ask yourself before choosing:
- How many different clients consume the API, and how different are the shapes they need? With a single client and a few screens, dedicated endpoints cost little.
- How much does shared HTTP caching (CDN, proxy) matter? With REST on GET you have it, with GraphQL you have to design it.
- Who controls the clients? If they are public and untrusted, you need depth and complexity limits; if they are internal, the urgency is lower.
- Is the team ready to manage a schema, resolvers and batched loading? It is work that does not exist with REST, and with GraphQL cannot be avoided.
The four questions have no order, but they have a practical hierarchy: if you answer "many different clients and little shared caching", GraphQL removes the multiplication of endpoints from the backend; if you answer "few clients and a lot of caching", REST with a few dedicated endpoints (like /post-cards) is simpler and gives the same guarantees with fewer moving parts. This is my own assessment, built on the mechanisms measured above, not a general empirical result. In many projects the honest answer is "both", with REST for public, cacheable resources and GraphQL for the interface that composes data.
Frequently asked questions
Does GraphQL solve the N+1 problem?
No, it makes it more visible. In the test, the same query runs 21 SELECTs with naive resolvers and 2 with a DataLoader; the same problem exists in REST if you load the relations inside a loop, as my endpoint did with include (21 SELECTs).
Can HTTP caching be used with GraphQL?
Not for normal POSTs: in the test the response carries no Cache-Control, and RFC 9110 allows reusing responses to a POST only with explicit freshness information and a consistent Content-Location. It has to be designed, for example with GET requests and documents known in advance.
Why must the DataLoader be created for every request?
Because each instance holds a cache of the values already loaded. DataLoader's documentation says to create it per request when different users may see different data, so as not to share results between people.
What you can do now
You can measure an API with three numbers (requests, SQL statements, bytes) instead of judging it by feel, and you can recognise under-fetching, over-fetching and the N+1, in REST as in GraphQL. You can write a DataLoader that respects the constraint on length and order and create it per request. You know what you lose by giving up HTTP caching and what you have to defend when the client decides the shape of the response, with a depth limit that is not enough on its own. The next chapter moves to the browser side, with Core Web Vitals; the index is on the series page.
The listings use TypeScript, like those in the first article; the series closes with Multi-environment CI/CD, which collects the list of all eight articles.
Sources
- RFC 9110, HTTP Semantics - section on the POST method: responses cacheable only with explicit freshness information and a
Content-Locationequal to the URL, read on 2026-11-13 - GraphQL over HTTP (draft) - media type
application/graphql-response+json, "legacy" servers, POST mandatory and GET optional, read on 2026-11-13 - GraphQL, Security - defences listed: depth limit, breadth, complexity, trusted documents; remark that a solved N+1 does not remove the load from nested queries, read on 2026-11-13
- DataLoader (official README) - batch function with the same length and the same order as the keys, per-request instances, cache as per-request memoization, read on 2026-11-13
- npm registry, graphql package - version 17.1.0, queried with
npm viewon 2026-11-13; same queries for fastify (5.12.8) and dataloader (2.2.3)
Commenti
Caricamento commenti...
Sign in to comment