GraphQL vs REST in 2026: When to Use Each API Style
Arvucore Team
September 21, 2025 · Updated August 26, 2026
13 min read
Choose REST when your API exposes stable, resource-shaped data to many consumers you do not control, and when HTTP caching matters. Choose GraphQL when a handful of first-party clients (web, iOS, Android) need different slices of the same data graph and you want to stop shipping a new endpoint for every screen. Most companies end up with both: REST at the edge for partners, GraphQL as an aggregation layer for their own apps.
How GraphQL and REST model data and requests
REST models the world as resources with URLs. The HTTP verb carries the intent (GET, POST, PUT/PATCH, DELETE), and the server decides the shape of each response. Clients compose screens by calling several endpoints and stitching the results.
GraphQL models the world as a typed graph described by a schema. There is one endpoint (conventionally POST /graphql), and the client sends a query that names exactly the fields it wants, including nested relations. The server resolves each field and returns JSON that mirrors the query.
The practical consequence: in REST the server owns the response shape. In GraphQL the client owns it, within the limits of the schema, so frontend teams compose queries without a backend ticket. That shift in ownership is the real reason teams pick GraphQL, and the reason it needs more guardrails.
Over-fetching and under-fetching
Over-fetching is receiving fields you do not need: a GET /users/42 that returns forty fields when a list row shows two. Under-fetching is the opposite: the endpoint does not return enough, so a profile screen that needs the user, their last five orders and each shipment status turns into one call plus N plus M.
REST mitigates both with sparse fieldsets (?fields=id,name), embedded resources (?include=orders) and purpose-built endpoints (/users/42/profile-summary). These work, but every new combination is a backend change.
GraphQL solves both structurally: one query, one round trip, only the requested fields. On a mobile network with hundreds of milliseconds per round trip, collapsing three sequential calls into one is a visible win. On a fast internal network it matters much less, which is why GraphQL rarely pays off for service-to-service traffic.
Caching: HTTP semantics vs client caches
This is where REST has a structural advantage. A GET with a stable URL is cacheable by every layer that already exists: browser cache, CDN, reverse proxy, API gateway. Cache-Control and ETag are understood everywhere. If your traffic is read-heavy and public (catalogs, content, pricing), REST plus a CDN is hard to beat on cost. The trade-offs of each layer are covered in caching strategies with Redis, Memcached and CDNs.
GraphQL by default sends POST requests with the query in the body, which intermediaries will not cache. Three techniques recover most of what is lost:
- Persisted queries. The client registers each query at build time and sends only a hash (
GET /graphql?extensions={"persistedQuery":...}). The request becomes a stable, cacheable GET, and the server rejects unknown queries, which doubles as a security control. - Normalized client caches. Apollo Client, Relay and urql store every returned object by type and id. A mutation that returns the updated
Product:42refreshes every screen showing it. REST clients get this only with extra work in TanStack Query or SWR. - Response and resolver caches. A server-side response cache keyed by operation hash plus variables, and per-field
@cacheControlhints that compute the max-age of the whole response from its least cacheable field.
The honest summary: REST caching is free and coarse; GraphQL caching is precise and needs setup. If your team will not do the setup, REST wins.
Versioning and evolution
REST APIs usually version in the URL (/v2/orders) or a header. A new major version means running two implementations in parallel until clients migrate. It is explicit, and it is also why many public REST APIs stay on v1 for years with additive changes only.
GraphQL discourages versioning. The schema evolves in place: add a field, mark the old one @deprecated(reason: "Use totalCents"), watch field-level usage in a schema registry and remove it when usage hits zero. Because clients declare exactly which fields they use, you know who a removal affects before you make it. This works for first-party clients you can update, and less well for third parties who ship a query once and never look again, which is one more reason to keep REST at the public edge.
Either way, contract-first pays off: an OpenAPI document or a GraphQL SDL file checked into the repo, with a diff check in CI that blocks breaking changes.
Errors and status codes
REST leans on HTTP status: 404, 403, 422, 429, 503. Proxies, SDKs and monitoring understand these without configuration, and RFC 9457 (Problem Details) standardizes the error body.
GraphQL returns 200 for almost everything and puts problems in an errors array next to a partial data object. One request can succeed for user and fail for user.orders. Partial success suits dashboards, but it breaks tools that key on status codes. In practice:
- Reserve transport-level codes (
400,401,429,5xx) for malformed requests, auth and infrastructure. - Put domain errors in the schema as typed results (
union CheckoutResult = Order | InsufficientStock | PaymentDeclined) so clients handle them with types rather than parsing message strings. - Use
extensions.codefor machine-readable error classes and log full context server-side, as described in error handling and logging strategies.
The N+1 problem and DataLoader
A naive GraphQL server resolves fields one at a time. A query for 50 orders with their customer calls customer 50 times, producing 51 database queries. This N+1 problem is the most common reason a GraphQL backend benchmarks worse than the REST it replaced.
The fix is batching per request. DataLoader (the reference implementation from the GraphQL project, with ports in every major language) collects all customer.load(id) calls made during one tick of execution, issues a single WHERE id IN (...), and caches results for the life of the request:
const customerLoader = new DataLoader(async (ids: readonly string[]) => {
const rows = await db.customers.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => byId.get(id) ?? null);
});
// resolver
Order: { customer: (order, _, ctx) => ctx.loaders.customer.load(order.customerId) }
Loaders must be created per request, never shared, or one user's cached row leaks into another's response. REST has the same problem inside a handler that loops over rows, but it is easier to see in one function than spread across resolvers.
Security: complexity, depth, introspection
REST security is per route: scopes, rate limits and WAF rules attach to POST /orders, and an API gateway enforces most of it. GraphQL has one route, so controls move into the execution layer:
- Depth limits. Reject queries nested beyond, say, 8 levels.
- Complexity scoring. Assign a cost per field, multiply by list sizes (
first: 100), and reject or throttle above a budget. Charge rate limits by cost, not by request count. - Persisted queries only in production. Unknown query strings are rejected, so an attacker cannot craft a pathological query at all.
- Introspection off for anonymous users. It powers explorers, and it is also a full map of your schema. Keep it on internally and gated at the public edge.
- Field-level authorization in resolvers or directives (
@auth(requires: ADMIN)), because a query can reach any type from any entry point. - Batching limits. Cap arrays of operations, or one HTTP request can carry a thousand logins.
Authentication itself is the same for both: OAuth 2.0 or OIDC at the edge, short-lived tokens, as described in modern authentication with OAuth 2.0, JWT and zero trust.
Tooling and ecosystem
REST tooling is universal: OpenAPI for contracts, generated clients in any language, Postman or Bruno for exploration, curl for debugging, and every observability product speaks routes and status codes. FastAPI, NestJS and Spring generate the OpenAPI document from code.
GraphQL tooling is narrower but deeper. The schema is executable documentation: GraphiQL and Apollo Sandbox give autocomplete and inline docs from introspection, and GraphQL Code Generator produces typed hooks and clients, so a renamed field fails the frontend build instead of production. Mature servers exist in every stack: Apollo Server, GraphQL Yoga and Mercurius (Node), graphql-java and Spring for GraphQL (JVM), Hot Chocolate (.NET), Strawberry (Python), gqlgen (Go). Managed options such as Hasura, AWS AppSync and Apollo GraphOS trade control for speed. For observability, GraphQL needs metrics per named operation rather than per route; OpenTelemetry instrumentation exists for every major server, but you have to turn it on.
Federation vs BFF
When several teams own several services, two architectures put GraphQL in front of them. Federation (Apollo Federation, or the open GraphQL Composite Schemas spec) lets each team publish a subgraph that owns its types; a router composes them into one supergraph and plans cross-subgraph joins via @key fields. It scales organizationally, but it adds a router, a schema registry and composition rules, and someone has to own the platform.
Backend for Frontend is simpler: one GraphQL (or REST) service per client type, owned by the frontend team, that calls internal REST and gRPC services and shapes the result. No composition, no registry; duplication across BFFs is the price of independence.
Choose federation when many teams contribute to one graph consumed by many apps. Choose a BFF when one or two frontend teams need aggregation and backend teams do not want to learn GraphQL. Both sit naturally on top of a microservices architecture; neither is worth it on a monolith with one client.
Alternatives worth naming: tRPC and gRPC
tRPC gives end-to-end type safety with no schema and no code generation: the server exports a router of typed procedures and the client imports the type. It only works when client and server are TypeScript in the same repository, and it is not a public API format. For a Next.js or React Native app with a TypeScript backend, it removes most of the reason to adopt GraphQL.
gRPC uses Protocol Buffers over HTTP/2 with generated clients in most languages. It is fast, strongly typed and streams natively, which makes it the default for internal service-to-service calls. Browser support requires gRPC-Web or Connect, and it is uncommon as a public API. The usual pattern is gRPC between services with REST or GraphQL on top.
Comparison table
| Criterion | REST | GraphQL |
|---|---|---|
| Contract | OpenAPI (optional, external) | SDL schema (mandatory, introspectable) |
| Response shape | Server-defined per endpoint | Client-defined per query |
| Over/under-fetching | Common; mitigated with fieldsets and includes | Solved by design |
| Round trips for nested data | One per resource | One per screen |
| HTTP caching | Native (Cache-Control, ETag, CDN) |
Needs persisted queries as GET |
| Client cache | Per URL, via libraries | Normalized by type and id |
| Versioning | URL or header, parallel versions | Field deprecation, usage-driven removal |
| Errors | HTTP status codes, Problem Details | errors array, partial data, typed results |
| N+1 risk | Inside handlers, easy to spot | Across resolvers, needs DataLoader |
| Security model | Per route at the gateway | Depth, complexity, persisted queries, introspection control |
| File upload | Native multipart | Multipart spec or separate REST endpoint |
| Real-time | SSE or WebSockets alongside | Subscriptions built in |
| Learning curve | Low; universal | Moderate; new concepts for backend |
| Best for | Public APIs, cacheable resources, simple CRUD | Multiple first-party clients, aggregation, fast UI iteration |
When to choose which: checklist by scenario
Public API for partners and third parties
- REST with OpenAPI. Consumers know it, SDKs generate cleanly, and CDN caching keeps cost predictable.
- Add GraphQL only if partners ask for it, behind persisted queries and complexity budgets.
Mobile app (iOS and Android) plus web
- GraphQL if screens aggregate several resources and the three clients need different fields. One query per screen, a normalized cache and generated types pay off quickly.
- REST if the app is mostly forms and lists over a few resources.
Internal microservices, service to service
- gRPC, or REST if the team wants HTTP debuggability. GraphQL adds resolver overhead without the round-trip benefit on a fast network.
- If clients need a unified read model, put federation or a BFF above the services rather than making each one speak GraphQL.
Admin dashboards and internal tools
- GraphQL, especially with Hasura or a similar engine over the database. Dashboards change constantly, need arbitrary joins and filtering, and partial-error rendering fits them.
- tRPC if the whole stack is TypeScript in one repo and there is exactly one client.
Signals that point to REST regardless of scenario
- Most traffic is anonymous reads of the same data.
- You must support file uploads and downloads as first-class operations.
- The team has no GraphQL experience and the deadline is short.
Signals that point to GraphQL regardless of scenario
- Frontend waits on backend for "just one more field" every sprint.
- Three or more client applications read the same domain.
- You need field-level usage data to deprecate safely.
Minimal example: one schema, one query, the REST equivalent
A small e-commerce read model:
type Query {
customer(id: ID!): Customer
}
type Customer {
id: ID!
name: String!
email: String!
orders(first: Int = 10): [Order!]!
}
type Order {
id: ID!
total: Money!
placedAt: DateTime!
items: [OrderItem!]!
shipment: Shipment
}
type OrderItem { sku: String! quantity: Int! product: Product! }
type Product { id: ID! name: String! imageUrl: String }
type Shipment { carrier: String! status: ShipmentStatus! eta: DateTime }
enum ShipmentStatus { PENDING IN_TRANSIT DELIVERED }
scalar Money
scalar DateTime
The query behind an "order history" screen:
query OrderHistory($id: ID!) {
customer(id: $id) {
name
orders(first: 5) {
id
total
placedAt
shipment { status eta }
items { quantity product { name imageUrl } }
}
}
}
One request, one response, only the fields the screen renders. With persisted queries it is sent as a GET and cached like any other GET.
The same screen against a conventional REST API:
GET /customers/42
GET /customers/42/orders?limit=5
GET /orders/1001/shipment (x5, one per order)
GET /products?ids=SKU-1,SKU-2,SKU-9
That is eight requests in three sequential waves, or two if the API offers GET /customers/42?include=orders.shipment,orders.items.product, which is the REST answer once the backend agrees to maintain that include grammar. Both work. The question is which team you want owning the shape of the response.
Recommendation
Default to REST for anything exposed outside the company and for service-to-service traffic, where gRPC is the other candidate. Adopt GraphQL when at least two first-party clients need different views of a shared data graph, and commit to the package that comes with it: DataLoader, persisted queries, depth and complexity limits, introspection control and a schema registry. If the stack is TypeScript end to end with one client, evaluate tRPC first. At Arvucore we usually recommend REST at the edge and a single GraphQL BFF for product apps, adding federation only when the number of teams contributing to the graph makes the BFF a bottleneck.
Ready to Transform Your Business?
Let's discuss how our solutions can help you achieve your goals. Get in touch with our experts today.
Talk to an ExpertTags:
Arvucore Team
Arvucore’s editorial team is formed by experienced professionals in software development. We are dedicated to producing and maintaining high-quality content that reflects industry best practices and reliable insights.
Frequently asked questions
- Is GraphQL faster than REST?
- Not by itself. GraphQL reduces round trips and payload size for nested, client-specific data, which helps on slow mobile networks. REST is usually faster and cheaper for simple, cacheable resources because CDNs and browsers cache GET responses without extra work.
- When should I use GraphQL instead of REST?
- Use GraphQL when many different clients need different views of the same data, when screens aggregate several backend services, or when the frontend team changes data needs faster than the backend can ship endpoints. Otherwise REST is the simpler default.
- Can GraphQL and REST be used together?
- Yes, and it is common. A typical setup exposes REST to partners and the public, and runs a GraphQL layer (a gateway or BFF) over internal REST and gRPC services for first-party web and mobile apps.
- Does GraphQL replace API versioning?
- It replaces URL versioning with field-level evolution: you add fields, mark old ones with @deprecated, and remove them after client usage drops to zero. You still need a schema registry and usage tracking to do that safely.
- Is GraphQL less secure than REST?
- It has a different attack surface. A single endpoint that accepts arbitrary queries needs depth limits, complexity scoring, persisted queries in production and introspection disabled for anonymous users. With those controls it is as secure as a well-built REST API.
- What about tRPC or gRPC instead of GraphQL or REST?
- tRPC fits a TypeScript monorepo where the same team owns client and server. gRPC fits internal service-to-service calls where binary performance and generated clients matter. Neither is a good fit for a public API consumed by third parties.
Related articles

Hexagonal Architecture in 2026: Ports, Adapters, Clean Arch
Hexagonal vs Clean vs Onion architecture explained with a worked TypeScript example, folder structure, testing strategy and the mistakes that sink most teams.

API Gateway: Enterprise API Management and Security
An API gateway is central to enterprise API management and security, providing routing, policy enforcement, and observability for distributed systems. This article from Arvucore explores practical api gateway implementation approaches, governance and lifecycle strategies, and robust microservices security patterns. It helps business and technical leaders evaluate trade-offs, reduce integration risk, and accelerate secure API adoption across complex IT landscapes.

Software Architecture: Domain-Driven Design in Practice
Domain-driven design (DDD) offers a pragmatic approach to modeling complex business domains within software architecture. This article from Arvucore explains practical DDD implementation strategies, patterns and trade-offs for teams facing complex software architecture challenges. It guides technical leaders and decision makers through bounded contexts, tactical patterns, and organizational alignment to deliver maintainable, business-aligned systems today.

Caching Strategies in 2026: Redis vs Memcached vs CDN
Cache layers, cache-aside vs write-through, stampede protection, a Redis vs Memcached comparison table, and CDN caching rules with a decision checklist.