GraphQL vs REST in 2025: The Debate Is Over
The GraphQL vs REST debate has been running since Facebook open-sourced GraphQL in 2015. Ten years later, the answer is clear — and it is not the answer either camp expected. Neither technology won. Both survived, but they settled into distinct niches where each is clearly superior. The teams still arguing about which to use in 2025 are asking the wrong question. The right question is: what are your clients, what are your data patterns, and who maintains the API? Here is the breakdown based on six years of building both at Harbor Software.
Where REST Won Decisively
REST won for server-to-server communication. When your API consumers are backend services, CLIs, cron jobs, and third-party integrations, REST’s simplicity is an overwhelming advantage. Every programming language has an HTTP client. Every developer understands GET, POST, PUT, DELETE. Every monitoring tool, CDN, and caching layer understands HTTP semantics natively. GraphQL adds a client library dependency, a query parser, and a schema type system that server-to-server consumers neither need nor want.
We run both at Harbor Software. Our public API is REST, and our internal dashboard API is GraphQL. The public API serves 340 integration partners, including teams using curl scripts, Python requests, Go’s net/http, and PHP’s cURL extension. When we briefly considered offering a GraphQL option for the public API, we surveyed our top 50 partners. Thirty-eight said they would not use it. Their reasons were consistent: they need to call one endpoint, get one resource, and they do not want to learn a query language for that.
REST also won for APIs that need aggressive caching. HTTP caching semantics — ETag, Cache-Control, Last-Modified, conditional requests with If-None-Match — work with REST endpoints out of the box. Every CDN on the planet (Cloudflare, Fastly, CloudFront) understands how to cache a GET request to /api/products/123. GraphQL sends all requests as POST to a single endpoint, which CDNs and browsers will not cache by default. Yes, you can implement persisted queries and use GET for reads, but at that point you are reinventing REST’s caching model with extra steps and fewer tools.
# REST caching — works with every CDN, zero configuration
GET /api/v1/products/123
Cache-Control: public, max-age=300
ETag: "a1b2c3d4"
# Subsequent request — CDN serves from cache or validates with origin
GET /api/v1/products/123
If-None-Match: "a1b2c3d4"
# 304 Not Modified — no data transfer, instant response
# GraphQL equivalent — requires persisted queries, CDN configuration,
# and GET-based query execution for any caching to work
GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123"}}
# Most teams never set this up
The numbers back this up. In our production environment, the REST API serves 67% of requests from Cloudflare’s edge cache with zero origin traffic. The equivalent data served through our GraphQL API has a 0% CDN cache hit rate because the queries are too diverse to cache effectively at the edge. That translates to roughly $2,100/month in saved origin compute costs for the REST API.
REST also won for discoverability and documentation. The OpenAPI (Swagger) ecosystem is mature, well-tooled, and universally understood. You can generate client SDKs in 40+ languages from an OpenAPI spec. Documentation tools like Redoc and Swagger UI produce interactive API references without any custom work. GraphQL’s introspection and documentation tools (GraphiQL, Apollo Studio) are good but only useful if the consumer is already committed to using GraphQL. For a potential partner evaluating whether to integrate with your API, a REST API with interactive Swagger docs is immediately accessible; a GraphQL API requires explaining what GraphQL is first.
Where GraphQL Won Decisively
GraphQL won for client-driven applications with complex data requirements. When your consumer is a web or mobile app that needs to fetch data from multiple domains in a single request, compose views from nested relationships, and minimize data transfer over mobile networks, GraphQL’s query model is genuinely superior.
Our internal dashboard demonstrates this. A single dashboard page displays user information, their subscription status, recent API usage, active models, and team members. With REST, this requires 5 sequential or parallel HTTP requests:
# REST approach — 5 requests, potential waterfall on mobile
GET /api/v1/users/456
GET /api/v1/users/456/subscription
GET /api/v1/users/456/usage?period=30d
GET /api/v1/users/456/models?status=active
GET /api/v1/teams/789/members
With GraphQL, it is one request that returns exactly the fields the UI needs:
query DashboardView($userId: ID!) {
user(id: $userId) {
name
email
createdAt
subscription {
plan
status
renewsAt
usage {
apiCalls
computeMinutes
storageGb
}
}
models(status: ACTIVE, first: 10) {
edges {
node {
id
name
lastDeployedAt
requestsToday
}
}
}
team {
members(first: 20) {
edges {
node {
name
role
avatarUrl
}
}
}
}
}
}
That single query replaced 5 REST calls and reduced payload size by 62% because it fetches only the fields the UI renders. On mobile networks with 200ms round-trip latency, eliminating 4 round trips saves 800ms of user-visible loading time. This is not a theoretical improvement — we measured it. Dashboard load time dropped from 1,400ms to 380ms after switching from REST to GraphQL.
GraphQL also won for teams where frontend and backend engineers have different release cadences. The schema serves as a contract that both sides agree on, and frontend engineers can request exactly the data they need without waiting for backend engineers to build custom endpoints. Our frontend team adds new fields to their queries without any backend deployment — as long as the field exists in the schema, it works. This reduced our cross-team coordination overhead by roughly 30% measured in meeting time and Slack thread length.
A less-discussed advantage of GraphQL: it discourages the “endpoint proliferation” problem that REST APIs suffer from. After two years of REST API development, we had 147 endpoints, 23 of which were variations like /users/{id}/summary, /users/{id}/detailed, and /users/{id}/with-subscription — each created because a specific frontend view needed a different subset of user data. GraphQL eliminates this entirely because the client specifies exactly which fields it needs. This reduced our API surface area by about 35% when we migrated the internal API to GraphQL.
The N+1 Problem Is Real (And Solvable)
The most legitimate criticism of GraphQL is the N+1 query problem. When a client requests a list of users with their posts, a naive resolver implementation fires one database query for the user list and then N additional queries to fetch posts for each user. This is devastating for performance at scale.
The solution is DataLoader, and every production GraphQL server must use it. DataLoader batches and deduplicates database queries within a single request execution cycle:
// Without DataLoader — N+1 problem
const resolvers = {
User: {
posts: async (user) => {
// Called once per user in the list — N queries
return db.posts.findMany({ where: { authorId: user.id } });
}
}
};
// With DataLoader — batched to 1 query
import DataLoader from 'dataloader';
function createLoaders() {
return {
postsByAuthor: new DataLoader(async (authorIds: readonly string[]) => {
// Single query for ALL authors
const posts = await db.posts.findMany({
where: { authorId: { in: [...authorIds] } }
});
// Return posts grouped by author, in the same order as authorIds
const postsByAuthor = new Map<string, Post[]>();
for (const post of posts) {
const existing = postsByAuthor.get(post.authorId) || [];
existing.push(post);
postsByAuthor.set(post.authorId, existing);
}
return authorIds.map(id => postsByAuthor.get(id) || []);
})
};
}
const resolvers = {
User: {
posts: (user, _args, context) => {
// Batched — all users' posts fetched in 1 query
return context.loaders.postsByAuthor.load(user.id);
}
}
};
DataLoader is not optional. It is required infrastructure for any GraphQL server that serves list queries with relationships. If your GraphQL server does not use DataLoader (or an equivalent batching mechanism like Prisma’s built-in query batching), you will have N+1 performance problems. Every major GraphQL framework — Apollo Server, GraphQL Yoga, Mercurius — documents DataLoader integration, and most provide helpers to create request-scoped loader instances automatically.
Beyond DataLoader, we also use query complexity analysis to prevent abusive queries that would trigger enormous database loads. A query that requests users -> posts -> comments -> replies -> users (nested 5 levels deep) could generate thousands of database queries even with DataLoader. We set a complexity budget of 1,000 points per query, where each field costs 1 point, each list multiplies the cost by its expected size (defaulting to 10), and each level of nesting multiplies by an additional factor. Queries exceeding the budget are rejected before execution with a clear error message explaining how to simplify the query.
The Security Model Is Fundamentally Different
REST and GraphQL have fundamentally different security attack surfaces. REST endpoints are individually secured — you set permissions on GET /api/users and POST /api/admin/settings separately. The attack surface is enumerable: you can list all endpoints and verify their authorization rules.
GraphQL exposes a single endpoint with an introspectable schema that reveals your entire data model. A malicious client can craft deeply nested queries that cause exponential resolver execution, or wide queries that return massive payloads. You need three security mechanisms that REST does not require:
// 1. Query depth limiting
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
schema,
validationRules: [depthLimit(7)] // Reject queries deeper than 7 levels
});
// 2. Query complexity analysis
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const complexityLimit = createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10 // Lists multiply cost by their expected size
});
// 3. Persisted queries in production (only allow pre-registered queries)
import { ApolloServerPluginPersistedQueries } from '@apollo/server/plugin/persistedQueries';
const server = new ApolloServer({
plugins: [
ApolloServerPluginPersistedQueries({
enforcePersistedQueries: process.env.NODE_ENV === 'production'
})
]
});
Persisted queries deserve special emphasis. In production, we only allow queries that were registered during the build process. This means a client cannot send arbitrary queries — they can only execute queries that exist in the codebase and were extracted during compilation. This eliminates the entire class of query-based attacks (depth attacks, complexity attacks, introspection-based reconnaissance) at the cost of requiring a build step to register new queries.
Authorization in GraphQL also requires a different approach than REST. In REST, you authorize at the endpoint level: “can this user access GET /api/admin/settings?” In GraphQL, you authorize at the field level: “can this user read the email field on the User type?” This is more granular but more complex to implement. We use a directive-based authorization system:
type User {
id: ID!
name: String!
email: String! @auth(requires: SELF_OR_ADMIN)
subscription: Subscription @auth(requires: SELF_OR_ADMIN)
internalNotes: String @auth(requires: ADMIN)
}
type Query {
users: [User!]! @auth(requires: ADMIN)
user(id: ID!): User @auth(requires: AUTHENTICATED)
}
Every field with sensitive data gets an @auth directive. The directive resolver checks the requesting user’s role against the required role before the field resolver executes. Fields that fail authorization are returned as null with an error in the errors array. This approach is more work upfront than REST’s endpoint-level authorization, but it provides finer-grained access control that is defined declaratively in the schema rather than scattered across middleware and route handlers.
Performance Monitoring: Different Tools for Different APIs
The operational characteristics of REST and GraphQL APIs differ in ways that affect your monitoring, alerting, and debugging workflows. REST APIs are straightforward to monitor because each endpoint is a distinct entity with its own latency distribution, error rate, and throughput metric. You can set up alerts per endpoint: “alert if GET /api/products p95 latency exceeds 200ms” or “alert if POST /api/orders error rate exceeds 1%.” Every APM tool on the market supports this out of the box.
GraphQL monitoring is harder because all requests hit a single endpoint. A latency spike on POST /graphql tells you nothing about which query or which resolver is slow. You need resolver-level tracing, which requires instrumenting every resolver function with timing data and propagating trace context through the resolver tree. Apollo Server provides this through its tracing plugin, and GraphQL Yoga has similar OpenTelemetry integration, but the setup and data volume are significantly greater than REST endpoint monitoring.
We instrument our GraphQL resolvers with OpenTelemetry spans that capture resolver name, parent type, return type, and execution time. This generates roughly 15x more trace spans per request than our REST API (because a single GraphQL query might invoke 40+ resolvers), which increases our observability costs. The tradeoff is worth it for our internal dashboard API where debugging speed matters, but it would not be worth it for a high-throughput public API where the monitoring cost per request needs to be minimal.
Error handling also differs fundamentally. REST uses HTTP status codes that every tool understands: 4xx for client errors, 5xx for server errors. Load balancers, CDNs, and monitoring tools all use status codes for routing, caching, and alerting decisions. GraphQL always returns 200 OK (even when individual resolvers fail) and reports errors in the response body’s errors array. This means your load balancer cannot distinguish between successful and failed GraphQL requests based on status code, your CDN cannot detect errors for cache invalidation, and your monitoring tools need custom configuration to parse GraphQL error payloads. We had a 45-minute incident where our GraphQL API was returning errors for 30% of requests, but our monitoring showed 100% success rate because it was tracking HTTP status codes, all of which were 200.
Rate limiting follows the same pattern. REST rate limiting is typically per-endpoint: 100 requests per minute to GET /api/users, 10 requests per minute to POST /api/deployments. This lets you protect expensive endpoints with stricter limits while allowing high throughput on cheap endpoints. GraphQL rate limiting requires query complexity analysis because all requests hit the same endpoint. A simple query that fetches a user’s name costs almost nothing, while a deeply nested query that traverses relationships across the entire data model could trigger thousands of database queries. Without complexity-based rate limiting, a single malicious or poorly written query can consume disproportionate server resources.
The Migration Path: REST to GraphQL and Back
If you are considering migrating between REST and GraphQL, the direction matters. Migrating from REST to GraphQL is generally additive: you build a GraphQL layer on top of your existing REST endpoints (or directly on your data layer), and clients migrate at their own pace. The REST API continues to work. We used Apollo Federation to compose our GraphQL schema from multiple backend services, each of which continued to expose its REST API for non-GraphQL consumers.
Migrating from GraphQL to REST is harder because you need to decompose a flexible query interface into fixed endpoints. Every distinct query pattern in your client code becomes a potential REST endpoint. If your client code has 50 unique queries, you might need 30-40 REST endpoints (some queries can be combined into parameterized endpoints). This is why getting the initial choice right matters — not because migration is impossible, but because it is expensive in engineering time and client-side code changes.
A hybrid approach that works well for teams in transition: keep your GraphQL API for internal clients that benefit from flexible querying, and build a “facade” REST API that translates REST requests into GraphQL queries internally. The REST endpoints become thin wrappers that construct a fixed GraphQL query, execute it against the GraphQL server, and transform the response into a REST-shaped response. This gives external consumers the simplicity of REST while the internal implementation benefits from GraphQL’s unified data layer. We use this pattern for 12 of our public API endpoints, and external developers have no idea that GraphQL is involved behind the scenes.
The Real Decision Framework
After building and maintaining both REST and GraphQL APIs for six years, here is the decision framework we use at Harbor Software:
Use REST when:
- Your consumers are other backend services, scripts, or third-party integrations
- Your API is public-facing and needs to be accessible from any language without a client library
- Caching at the CDN/HTTP level is important for your performance and cost model
- Your data model is simple (CRUD operations on discrete resources)
- You need to support webhooks (webhooks are inherently REST-shaped)
- Your documentation needs to be immediately understandable by external developers
Use GraphQL when:
- Your primary consumers are web or mobile applications that you control
- The UI needs to compose data from multiple domains in a single view
- Minimizing data transfer matters (mobile networks, metered connections)
- Frontend and backend teams need to iterate independently on data requirements
- Your data model has deep relationships that clients need to traverse flexibly
- You are seeing endpoint proliferation in your REST API to serve different view requirements
Use both when:
- You have both internal applications and external API consumers — REST for external, GraphQL for internal
- Different parts of your product have different data access patterns
The debate is over because the answer was never one or the other. It was always about matching the technology to the consumer. The teams that are thriving in 2025 use both, each where it fits. The teams that are struggling picked one dogmatically and are fighting the mismatches every day.