REST отдаёт данные ресурсами с отдельными адресами, поэтому его ответы кэшируют браузер, CDN и прокси, а ошибки видны по HTTP-статусу. GraphQL принимает на один адрес запрос с перечнем нужных полей и собирает связанные данные за один круг по сети, но кэш переезжает на клиент, ошибки — в тело ответа, а сервер обязан ограничивать глубину и стоимость запросов. REST разумен для публичных API, интеграций и файлов, GraphQL — для нескольких клиентов с разными экранами над связанными данными.
После материала вы сможете
- посчитать, сколько HTTP-запросов и кругов по сети нужно экрану в REST и в GraphQL, и объяснить, куда при этом уходит работа сервера
- объяснить, почему ответ
GET /services/s1кэширует CDN, аPOST /graphql— нет, и чем это заменяют в GraphQL - различить ошибку запроса и ошибку выполнения GraphQL и предсказать HTTP-статус ответа
- выбрать стиль API (REST, GraphQL, gRPC или tRPC) по таблице «когда что»
Что знать заранее
Проверено на
Node.js 24 LTS (24.18)GraphQL Yoga 5.24.4graphql-js 16.14.2GraphQL spec (September 2025)
Сверено с документацией: 11 октября 2026 г.
Содержание
Одна задача — два подхода
Сравним стили на одном экране: карточка услуги в каталоге студии — название, категория и отзывы. Данные одни и те же, небольшая «база» в памяти, к ней два учебных сервера: REST на node:http и GraphQL на GraphQL Yoga из урока «GraphQL: схема, запросы и мутации». Все ответы ниже — реальный вывод. Соглашения REST — ресурсы, методы, коды, формат ошибок Problem Details, версии — разобраны в уроке «Проектирование API», здесь мы их не повторяем.
REST (Representational State Transfer) — стиль, в котором данные представлены ресурсами со своими адресами, а действие выражает HTTP-метод. GraphQL — язык запросов и среда выполнения: сервер публикует схему типов, а клиент присылает на один адрес, обычно /graphql, документ с перечнем нужных полей. Сервер возвращает ровно эти поля — в той же форме, что и запрос.
// Учебная 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"));REST-клиент собирает карточку из трёх ресурсов. Адрес категории он узнаёт только из первого ответа (поле categoryId), поэтому запросы идут минимум в два последовательных круга: сначала услуга, затем параллельно категория и отзывы.
$ 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":"Веб-разработка"}GraphQL-клиент описывает весь экран одним документом. Значение id передаётся отдельной переменной, а не вклеивается в текст запроса:
{
"query": "query ServiceCard($id: ID!) { service(id: $id) { title category { name } reviews { rating text } } }",
"variables": { "id": "s1" }
}ServiceCard с переменной $id$ 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-запроса, 2 последовательных круга по сети
- Лишние для экрана поля:
categoryId,serviceId,id - Каждый ответ кэшируется по своему адресу
GraphQL
- 1 HTTP-запрос
- Только запрошенные поля
- Один адрес и метод POST: HTTP-кэш не работает без дополнительных мер
Выборка данных: over- и under-fetching
Overfetching — ответ содержит больше, чем нужно экрану. Ресурс услуги в REST отдаёт categoryId, а отзывы — id и serviceId: сервер не знает, какой клиент спрашивает, и возвращает представление целиком. Underfetching — обратная проблема: одного ресурса не хватает, и клиент делает дополнительные запросы. На мобильной сети каждый последовательный круг заметно задерживает экран.
В GraphQL клиент выбирает поля сам и проходит по связям в одном запросе. Документация graphql.org формулирует это прямо: запрос обходит связанные объекты и получает их за один раз вместо нескольких кругов, типичных для REST. Аргументы можно передать любому полю, а не только адресу целиком.
В REST те же проблемы решают проектированием. Рабочие варианты:
- Встраивание связей параметром:
GET /services/s1?include=category,reviews— его нужно спроектировать, задокументировать и защитить от слишком больших выборок. - Выбор полей:
?fields=title,category— то же самое, но для полей. - Отдельный endpoint под экран или слой BFF (backend for frontend — серверная прослойка, которая собирает данные под конкретный клиент). Просто и быстро, но каждый новый экран — новая работа на сервере.
- Принять лишние поля: при небольших ответах и сжатии это часто дешевле любой из схем выше.
У гибкости GraphQL есть цена на сервере. Каждое поле вычисляет свой резолвер — функция, которая возвращает значение поля, — и наивная реализация списка порождает проблему N+1: один запрос за списком и ещё по запросу на каждый элемент. В уроке по GraphQL список из пяти услуг с категориями и отзывами без пакетной загрузки делает 11 запросов к базе, а с DataLoader — 3; оба числа проверены тестами.
Кэширование: HTTP против клиентского кэша
HTTP-кэш выбирает сохранённый ответ по ключу. RFC 9111 определяет ключ как минимум из метода и адреса запроса и уточняет, что многие кэши на практике сохраняют только ответы на GET и используют как ключ один адрес. У каждого ресурса REST свой адрес, поэтому браузер, CDN и обратный прокси кэшируют его, ничего не зная о вашем API. Заголовок ETag — версия ответа: клиент присылает её в If-None-Match, и если данные не изменились, сервер отвечает 304 Not Modified без тела.
$ 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, Content-Length убраны. У ответа GraphQL по умолчанию нет ни ETag, ни Cache-ControlGraphQL обычно ходит одним методом POST на один адрес. RFC 9110 разрешает кэшировать ответ на POST только при явной свежести и заголовке Content-Location, совпадающем с адресом запроса, и запрещает отвечать сохранённым ответом на следующий POST. Для типичного GraphQL-сервера это значит: CDN и браузер его ответы не кэшируют.
Обходные пути описаны на graphql.org. Запросы на чтение (query) можно отправлять методом GET — тогда работает HTTP-кэш, — но длинный документ упирается в ограничения длины URL у браузеров и CDN. Это решают persisted queries: клиент шлёт хэш заранее сохранённого документа вместо его текста. На сервере кэшируют результаты: у Yoga есть плагин @graphql-yoga/plugin-response-cache, который хранит ответы, сбрасывает их после мутаций, отдаёт ETag и отвечает 304.
Главный кэш GraphQL — на клиенте. Клиентская библиотека раскладывает ответы на объекты и хранит каждый по ключу «тип + идентификатор», поэтому услуга, полученная в списке, сразу видна в карточке. graphql.org советует давать объектам глобально уникальный id или строить ключ из __typename и идентификатора.
Браузер, CDN, прокси
- REST
- Работают по адресу ресурса и
Cache-Control - GraphQL
- Не работают для
POST; дляGET— при коротких или сохранённых документах
Условные запросы (ETag, 304)
- REST
- Встроены в HTTP, реализуются на каждом ресурсе
- GraphQL
- Через серверный плагин кэша ответов
Клиентский кэш
- REST
- Обычно по адресу запроса
- GraphQL
- Нормализованный: объект по типу и
id
Сброс после изменения
- REST
- По адресам изменённых ресурсов
- GraphQL
- По типам и
idиз ответа мутации
Как выбирать время жизни и сбрасывать кэш, когда данные изменились, — в разборе «Кэширование и инвалидация».
Ошибки и коды ответов
В REST результат сообщает статус: 404 — нет ресурса, 400 — неверный запрос; тело ошибки — в формате Problem Details. В GraphQL у ответа три ключа верхнего уровня — data, errors и extensions, — а смысл передаёт тело. Спецификация различает два вида ошибок.
Ошибка запроса
Синтаксис, неизвестное поле, переменная не того типа. Возникает до выполнения, поэтому ключа
dataв ответе нет.Ошибка выполнения
Её выбросил резолвер. Остальные поля выполняются, ответ частичный: в
dataто, что удалось, вerrors— сообщение иpathдо поля. Если упавшее поле объявлено non-null (!),nullподнимается к ближайшему родителю, который может бытьnull.
$ 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; ошибка выполнения в поле services: [Service!]! обнулила весь dataСтатус ответа на ошибку запроса зависит от формата. Руководство graphql.org описывает это по спецификации GraphQL over HTTP: если клиент принимает тип application/graphql-response+json, сервер отвечает на неудачный запрос кодом 4xx, а со старым application/json коды 4xx и 5xx для таких ошибок не рекомендуются — отвечают 2xx. Ответ с непустым data — 2xx даже при ошибках: в HTTP нет кода «частичный успех».
$ 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- Мониторинг по HTTP-статусам не видит большую часть ошибок GraphQL — считайте ответы с непустым
errorsи группируйте их по имени операции. nullв ответе означает либо «нет данных», либо «ошибка в поле». Отличить помогаетpathвerrors.- Ожидаемые ошибки (неверный ввод, нет доступа) удобно отдавать со своим кодом в
extensions.code, какBAD_USER_INPUTвыше; неожиданные Yoga маскирует фразойUnexpected error..
Безопасность и стоимость запросов
У REST-маршрута стоимость известна при проектировании: GET /services/s1 — один-два запроса к базе. Лимиты частоты и права ставят на маршруты. В GraphQL запрос составляет клиент. Если в схеме есть цикл — у категории есть услуги, у услуги категория, — запрос categories { services { category { services … } } } растёт без предела, а сто алиасов одного поля умножают работу в сто раз. Поэтому руководство graphql.org по безопасности перечисляет меры, которых в REST-API обычно нет.
Доверенные документы (trusted documents)
- Что ограничивает
- Разрешены только заранее известные операции
- Комментарий
- Самая сильная мера, но только для своих клиентов: операции стороннего клиента заранее неизвестны
Пагинация
- Что ограничивает
- Размер списков
- Комментарий
- Любое поле-список, которое может вырасти, — с ограничением
Лимит глубины
- Что ограничивает
- Вложенность полей
- Комментарий
- Проверяется до выполнения; для вложенных списков — отдельный, меньший лимит
Лимит ширины и алиасов
- Что ограничивает
- Число полей верхнего уровня и алиасов
- Комментарий
- Защита от
friends1 … friends100
Лимит пакета
- Что ограничивает
- Число операций в одном HTTP-запросе
- Комментарий
- Если сервер принимает пакеты запросов
Анализ стоимости
- Что ограничивает
- Суммарный «вес» полей
- Комментарий
- Запрос дороже порога отклоняется или списывается из бюджета клиента
Лимиты частоты в бизнес-логике
- Что ограничивает
- Дорогие операции
- Комментарий
- Подсчёт HTTP-запросов не отражает их стоимость
Там же сказано: отключение интроспекции (запроса схемы) для API только своих клиентов и маскирование деталей ошибок полезны, но по отдельности не скрывают схему — её можно восстановить перебором имён полей. На проверенном сервере лимит глубины 5 отклоняет запрос глубины 6 до обращения к базе — это показано в уроке по GraphQL.
Авторизация в GraphQL — на уровне полей. Аутентификацию выполняет middleware до GraphQL, а решения о правах принимаются во время выполнения, в резолверах или слое бизнес-логики. В REST права обычно проверяют на маршруте и объекте; подход подробно разобран в уроке «Авторизация: роли и права».
Версии, инструменты и альтернативы: gRPC и tRPC
Версионирование. REST-API выпускают версиями — в пути, параметре или заголовке, — и старую версию выводят из эксплуатации по плану. GraphQL, по формулировке graphql.org, «занимает твёрдую позицию против версий»: клиент получает только запрошенные поля, поэтому новые типы и поля ничего не ломают. Устаревшее поле помечают встроенной директивой @deprecated(reason: …), следят, какие операции его ещё запрашивают, и удаляют, когда запросов не осталось.
Контракт
- REST
- OpenAPI — пишется отдельно или генерируется из кода
- GraphQL
- Схема SDL — часть сервера, доступна через интроспекцию
Проверка запроса
- REST
- Схемы JSON на сервере, если их подключили
- GraphQL
- Валидация по схеме до выполнения — всегда
Типы для клиента
- REST
- Генерация из OpenAPI
- GraphQL
- Генерация из схемы и конкретных операций
Отладка
- REST
- curl, DevTools, логи прокси — адрес говорит сам за себя
- GraphQL
- Песочница GraphiQL; в DevTools все запросы —
POST /graphql, различать поoperationName
Файлы и потоки
- REST
- Обычный
multipart,Range, редиректы - GraphQL
- Не для этого: подписанные ссылки или REST рядом
gRPC — фреймворк удалённого вызова процедур. Сервис и сообщения описывают в .proto-файле: Protocol Buffers служат и языком описания, и двоичным форматом сообщений, а компилятор protoc генерирует клиента и сервер на нужных языках. Кроме вызова «запрос — ответ» есть потоки от сервера, от клиента и двунаправленные, а клиент задаёт крайний срок (deadline) вызова. Транспорт — HTTP/2. Для браузера есть отдельный вариант gRPC-Web; в примере документации запросы браузера проходят через прокси Envoy. Типичное место gRPC — связь между сервисами внутри системы.
tRPC — реализация RPC, по словам документации, «для TypeScript-монорепозиториев». Схемы и генерации кода нет: клиент получает типы входа, выхода и ошибок процедур выводом TypeScript прямо из кода сервера. Отсюда наш вывод: tRPC удобен, когда фронтенд и бэкенд пишет одна команда на TypeScript в одном репозитории, и не подходит, если API нужен клиентам на других языках или внешним партнёрам.
Таблица выбора
Публичный API для партнёров, вебхуки, обмен с 1С и CRM
- Что выбрать
- REST
- Почему
- Понятен любому HTTP-клиенту, кэшируется по адресам, ошибки видны по статусам
Загрузка и раздача файлов, большие выгрузки
- Что выбрать
- REST (или подписанные ссылки хранилища)
- Почему
- GraphQL не рассчитан на файлы
Веб и мобильное приложение с разными экранами над связанными данными
- Что выбрать
- GraphQL
- Почему
- Каждый клиент берёт свои поля за один круг; схема — общий контракт
Много команд и сервисов за одним API
- Что выбрать
- GraphQL как общий слой
- Почему
- Единая схема вместо набора BFF под каждый клиент
Связь между внутренними сервисами, потоки, низкие задержки
- Что выбрать
- gRPC
- Почему
- Двоичный формат, генерация кода, потоковые вызовы, крайние сроки
Full-stack TypeScript, одна команда, один репозиторий
- Что выбрать
- tRPC
- Почему
- Типы клиента выводятся из сервера без схемы и генерации
Простой CRUD-сервис, мало клиентов, важен CDN
- Что выбрать
- REST
- Почему
- Меньше движущихся частей, кэш из коробки
- Кто клиенты API: свои приложения, партнёры, другие сервисы?
- Сколько разных экранов читают одни и те же данные и как часто меняются их требования?
- Нужен ли кэш CDN для публичных данных?
- Есть ли файлы, потоки, длинные выгрузки?
- Кто будет ограничивать глубину и стоимость запросов и следить за
errorsв мониторинге? - На каких языках пишут клиенты — всё ли на TypeScript?
Дальше — практика: в уроке «GraphQL: схема, запросы и мутации» поднимаем сервер из этой статьи, пишем резолверы, решаем N+1 через DataLoader и ограничиваем глубину запросов.
Как проверено3 · пройденосверено 11 октября 2026 г.
- запущено, работает
src/rest.js (REST-заглушка на node:http) + вывод curl: три ресурса карточки, ETag/304, 404 Problem Details
запущена на Node.js 24.18 во временном проекте, запросы curl к localhost:4401, вывод скопирован (служебные заголовки убраны)
- запущено, работает
requests/card.json + вывод GraphQL (сервер из урока graphql-osnovy: graphql-yoga 5.24.4, graphql 16.14.2, dataloader 2.2.3): карточка, s404 → null, BAD_USER_INPUT с data:null, валидация 200/400 по Accept, отсутствие Cache-Control/ETag
сервер запущен на localhost:4400, запросы curl, вывод скопирован; журнал [db] обоих серверов — по 3 запроса на карточку
- запущено, работает
цифры 11 и 3 запросов к БД (N+1 без/с DataLoader)
тесты node:test в проекте graphql-osnovy (11/11 pass)
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.