REST serves data as resources with their own URLs, so browsers, CDNs and proxies can cache responses and errors show up in the HTTP status. GraphQL takes a request listing the fields it needs at a single endpoint and gathers related data in one round trip, but caching moves to the client, errors move into the response body, and the server must limit query depth and cost. REST suits public APIs, integrations and files; GraphQL suits several clients with different screens over connected data.
After this you will be able to
- count how many HTTP requests and network round trips a screen needs in REST and in GraphQL, and explain where the server-side work goes
- explain why a CDN caches
GET /services/s1but notPOST /graphql, and what GraphQL uses instead - tell a GraphQL request error from an execution error and predict the HTTP status of the response
- choose an API style (REST, GraphQL, gRPC or tRPC) using the decision table
Know beforehand
Verified with
Node.js 24 LTS (24.18)GraphQL Yoga 5.24.4graphql-js 16.14.2GraphQL spec (September 2025)
Checked against docs: October 11, 2026
Contents
One task, two approaches
Let's compare the styles on one screen: a service card in a studio catalogue showing the title, category and reviews. The data is the same small in-memory "database", served by two learning servers: REST on node:http and GraphQL on GraphQL Yoga from the lesson "GraphQL: schema, queries and mutations". Every response below is real output. REST conventions — resources, methods, status codes, the Problem Details error format, versioning — are covered in the API design lesson and are not repeated here.
REST (Representational State Transfer) is a style in which data is exposed as resources with their own URLs and the HTTP method expresses the action. GraphQL is a query language and runtime: the server publishes a type schema, and the client sends a document listing the fields it needs to a single endpoint, usually /graphql. The server returns exactly those fields, in the same shape as the request.
// Учебная REST-заглушка над той же «БД», что и GraphQL-сервер: только GET,
// без авторизации. Каждый адрес — отдельный ресурс со своим ETag.
import { createServer } from "node:http";
import { createHash } from "node:crypto";
import { createDb } from "./db.js";
const db = createDb({ onQuery: (sql, params) => console.log("[db]", sql, JSON.stringify(params)) });
const routes = [
[/^\/services\/(\w+)$/, ([id]) => db.findService(id)],
[/^\/services\/(\w+)\/reviews$/, ([id]) => db.reviewsByServiceIds([id])],
[/^\/categories\/(\w+)$/, async ([id]) => (await db.categoriesByIds([id]))[0] ?? null],
];
function notFound(res) {
res.writeHead(404, { "content-type": "application/problem+json" });
res.end(JSON.stringify({ type: "about:blank", title: "Not Found", status: 404 }));
}
createServer(async (req, res) => {
const { pathname } = new URL(req.url, "http://localhost");
const route = req.method === "GET" && routes.find(([pattern]) => pattern.test(pathname));
if (!route) return notFound(res);
const data = await route[1](pathname.match(route[0]).slice(1));
if (data === null) return notFound(res);
const body = JSON.stringify(data);
const etag = `"${createHash("sha256").update(body).digest("base64url").slice(0, 16)}"`;
const headers = { "cache-control": "public, max-age=60", etag };
if (req.headers["if-none-match"] === etag) {
res.writeHead(304, headers); // тело не передаётся: у клиента актуальная копия
return res.end();
}
res.writeHead(200, { ...headers, "content-type": "application/json" });
res.end(body);
}).listen(4401, () => console.log("REST: http://localhost:4401"));The REST client builds the card from three resources. It only learns the category URL from the first response (the categoryId field), so the requests take at least two sequential round trips: the service first, then the category and reviews in parallel.
$ curl -s http://localhost:4401/services/s1
{"id":"s1","title":"Корпоративный сайт","categoryId":"c1"}
$ curl -s http://localhost:4401/services/s1/reviews
[{"id":"r1","serviceId":"s1","rating":5,"text":"Сайт сдали в срок"}]
$ curl -s http://localhost:4401/categories/c1
{"id":"c1","name":"Веб-разработка"}The GraphQL client describes the whole screen in one document. The id value is passed as a separate variable rather than pasted into the query text:
{
"query": "query ServiceCard($id: ID!) { service(id: $id) { title category { name } reviews { rating text } } }",
"variables": { "id": "s1" }
}ServiceCard operation with a $id variable$ curl -s http://localhost:4400/graphql -H "content-type: application/json" -d @requests/card.json
{"data":{"service":{"title":"Корпоративный сайт","category":{"name":"Веб-разработка"},"reviews":[{"rating":5,"text":"Сайт сдали в срок"}]}}}REST
- 3 HTTP requests, 2 sequential round trips
- Fields the screen does not need:
categoryId,serviceId,id - Each response is cached by its own URL
GraphQL
- 1 HTTP request
- Only the requested fields
- One endpoint and POST: HTTP caching does not work without extra measures
Fetching data: over- and under-fetching
Overfetching means the response contains more than the screen needs. The REST service resource returns categoryId, and reviews return id and serviceId: the server does not know which client is asking, so it returns the whole representation. Underfetching is the opposite: one resource is not enough, and the client makes extra requests. On a mobile network every sequential round trip noticeably delays the screen.
In GraphQL the client picks the fields and follows relationships within one request. The graphql.org documentation puts it directly: a query traverses related objects and fetches them in one go instead of the several round trips typical of REST. Arguments can be passed to any field, not just to the URL as a whole.
In REST the same problems are solved by design. Workable options:
- Embedding relationships with a parameter:
GET /services/s1?include=category,reviews— it has to be designed, documented and protected from oversized selections. - Field selection:
?fields=title,category— the same idea, for fields. - A dedicated endpoint per screen or a BFF layer (backend for frontend — a server layer that assembles data for one specific client). Simple and fast, but every new screen means new server work.
- Accept the extra fields: with small responses and compression this is often cheaper than any of the schemes above.
GraphQL's flexibility has a price on the server. Each field is computed by its resolver — a function that returns the field's value — and a naive list implementation causes the N+1 problem: one query for the list plus one more per item. In the GraphQL lesson, a list of five services with categories and reviews makes 11 database queries without batching and 3 with DataLoader; both numbers are checked by tests.
Caching: HTTP versus a client cache
An HTTP cache selects a stored response by key. RFC 9111 defines the key as at least the request method and target URI, and notes that many caches in practice only store GET responses and use the URI alone as the key. Every REST resource has its own URL, so browsers, CDNs and reverse proxies cache it without knowing anything about your API. The ETag header is a version of the response: the client sends it back in If-None-Match, and if the data has not changed the server answers 304 Not Modified with no body.
$ curl -si http://localhost:4401/services/s1
HTTP/1.1 200 OK
cache-control: public, max-age=60
etag: "2-1XKP8a0VuDcTfE"
content-type: application/json
{"id":"s1","title":"Корпоративный сайт","categoryId":"c1"}
$ curl -si http://localhost:4401/services/s1 -H 'if-none-match: "2-1XKP8a0VuDcTfE"'
HTTP/1.1 304 Not Modified
cache-control: public, max-age=60
etag: "2-1XKP8a0VuDcTfE"
$ curl -si http://localhost:4400/graphql -H "content-type: application/json" -d @requests/card.json
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"data":{"service":{"title":"Корпоративный сайт","category":{"name":"Веб-разработка"},"reviews":[{"rating":5,"text":"Сайт сдали в срок"}]}}}Date, Connection, Keep-Alive, Transfer-Encoding and Content-Length headers are removed. By default the GraphQL response has neither ETag nor Cache-ControlGraphQL usually goes through a single POST endpoint. RFC 9110 allows a POST response to be cached only with explicit freshness information and a Content-Location header equal to the request URI, and forbids satisfying a later POST from a stored response. For a typical GraphQL server this means CDNs and browsers do not cache its responses.
The workarounds are described on graphql.org. Read operations (query) can be sent with GET so HTTP caching applies, but a long document runs into URL length limits in browsers and CDNs. Persisted queries solve that: the client sends a hash of a stored document instead of its text. On the server you cache results: Yoga has the @graphql-yoga/plugin-response-cache plugin, which stores responses, invalidates them after mutations, sends ETag and answers 304.
GraphQL's main cache lives on the client. The client library splits responses into objects and stores each one under a "type + identifier" key, so a service fetched in a list immediately shows up in the card. graphql.org recommends giving objects a globally unique id, or building the key from __typename and an identifier.
Browser, CDN, proxy
- REST
- Work by resource URL and
Cache-Control - GraphQL
- Do not work for
POST; forGET, with short or persisted documents
Conditional requests (ETag, 304)
- REST
- Built into HTTP, implemented per resource
- GraphQL
- Through a server-side response cache plugin
Client cache
- REST
- Usually keyed by request URL
- GraphQL
- Normalised: each object by type and
id
Invalidation after a change
- REST
- By the URLs of changed resources
- GraphQL
- By the types and
ids in the mutation response
How to choose cache lifetimes and invalidate when data changes is covered in the deep dive on caching and invalidation.
Errors and status codes
In REST the status code carries the outcome: 404 means no such resource, 400 a bad request, and the error body uses Problem Details. A GraphQL response has three top-level keys — data, errors and extensions — and the body carries the meaning. The specification distinguishes two kinds of errors.
Request error
Syntax, an unknown field, a variable of the wrong type. It happens before execution, so the response has no
datakey.Execution error
Raised by a resolver. The other fields still execute and the response is partial:
dataholds what succeeded,errorsholds the message and thepathto the field. If the failed field is non-null (!), thenullpropagates up to the nearest parent that may benull.
$ curl -si http://localhost:4401/services/s404
HTTP/1.1 404 Not Found
content-type: application/problem+json
{"type":"about:blank","title":"Not Found","status":404}
$ curl -si http://localhost:4400/graphql -H "content-type: application/json" -d '{"query":"{ service(id: \"s404\") { title } }"}'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"data":{"service":null}}
$ curl -si http://localhost:4400/graphql -H "content-type: application/json" -d '{"query":"{ services(first: 100) { title } }"}'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"errors":[{"message":"first должен быть от 1 до 50","locations":[{"line":1,"column":3}],"path":["services"],"extensions":{"code":"BAD_USER_INPUT"}}],"data":null}null; an execution error in the services: [Service!]! field nulled the whole dataThe status for a request error depends on the media type. The graphql.org guide describes it following the GraphQL over HTTP specification: if the client accepts application/graphql-response+json, the server answers a failed request with a 4xx code, while with the legacy application/json 4xx and 5xx codes are discouraged for such errors and 2xx is used. A response with non-null data is 2xx even when it has errors: HTTP has no "partial success" status.
$ curl -s -w '\nHTTP %{http_code}\n' http://localhost:4400/graphql -H 'content-type: application/json' -H 'accept: application/json' -d '{"query":"{ servise(id: \"s1\") { title } }"}'
{"errors":[{"message":"Cannot query field \"servise\" on type \"Query\". Did you mean \"service\" or \"services\"?","locations":[{"line":1,"column":3}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}
HTTP 200
$ curl -s -w '\nHTTP %{http_code}\n' http://localhost:4400/graphql -H 'content-type: application/json' -H 'accept: application/graphql-response+json' -d '{"query":"{ servise(id: \"s1\") { title } }"}'
{"errors":[{"message":"Cannot query field \"servise\" on type \"Query\". Did you mean \"service\" or \"services\"?","locations":[{"line":1,"column":3}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}
HTTP 400Accept- Monitoring by HTTP status misses most GraphQL errors — count responses with a non-empty
errorsand group them by operation name. - A
nullin the response means either "no data" or "an error in the field". Thepathinerrorstells them apart. - Expected errors (bad input, no access) are best returned with your own code in
extensions.code, likeBAD_USER_INPUTabove; Yoga masks unexpected ones asUnexpected error..
Security and query cost
The cost of a REST route is known at design time: GET /services/s1 means one or two database queries. Rate limits and permissions are attached to routes. In GraphQL the client composes the query. If the schema has a cycle — a category has services, a service has a category — the query categories { services { category { services … } } } grows without bound, and a hundred aliases of one field multiply the work a hundredfold. That is why the graphql.org security guide lists measures REST APIs usually do not need.
Trusted documents
- What it limits
- Only operations known in advance are allowed
- Notes
- The strongest measure, but only for your own clients: third-party operations are not known in advance
Pagination
- What it limits
- List size
- Notes
- Any list field that can grow gets a limit
Depth limit
- What it limits
- Field nesting
- Notes
- Checked before execution; a separate, smaller limit for nested lists
Breadth and alias limit
- What it limits
- Number of top-level fields and aliases
- Notes
- Protects against
friends1 … friends100
Batch limit
- What it limits
- Operations per HTTP request
- Notes
- If the server accepts batched requests
Cost analysis
- What it limits
- The total "weight" of fields
- Notes
- A query above the threshold is rejected or charged against the client's budget
Rate limits in business logic
- What it limits
- Expensive operations
- Notes
- Counting HTTP requests does not reflect their cost
The same guide notes that disabling introspection (the schema query) for first-party-only APIs and masking error details help, but neither hides the schema on its own — it can be reconstructed by guessing field names. On the verified server, a depth limit of 5 rejects a depth-6 query before it reaches the database; this is shown in the GraphQL lesson.
Authorization in GraphQL works at the field level. Authentication runs in middleware before GraphQL, while permission decisions are made during execution, in resolvers or the business logic layer. In REST permissions are usually checked per route and per object; that approach is covered in the lesson on roles and permissions.
Versioning, tooling and alternatives: gRPC and tRPC
Versioning. REST APIs ship versions — in the path, a parameter or a header — and retire old versions on a schedule. GraphQL, in graphql.org's words, "takes a strong opinion on avoiding versioning": clients only receive the fields they ask for, so new types and fields break nothing. An outdated field is marked with the built-in @deprecated(reason: …) directive, you track which operations still request it, and you remove it once nobody does.
Contract
- REST
- OpenAPI — written separately or generated from code
- GraphQL
- The SDL schema — part of the server, available via introspection
Request validation
- REST
- JSON schemas on the server, if you wire them in
- GraphQL
- Validation against the schema before execution — always
Client types
- REST
- Generated from OpenAPI
- GraphQL
- Generated from the schema and the specific operations
Debugging
- REST
- curl, DevTools, proxy logs — the URL speaks for itself
- GraphQL
- The GraphiQL sandbox; in DevTools every request is
POST /graphql, tell them apart byoperationName
Files and streams
- REST
- Plain
multipart,Range, redirects - GraphQL
- Not designed for it: signed URLs or REST alongside
gRPC is a remote procedure call framework. Services and messages are described in a .proto file: Protocol Buffers serve both as the interface definition language and as the binary message format, and the protoc compiler generates clients and servers in the languages you need. Besides request–response calls there are server-streaming, client-streaming and bidirectional streaming calls, and the client sets a deadline for each call. The transport is HTTP/2. For browsers there is a separate flavour, gRPC-Web; in the documentation example, browser requests go through an Envoy proxy. gRPC typically connects services inside a system.
tRPC is an RPC implementation that its documentation describes as "designed for TypeScript monorepos". There is no schema and no code generation: the client gets the input, output and error types of procedures through TypeScript inference straight from the server code. Our conclusion: tRPC fits when one team writes the frontend and backend in TypeScript in one repository, and does not fit when the API must serve clients in other languages or external partners.
Decision table
Public API for partners, webhooks, exchange with 1C and CRMs
- What to choose
- REST
- Why
- Understood by any HTTP client, cached by URL, errors visible in status codes
Uploading and serving files, large exports
- What to choose
- REST (or signed storage URLs)
- Why
- GraphQL is not designed for files
Web and mobile apps with different screens over connected data
- What to choose
- GraphQL
- Why
- Each client takes its own fields in one round trip; the schema is a shared contract
Many teams and services behind one API
- What to choose
- GraphQL as a shared layer
- Why
- One schema instead of a BFF per client
Communication between internal services, streaming, low latency
- What to choose
- gRPC
- Why
- Binary format, code generation, streaming calls, deadlines
Full-stack TypeScript, one team, one repository
- What to choose
- tRPC
- Why
- Client types are inferred from the server with no schema or codegen
A simple CRUD service, few clients, CDN matters
- What to choose
- REST
- Why
- Fewer moving parts, caching out of the box
- Who are the API clients: your own apps, partners, other services?
- How many different screens read the same data, and how often do their needs change?
- Do you need CDN caching for public data?
- Are there files, streams, long exports?
- Who will limit query depth and cost and watch
errorsin monitoring? - What languages are the clients written in — is everything TypeScript?
Next comes practice: in the lesson "GraphQL: schema, queries and mutations" we build the server from this article, write resolvers, solve N+1 with DataLoader and limit query depth.
How this was verified3 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.