How to buildOctober 11, 202614 min read
APIs & IntegrationsStep 1 of 10

REST or GraphQL

In short

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.

ComparisonLevel: IntermediateREST & GraphQL

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/s1 but not POST /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

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
  1. 01One task, two approaches
  2. 02Fetching data: over- and under-fetching
  3. 03Caching: HTTP versus a client cache
  4. 04Errors and status codes
  5. 05Security and query cost
  6. 06Versioning, tooling and alternatives: gRPC and tRPC
  7. 07Decision table

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.

src/rest.js
// Учебная 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"));
Learning REST stub: three resources, ETag and Cache-Control on every response. Not production code: no authorization, rate limiting or database error handling

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.

terminal
$ 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":"Веб-разработка"}
Real output: three requests to the REST stub

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:

requests/card.json
{
  "query": "query ServiceCard($id: ID!) { service(id: $id) { title category { name } reviews { rating text } } }",
  "variables": { "id": "s1" }
}
The card query: the ServiceCard operation with a $id variable
terminal
$ curl -s http://localhost:4400/graphql -H "content-type: application/json" -d @requests/card.json
{"data":{"service":{"title":"Корпоративный сайт","category":{"name":"Веб-разработка"},"reviews":[{"rating":5,"text":"Сайт сдали в срок"}]}}}
Real output: one request, and the response mirrors the shape of the document

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.

terminal
$ 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":"Сайт сдали в срок"}]}}}
Real output; the Date, Connection, Keep-Alive, Transfer-Encoding and Content-Length headers are removed. By default the GraphQL response has neither ETag nor Cache-Control

GraphQL 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; for GET, 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.

  1. Request error

    Syntax, an unknown field, a variable of the wrong type. It happens before execution, so the response has no data key.

  2. Execution error

    Raised by a resolver. The other fields still execute and the response is partial: data holds what succeeded, errors holds the message and the path to the field. If the failed field is non-null (!), the null propagates up to the nearest parent that may be null.

terminal
$ 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}
Real output: REST answers 404, GraphQL answers 200 and null; an execution error in the services: [Service!]! field nulled the whole data

The 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.

terminal
$ 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 400
Real Yoga 5.24 output: the same field typo gives 200 or 400 depending on Accept
  • Monitoring by HTTP status misses most GraphQL errors — count responses with a non-empty errors and group them by operation name.
  • A null in the response means either "no data" or "an error in the field". The path in errors tells them apart.
  • Expected errors (bad input, no access) are best returned with your own code in extensions.code, like BAD_USER_INPUT above; Yoga masks unexpected ones as Unexpected 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 by operationName

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 errors in 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.