How REST and GraphQL model data
REST models an API as a set of resources, each identified by a URI. Clients operate on those resources through HTTP's uniform interface: GET retrieves a representation, PUT replaces state, DELETE removes it, and POST handles processing the server defines. RFC 9110 assigns these methods shared semantics. GET is safe, and PUT and DELETE are idempotent. Because the meaning sits in the protocol, any intermediary can act on a request without knowing your domain. A proxy can cache a GET, and a client library can retry a PUT. That is the main reason teams pick REST: the existing web stack already understands the contract.
GraphQL exposes a single typed schema, usually behind one endpoint. Clients write queries that select the exact fields they need across related types. They send mutations for writes and open subscriptions to receive updates in real time. The schema is the contract. It is introspectable, so tooling can validate a query before it runs, and the server can evolve fields without cutting new endpoint versions. Teams adopt it when many clients need different shapes of the same data.
The difference that matters for the rest of this article is where each style puts complexity. With REST, complexity lives in resource design, HTTP semantics and the infrastructure built around them. With GraphQL, it moves into the schema and the resolver layer. Your team then owns problems that HTTP used to absorb for you.
Over-fetching and under-fetching in one concrete request
Take a screen that shows a user's name and their last three orders. In a typical REST design, the client first calls GET /users/42 and receives the full user representation: email, address, preferences and audit timestamps, almost all of which it discards. It then calls GET /users/42/orders?limit=3. Each order arrives with line items, shipping details and payment state, although the screen needs only an ID, a date and a total. If orders reference products by ID and the screen shows a product name, a third round of requests follows. The same flow shows both problems. Over-fetching is the unused fields in each response. Under-fetching is each response containing too little, which forces additional sequential calls.
With GraphQL, the client sends one query. It selects the user's name and, on a nested orders field limited to three, only the ID, date and total. The response mirrors that selection and contains nothing else.
This matters most on mobile. Every sequential round trip pays the connection's latency, unused bytes still cost bandwidth and parsing time, and installed app versions stay in use long after you ship a fix. It also matters for clients with many screens. Each screen needs a different slice of the same data, so the REST options are to return a superset everywhere or to add endpoints.
Backend-for-frontend endpoints such as /mobile/home-summary take the second option. They return exactly what one screen needs, which works, but each new screen or client variant adds another endpoint. Every one of them must be tested, documented and kept consistent with the underlying services. You also cannot retire an endpoint while old app versions still call it.
# REST: two sequential requests; the second needs the first to succeed.
GET /users/42
# -> { "id": 42, "name": "Ada Byrne", "email": "ada@example.com",
# "address": {...}, "preferences": {...},
# "createdAt": "...", "updatedAt": "..." }
# The screen uses only "name"; everything else is over-fetched.
GET /users/42/orders?limit=3
# -> [ { "id": "ord_981", "placedAt": "...", "total": 54.20,
# "lineItems": [...], "shipping": {...}, "payment": {...} }, ... ]
# The screen uses id, placedAt and total; line items, shipping and
# payment are discarded. If it needs product names, add a third round.
# GraphQL: one request; the client states exactly which fields it needs.
POST /graphql
query UserWithRecentOrders {
user(id: "42") {
name
orders(first: 3) { # argument name depends on your schema
id
placedAt
total
}
}
}
# -> { "data": { "user": { "name": "Ada Byrne",
# "orders": [ { "id": "ord_981", "placedAt": "...", "total": 54.20 },
# ... ] } } }
# The response has the same shape as the query and nothing else.
The same screen's data needs met two ways: chained REST calls that return unused fields, and one GraphQL query whose response contains only the selected fields.
Caching: HTTP-native semantics vs normalized client caches
REST inherits its caching from HTTP itself. A GET is defined as safe and idempotent in HTTP Semantics (RFC 9110), so every intermediary can cache it without understanding your API. The URL is the cache key. Cache-Control sets freshness. ETags with If-None-Match let a client revalidate and receive a bodyless 304 Not Modified. Browsers, reverse proxies and CDNs apply all of this automatically. You opt in with headers rather than with code.
GraphQL is usually sent as a POST to a single endpoint such as /graphql. Shared caches do not store POST responses by default, and the URL no longer identifies the data because the query lives in the request body. Most of the HTTP caching layer stops working.
GraphQL clients compensate on the client side. They build a normalized cache that splits each response into objects keyed by type and ID. The GraphQL best practices recommend globally unique IDs and a Node interface so that any object can be identified and refetched. This cache deduplicates data across screens, but it lives in a single client and does nothing for your origin's load.
| Concern | REST | GraphQL (default) |
|---|---|---|
| Cache key | URL + Vary headers |
Query body, opaque to proxies |
| CDN / shared cache | Works out of the box | Needs GET or persisted queries |
| Revalidation | ETag, 304 |
Custom, per client |
| Client deduplication | Manual | Normalized cache by ID |
To restore shared caching, send queries as GET with the query in the URL, or use persisted queries, where the client sends only a hash of a query the server already knows. Mutations must still use POST. Persisted queries add process overhead. You need a build step that extracts queries, a registry of approved queries, a deploy order that registers hashes before clients ship them, and cache-header logic that reflects the shortest freshness lifetime of any field in the response.
# 1) REST: a plain GET is cacheable by browsers, proxies and CDNs.
# -i prints response headers so you can see Cache-Control and ETag.
curl -i
# HTTP/1.1 200 OK
# Cache-Control: public, max-age=60
# ETag: "a1b2c3"
# 2) REST revalidation: send the ETag back. If nothing changed,
# the server replies 304 Not Modified with no body.
curl -i \
-H 'If-None-Match: "a1b2c3"'
# HTTP/1.1 304 Not Modified
# 3) GraphQL default: POST to one endpoint. The query is in the body,
# so the URL is the same for every query and shared caches skip it.
curl -i \
-H 'Content-Type: application/json' \
-d '{"query":"{ user(id: \"42\") { id name } }"}'
# 4) GraphQL over GET: -G moves the urlencoded data into the query
# string, so each distinct query gets its own cacheable URL.
# The server must allow GET and must reject mutations sent this way.
curl -i -G \
--data-urlencode 'query={ user(id: "42") { id name } }'
# 5) Persisted query (concept): the client sends only an identifier for
# a query the server already registered at build time. The URL stays
# short and stable. The parameter name and hash format depend on your
# server, so treat "id" and this value as placeholders.
curl -i -G \
--data-urlencode 'id=3f2a9c0e-placeholder-hash'
Curl requests that compare REST's cacheable GET and ETag revalidation with GraphQL sent as POST, sent as GET, and sent as a persisted query, which restores a stable URL that shared caches can key on.
Resolvers, the N+1 problem and query cost control
A GraphQL server executes a query by calling a resolver for each field, and each resolver knows only its own parent object. That isolation keeps resolvers simple to write. It also means the server does not plan data access across the whole query, so a nested list can quietly multiply backend calls. Consider a query that requests a list of orders and, for each order, its customer. The orders resolver runs one query. The customer resolver then runs once per order, and each run issues its own lookup.
Illustrative estimate (assumed numbers, not a benchmark): with N parent rows and M children per parent across two nested levels, naive calls = 1 + N + (N × M). If N = 50 and M = 10, that is 1 + 50 + 500 = 551 calls. With batching, it drops to 3 calls, one per level.
The standard fix is the data-loader pattern. Instead of fetching immediately, each resolver registers the key it needs. The loader collects keys during one tick of execution and issues a single batched fetch, such as WHERE id IN (...). It also caches results per request, so the same object is not loaded twice. These calls are hard to spot because they appear as many fast, similar queries. An endpoint-level timer shows only one slow request, so you need per-resolver tracing to see the fan-out (GraphQL performance guidance).
Cost control also differs between the two styles. A REST endpoint has a fixed response shape, so its worst-case cost is known in advance, and a per-endpoint rate limit bounds total load. In GraphQL, the client decides the shape of each query, so the server has to enforce its own limits:
- maximum query depth
- complexity scoring that weights list fields by their page size
- execution timeouts
- strict validation of pagination arguments
You also need to decide whether to expose introspection in production, because it gives any caller a full map of the schema.
import DataLoader from 'dataloader';
type Customer = { id: string; name: string };
// Placeholder DB client: any driver that runs parameterized SQL.
declare const db: { query(sql: string, params: unknown[]): Promise<Customer[]> };
// Batch function: receives every key requested during one tick of execution
// and must return results in the same order as the keys.
async function batchCustomers(ids: readonly string[]): Promise<(Customer | null)[]> {
// One round trip instead of one query per order.
const rows = await db.query('SELECT id, name FROM customers WHERE id = ANY($1)', [ids]);
const byId = new Map(rows.map((c) => [c.id, c]));
// Missing ids map to null so positions still line up with the keys.
return ids.map((id) => byId.get(id) ?? null);
}
// Create loaders per request so the cache never leaks data between users.
export function createContext() {
return { customerLoader: new DataLoader(batchCustomers) };
}
type Ctx = ReturnType<typeof createContext>;
export const resolvers = {
Order: {
// Runs once per order, but only registers a key; the loader issues
// a single batched query for all orders in the list.
customer: (order: { customerId: string }, _args: unknown, ctx: Ctx) =>
ctx.customerLoader.load(order.customerId),
},
};
A per-request DataLoader collects the customer IDs requested by each Order.customer resolver and fetches them in one batched query, so the N lookups become a single query.
Errors, observability, versioning and authorization
With REST, the outcome of a request is in the status line. A 404, 409 or 503 goes into access logs, load balancer metrics and alert rules without anyone parsing a body. GraphQL responses carry data, errors and optional extensions. A request that resolves nine fields and fails on one usually comes back as HTTP 200 with partial data. A status-code error-rate alert sees nothing wrong.
Teams handle this in two ways. Top-level errors entries fit exceptional failures such as timeouts or bugs. Errors-as-data, typed results like a PaymentDeclined union member, fit expected business outcomes that clients must handle. Either way, observability has to move up a layer. Record the operation name, per-resolver latency and error codes as metrics, because the URL is always /graphql and tells you nothing.
| Concern | REST | GraphQL |
|---|---|---|
| Error signal | HTTP status codes | errors array, often with a 200 |
| Log granularity | Method and path | Operation name and resolver |
| Versioning | URI (/v2/) or media type |
Additive changes, @deprecated |
| Authorization point | Endpoint or resource | Type and field |
GraphQL versioning depends on discipline from both sides. The server adds fields and deprecates old ones instead of removing them. Clients must tolerate unknown enum values and new union members. If they don't, an "additive" change still breaks them.
Authorization follows the same pattern. One REST route maps to one check. In GraphQL, any query can reach a sensitive field through several paths, such as user.email and order.customer.email. A gateway check on the endpoint can't see this. Enforce type- and field-level rules in the business layer that every resolver calls, as the GraphQL authorization guidance recommends (1).
When to choose REST, GraphQL or a hybrid
Start from who consumes the API and how they fetch, not from which style is more modern.
| Situation | Lean toward | Why |
|---|---|---|
| Public API, unknown consumers | REST | Stable URLs and HTTP status codes are the contract most integrators already expect |
| Read-heavy, cacheable resources | REST | CDNs and proxies cache GET responses by URL without custom configuration |
| Simple CRUD over a few entities | REST | A schema and resolver layer adds work without solving a real problem |
| File uploads, streaming downloads, webhooks | REST | These are plain HTTP flows; GraphQL handles them through workarounds |
| Web, mobile and partner clients composing data from several backends | GraphQL | Each client selects its own shape, and you avoid writing a bespoke endpoint per screen |
The hybrid: GraphQL over existing REST
You can often get most of GraphQL's client-side benefit without rewriting services. Put a GraphQL layer in front of the REST APIs you already run, and have its resolvers call those APIs. Clients get one schema. Backend teams keep their REST contracts, caching and deployment model.
This layer has three costs:
- An extra network hop. Every query passes through the gateway before it reaches a service, which adds latency to every request.
- Another component to operate. The gateway needs scaling, monitoring, on-call ownership and schema governance.
- Hidden backend problems. One query can fan out into dozens of REST calls. The client sees a single fast-looking endpoint, while a slow or chatty service stays out of view unless you trace each resolver's downstream calls.
+---------+ +---------+ +---------+
| Web | | Mobile | | Partner |
+----+----+ +----+----+ +----+----+
| | |
+-----------+-----------+
| queries: one schema, each
| client selects its own shape
v [1] extra network hop
+-------------------------------+
| GraphQL gateway | [2] another component:
| schema + resolvers per type | scaling, monitoring,
+-------------------------------+ on-call, governance
| | |
| | | [3] one query fans out
v v v into many REST calls
+-----------+ +-----------+ +-----------+
| Users | | Orders | | Inventory |
| REST API | | REST API | | REST API |
+-----------+ +-----------+ +-----------+
REST contracts, HTTP caching and deploys stay unchanged
[3] a slow or chatty service stays hidden unless each resolver's
downstream calls are traced
A GraphQL gateway over existing REST services gives web, mobile and partner clients one schema, but it adds a network hop and another component to operate, and its fan-out can hide slow backend services.
Impact on development cost and engineering time
Most of the cost of either style comes after launch, while the API is being maintained.
Where GraphQL costs more
A shared graph needs someone to own it. Several teams contribute types, so you need schema review, naming conventions and a clear policy for nullability and deprecation. Once more than one service owns part of the graph, federation and composition become their own platform workload, with composition checks in CI and rules for which team owns which entity. Removing a field without breaking anyone requires usage data showing which clients still request it. Cost limiting and per-field tracing are not built into the protocol. Someone has to build them and keep tuning them as query patterns change.
Where REST costs more
On the REST side, the cost grows gradually. Each new screen tends to get its own endpoint or its own backend-for-frontend (BFF), and BFFs for web, mobile and partners slowly drift apart. Breaking changes require version migrations that the server team and every consumer have to schedule together, and old versions often keep running long after they should have been retired.
Where each saves time
GraphQL saves the most time on the frontend. Teams can change their data needs without waiting for backend changes, and the typed schema supports code generation, validation and editor tooling. REST saves time on operations: existing gateways, CDNs, HTTP caches, dashboards and alerting work with little or no extra setup.
Choose based on how many different kinds of clients you serve, how much you depend on HTTP caching, and whether your platform team can take on graph governance. Payload efficiency is a weak reason on its own, because the operational and governance costs above usually outweigh it.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to graphql vs rest, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.