GraphQL vs REST for Data Access Cheat Sheet
Compares GraphQL and REST for data-fetching patterns, covering over/under-fetching, endpoint design, caching, and N+1 query pitfalls.
Request Comparison
Fetching related data with REST versus GraphQL.
// REST: multiple round trips to avoid over-fetching, or one bloated endpoint// GET /api/users/42// GET /api/users/42/posts// GET /api/posts/17/comments// GraphQL: one request, client specifies exactly the fields it needsconst query = ` query { user(id: 42) { name posts { title comments { text } } } }`;const res = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }),});
Trade-offs
How the two approaches differ in practice.
- Over-fetching- REST endpoints often return fixed shapes with more fields than the client needs; GraphQL clients request exact fields
- Under-fetching- REST may require multiple round trips to assemble related data; GraphQL resolves nested relations in a single request
- Caching- REST benefits from standard HTTP caching (ETag, Cache-Control, CDNs) keyed by URL; GraphQL POST requests need custom client-side caching (Apollo, Relay normalized cache)
- Versioning- REST typically versions endpoints (/v1, /v2); GraphQL favors additive schema evolution with field deprecation instead of versioning
- Error handling- REST uses HTTP status codes per request; GraphQL usually returns 200 with an errors array, requiring clients to check payload-level errors
- Tooling- GraphQL ships a strongly typed schema enabling introspection, codegen, and interactive explorers (GraphiQL); REST relies on external specs like OpenAPI for the same
Avoiding N+1 with DataLoader
Batch per-request lookups into a single query.
const DataLoader = require('dataloader');// Batches individual post.author lookups into one SQL query per tickconst userLoader = new DataLoader(async (userIds) => { const users = await db.query( 'SELECT * FROM users WHERE id = ANY($1)', [userIds] ); const byId = Object.fromEntries(users.map((u) => [u.id, u])); return userIds.map((id) => byId[id]);});const resolvers = { Post: { author: (post) => userLoader.load(post.authorId), },};
When to Use Which
Guidance for picking an API style for data access.
- Choose REST when- You need simple CRUD, strong HTTP caching, public/partner APIs, or your clients' data needs are uniform and stable
- Choose GraphQL when- Clients have diverse, evolving data needs (e.g., web + mobile with different field requirements), or you're aggregating multiple backend services
- Hybrid approach- Many teams expose REST for simple public endpoints and GraphQL (or a BFF) for complex, client-driven aggregation
- Team/ops cost- GraphQL requires more upfront investment: schema design, resolver N+1 mitigation, query complexity/depth limiting to prevent abuse
Persisted Queries
Ship a hash instead of the raw query string to cut payload size and block arbitrary query abuse.
// Build time: generate a manifest of query -> sha256 hash// { "a1b2c3...": "query GetUser($id: ID!) { user(id: $id) { name } }" }// Client sends only the hash, not the full query textconst res = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ extensions: { persistedQuery: { version: 1, sha256Hash: 'a1b2c3...', }, }, variables: { id: 42 }, }),});// Server: reject any request whose hash isn't in the allowlist// (APQ - Automatic Persisted Queries, or a strict allowlist for prod)if (!persistedQueryManifest.has(sha256Hash)) { throw new Error('PersistedQueryNotFound');}
Query Complexity & Depth Limiting
Reject expensive or deeply nested queries before execution to prevent denial-of-service via GraphQL.
const { createComplexityLimitRule } = require('graphql-validation-complexity');const depthLimit = require('graphql-depth-limit');const server = new ApolloServer({ schema, validationRules: [ depthLimit(7), // reject queries nested deeper than 7 levels createComplexityLimitRule(1000, { // assign per-field cost, e.g. list fields cost more scalarCost: 1, objectCost: 2, listFactor: 10, onCost: (cost) => console.log('query cost:', cost), }), ],});// Field-level cost directive in schema// type Query {// users(first: Int): [User!]! @cost(complexity: 10, multipliers: ["first"])// }
HATEOAS-Style REST Response
A REST response embedding hypermedia links so clients discover valid next actions without hardcoding URLs.
{ "id": 42, "status": "pending", "total_cents": 4999, "_links": { "self": { "href": "/api/orders/42" }, "cancel": { "href": "/api/orders/42/cancel", "method": "POST" }, "items": { "href": "/api/orders/42/items" }, "customer": { "href": "/api/customers/17" } }}
GraphQL Gotchas at Scale
Issues that only surface once GraphQL is serving real production traffic.
- HTTP caching bypass- POST-based GraphQL requests skip browser/CDN HTTP caching entirely; GET-based persisted queries with query params can restore CDN cacheability
- File uploads- The spec has no native multipart support; teams bolt on graphql-multipart-request-spec or move uploads to a separate REST/presigned-URL endpoint
- Field-level authorization drift- Because any client can request any field combination, authorization must be enforced per-resolver/per-field, not just per-endpoint like REST
- Response size explosion- A single query can traverse many-to-many relations and return megabytes of nested data; pagination (cursor-based, Relay connections) must be mandatory on list fields
- Schema stitching vs federation- Combining multiple GraphQL services requires either schema stitching (deprecated pattern) or Apollo Federation/GraphQL Federation with entity resolution across subgraphs
- Caching invalidation- Normalized client caches (Apollo, Relay) key entities by __typename + id; mutations must return the updated fields so the cache can reconcile without a full refetch
Real-Time Data with Subscriptions
GraphQL's third operation type for push-based updates, an area REST has no standard answer for.
type Subscription { orderStatusChanged(orderId: ID!): Order!}# Client (over WebSocket via graphql-ws)subscription OnOrderStatus($orderId: ID!) { orderStatusChanged(orderId: $orderId) { id status updatedAt }}# Resolver publishes to a topic-based PubSub on mutationconst resolvers = { Mutation: { updateOrderStatus: async (_, { id, status }, { pubsub }) => { const order = await db.orders.update(id, { status }); pubsub.publish(`ORDER_${id}`, { orderStatusChanged: order }); return order; }, }, Subscription: { orderStatusChanged: { subscribe: (_, { orderId }, { pubsub }) => pubsub.asyncIterator(`ORDER_${orderId}`), }, },};
GraphQL's flexibility is also its biggest operational risk — without query depth limiting, complexity analysis, and persisted queries, a single malicious or buggy client query can fan out into thousands of database calls; treat query cost limiting as a launch requirement, not an afterthought.