Senior 8 min · March 05, 2026

GraphQL vs REST — N+1 Query That Took Down a Product Page

When a GraphQL product page slowed from 200ms to 8 seconds due to N+1 queries, the DataLoader fix proved crucial—and REST avoids the problem entirely..

N
Naren Founder & Principal Engineer

20+ years shipping large-scale distributed systems. Notes here come from systems that actually shipped.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • REST organizes APIs around resources and HTTP verbs — predictable, cacheable, and universal.
  • GraphQL lets clients declare exact data shapes — solves overfetching and underfetching but adds complexity.
  • Choose REST when you have few clients, simple CRUD, or need aggressive HTTP caching.
  • Choose GraphQL for multiple client types, rapid iteration, or mobile-heavy apps.
  • The hybrid pattern — GraphQL at the edge, REST internally — is what senior teams actually run in production.
✦ Definition~90s read
What is GraphQL vs REST?

GraphQL and REST are two fundamentally different API paradigms that solve different problems, despite being frequently compared as alternatives. REST is an architectural style built around resources, HTTP verbs, and stateless operations — it works well when you have predictable data shapes and can benefit from HTTP caching at the CDN or browser level.

Imagine you're at a restaurant.

GraphQL, by contrast, is a query language and runtime that lets clients request exactly the data they need from a single endpoint, eliminating overfetching and underfetching at the cost of shifting complexity to the server. The N+1 query problem — where fetching a list of items triggers one query for the list and N additional queries for each item's related data — is a classic REST pain point that GraphQL's batching and dataloader patterns can solve, but GraphQL introduces its own N+1 risks if resolvers aren't carefully designed.

In practice, these technologies are not interchangeable because they optimize for different constraints. REST excels in public-facing APIs where caching is critical, operations are CRUD-heavy, and clients have stable data requirements — think Stripe's API or GitHub's v3 REST API.

GraphQL shines in complex, data-dense UIs like Facebook's news feed or Shopify's storefront, where multiple data sources must be composed and mobile clients need to minimize payload size. The real-world decision isn't about picking one over the other; it's about understanding that REST gives you predictable performance and cacheability, while GraphQL gives you flexibility and developer velocity at the cost of more complex monitoring and security.

The hybrid architecture — a GraphQL gateway sitting in front of REST microservices — has become the pragmatic default for many teams. Companies like Netflix, Airbnb, and GitHub (with their v4 API) use this pattern: the GraphQL layer handles query parsing, batching, and field-level authorization, while delegating to well-tested REST services that handle caching, rate limiting, and data persistence.

This approach lets you get the client-side benefits of GraphQL without rewriting your entire backend, though it introduces latency overhead from the gateway and requires careful resolver design to avoid cascading failures. Production considerations like persisted queries (Apollo's automatic persisted queries), query cost analysis (GitHub's node-count limits), and response caching (using CDNs with GET-based queries) are non-negotiable for any GraphQL deployment at scale.

Plain-English First

Imagine you're at a restaurant. With REST, there's a fixed menu — you order the 'burger meal' and you get a burger, fries, AND a drink, even if you only wanted the burger. With GraphQL, you tell the waiter exactly what you want: 'just the burger, no fries, add extra cheese.' REST gives you predefined plates; GraphQL lets you build your own plate every time. Neither is wrong — it just depends on whether your customers always want the same thing.

Every modern app lives or dies by its API. Whether you're building a mobile app that has to load fast on a 3G connection in rural Brazil, or a dashboard that needs to stitch together data from five different services, the API architecture you choose on day one will haunt you — or reward you — for years. GraphQL and REST are the two dominant players in that space, and the choice between them is one of the most hotly debated decisions in backend engineering.

REST has been the industry standard for over two decades. It's predictable, HTTP-native, and every developer on earth has used it. But as UIs grew more complex and mobile clients became first-class citizens, REST started showing its cracks. Teams were hitting endpoints and getting mountains of data they didn't need, or worse, firing off six requests just to render a single screen. GraphQL was Facebook's answer to that pain, built internally in 2012 and open-sourced in 2015.

By the end of this article you'll be able to clearly explain the structural difference between REST and GraphQL, identify the specific scenarios where each one wins, spot the classic mistakes teams make when choosing between them, and walk into a system design interview with a confident, nuanced answer instead of a vague 'it depends.'

Why GraphQL and REST Are Not Interchangeable

GraphQL and REST are both API query paradigms, but they differ fundamentally in how they fetch data. REST exposes a set of endpoints, each returning a fixed structure. GraphQL exposes a single endpoint and lets the client specify exactly which fields it needs. This sounds like a minor difference, but it changes the entire data-fetching model from server-driven to client-driven.

In practice, REST works well when the client's data needs align with the server's predefined resources. GraphQL shines when clients have varied or nested data requirements — it eliminates over-fetching (getting 20 fields when you need 3) and under-fetching (needing multiple round trips to assemble a view). However, GraphQL shifts complexity to the server: a single query can trigger dozens of database calls if resolvers are naive, leading to the infamous N+1 problem.

Use REST when your API is simple, cache-friendly, and clients are predictable. Use GraphQL when you have multiple clients (web, mobile, IoT) with divergent needs, or when you need to aggregate data from multiple services. The choice isn't about which is 'better' — it's about which trade-offs your system can absorb.

The N+1 Trap
A single GraphQL query can trigger N+1 database queries per nested field. Without batching (e.g., DataLoader), a product page with 10 reviews and 5 comments each can explode into 1 + 10 + 50 = 61 queries.
Production Insight
E-commerce product page serving 1000+ SKUs: a GraphQL query for product details, reviews, and inventory triggers 1 query for products, then N queries for reviews, then M queries for inventory per review — total queries = 1 + N + N*M. Symptom: page load time jumps from 200ms to 8s under moderate load. Rule: always batch nested resolvers with DataLoader or equivalent; never let a resolver call the database directly.
Key Takeaway
GraphQL eliminates over-fetching but introduces N+1 query risk; REST avoids N+1 but forces over-fetching.
Always profile GraphQL resolvers in production — a single 'innocent' query can hammer your database.
Choose REST for cache-heavy, read-dominated APIs; choose GraphQL for complex, client-driven UIs with multiple data sources.
GraphQL vs REST: N+1 Query Breakdown THECODEFORGE.IO GraphQL vs REST: N+1 Query Breakdown How overfetching and N+1 queries impact product page performance REST API Call Multiple endpoints, fixed data shape Overfetching Problem Unused fields waste bandwidth N+1 Query Issue Sequential requests per resource GraphQL Query Single endpoint, precise data Resolver Batching DataLoader coalesces requests Optimized Product Page Reduced latency and payload ⚠ GraphQL can still cause N+1 without batching Always use DataLoader or similar to batch and cache THECODEFORGE.IO
thecodeforge.io
GraphQL vs REST: N+1 Query Breakdown
Graphql Vs Rest

How REST Actually Works — and Where It Starts to Break

REST (Representational State Transfer) organizes your API around resources. A resource is a noun — a User, an Order, a Product. You expose that resource at a URL, and HTTP verbs tell the server what to do with it: GET to read, POST to create, PUT/PATCH to update, DELETE to remove. This mapping is clean, intuitive, and stateless by design.

The trouble starts when your UI needs don't map neatly onto individual resources. Say you're building a Twitter-style profile page. You need the user's name and avatar, their last three tweets, and their follower count. In REST, that's three separate endpoints: GET /users/:id, GET /users/:id/tweets, GET /users/:id/followers. Three round trips. On mobile, each round trip is expensive.

The other side of that coin is overfetching. GET /users/:id might return 40 fields — date of birth, billing address, preferences, internal flags — when your profile page only needs four of them. You're downloading data you'll throw away, every single time.

These two problems — underfetching (too few fields, too many requests) and overfetching (too many fields, wasted bandwidth) — are not bugs in REST. They're structural consequences of organizing an API around fixed resource shapes instead of around what the client actually needs.

rest_profile_page_requests.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Scenario: Render a user profile page using a typical REST API.
# We need: user name + avatar, their last 3 tweets, follower count.
# That means 3 separate HTTP requests.

# --- Request 1: Get the user's basic info ---
# Returns ~40 fields. We only need 'name' and 'avatar_url'.
curl https://api.example.com/users/42
# Response (truncated to show the overfetching problem):
# {
#   "id": 42,
#   "name": "Maya Patel",           <-- we need this
#   "avatar_url": "https://...",    <-- we need this
#   "email": "maya@example.com",    <-- not needed on this page
#   "date_of_birth": "1990-03-15",  <-- not needed on this page
#   "billing_address": {...},        <-- definitely not needed
#   "account_flags": [...],          <-- internal — should not even be here!
#   ... 34 more fields
# }

# --- Request 2: Get the user's recent tweets ---
# A separate round trip just to get 3 tweets.
curl https://api.example.com/users/42/tweets?limit=3
# Response:
# [ { "id": 101, "body": "...", "likes": 14 }, ... ]

# --- Request 3: Get follower count ---
# Yet another round trip for a single number.
curl https://api.example.com/users/42/followers/count
# Response:
# { \"follower_count\": 8321 }\n\n# TOTAL: 3 HTTP requests, ~40 wasted fields downloaded and discarded.\n# On a slow mobile connection, these requests are sequential or require\n# client-side orchestration to run in parallel \u2014 adding code complexity.",
        "output": "3 HTTP round trips fired to render one screen.\nResponse body for /users/42 is ~2.1 KB. Actual data used: ~180 bytes.\nOverfetch ratio: ~11x more data downloaded than needed."
      }

How GraphQL Solves Overfetching — and the Trade-offs It Introduces

GraphQL flips the model on its head. Instead of the server deciding what data a response contains, the client declares exactly what it needs in a query. The server exposes a single endpoint (typically POST /graphql) and a typed schema that describes every piece of data available. The client sends a query document describing the shape it wants, and the server returns exactly that shape — nothing more, nothing less.

That same profile page that needed three REST requests? One GraphQL query handles it. The client asks for user name, avatar, their last three tweets, and follower count in a single request. The server resolves all of it and returns one response shaped exactly like the query.

But GraphQL isn't a free lunch. That flexibility comes with real costs. Caching gets harder — REST leverages HTTP caching at the URL level natively, while GraphQL's single endpoint makes URL-based caching useless by default (you need tools like Apollo Client's normalized cache or persisted queries). Query complexity is another concern: a malicious or poorly-written client can craft a deeply nested query that brings your database to its knees. You need query depth limiting and complexity analysis in production. The learning curve for schema design and resolvers is also steeper than standing up REST routes.

profile_page_query.graphqlGRAPHQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# The same profile page data — now in a single GraphQL query.
# The client is 100% in control of the shape of the response.

query GetUserProfile($userId: ID!, $tweetLimit: Int!) {\n  # Fetch the user by ID\n  user(id: $userId) {\n    # Ask for ONLY the fields this page actually renders\n    name\n    avatarUrl\n\n    # Nested query for tweets \u2014 limit is passed as a variable\n    tweets(limit: $tweetLimit) {\n      id\n      body\n      likeCount\n    }

    # A computed field on the User type — resolved server-side
    followerCount
  }
}

# Variables sent alongside the query (not hardcoded in the query string)
# {
#   "userId": "42",
#   "tweetLimit": 3
# }

# --- What the server returns ---
# {\n#   \"data\": {\n#     \"user\": {\n#       \"name\": \"Maya Patel\",\n#       \"avatarUrl\": \"https://cdn.example.com/avatars/42.jpg\",\n#       \"tweets\": [\n#         { \"id\": \"101\", \"body\": \"Just shipped a new feature!\", \"likeCount\": 14 },\n#         { \"id\": \"98\",  \"body\": \"GraphQL resolvers are wild.\",  \"likeCount\": 31 },\n#         { \"id\": \"95\",  \"body\": \"Coffee > sleep.\",              \"likeCount\": 7  }\n#       ],\n#       \"followerCount\": 8321\n#     }\n#   }\n# }\n\n# TOTAL: 1 HTTP request. Response contains EXACTLY the fields requested.\n# Response body: ~310 bytes. Zero wasted data.",
        "output": "1 HTTP request fires.\nResponse body: ~310 bytes \u2014 matches exactly what the UI needs.\nNo overfetching. No underfetching. No client-side orchestration needed."
      }

Real-World Decision Framework — When to Actually Pick Each One

The honest answer isn't 'GraphQL is better' or 'REST is simpler.' The right answer depends on the shape of your problem. Here's how senior engineers actually think about this decision.

Choose REST when your data model is simple and resource-oriented, your clients are few and controlled (e.g., internal services talking to each other), you need aggressive HTTP caching (CDN caching of GET endpoints is trivially easy with REST), or your team is small and you want zero-overhead tooling. Public APIs that third-party developers will consume are also often better served by REST — it's more universally understood and doesn't require GraphQL client libraries.

Choose GraphQL when you have multiple client types with different data needs — a mobile app, a web app, and a third-party integration all hitting the same backend. It's the perfect fit for a BFF (Backend for Frontend) layer. Also choose it when your product is iteration-heavy: adding a new field to a GraphQL schema is non-breaking by default, whereas adding a new REST endpoint requires versioning discussions. Rapid product teams love this.

The hybrid approach is increasingly common: REST for simple CRUD microservices talking to each other, GraphQL at the edge as an API gateway that stitches them together for clients. This is the architecture used by major platforms like GitHub (which offers both APIs) and Shopify.

graphql_server_with_depth_limit.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// A minimal but production-aware GraphQL server using Apollo Server.
// Demonstrates: schema definition, resolvers, and the depth-limiting
// protection you MUST add before going to production.

const { ApolloServer, gql } = require('apollo-server');
const depthLimit = require('graphql-depth-limit'); // npm install graphql-depth-limit

// --- Step 1: Define the Schema ---
// The schema is a contract between server and client.
// Every field, type, and relationship is declared here.
const typeDefs = gql`
  type Tweet {\n    id: ID!\n    body: String!\n    likeCount: Int!\n  }

  type User {
    id: ID!
    name: String!
    avatarUrl: String!
    followerCount: Int!
    # The 'limit' argument lets the client control how many tweets to fetch
    tweets(limit: Int = 10): [Tweet!]!
  }

  type Query {
    user(id: ID!): User
  }
`;

// --- Step 2: Mock data (replace with DB calls in production) ---
const USERS = {
  '42': {
    id: '42',
    name: 'Maya Patel',
    avatarUrl: 'https://cdn.example.com/avatars/42.jpg',
    followerCount: 8321,
  },
};

const TWEETS_BY_USER = {\n  '42': [\n    { id: '101', body: 'Just shipped a new feature!', likeCount: 14 },
    { id: '98',  body: 'GraphQL resolvers are wild.',  likeCount: 31 },
    { id: '95',  body: 'Coffee > sleep.',              likeCount: 7  },
    { id: '91',  body: 'Depth limits save lives.',     likeCount: 22 },
  ],
};

// --- Step 3: Resolvers ---
// Each resolver is a function that returns data for one field in the schema.
// Apollo walks the query tree and calls the right resolver for each field.
const resolvers = {\n  Query: {\n    // Called when client queries: user(id: \"42\") { ... }\n    user: (parent, args) => {\n      const foundUser = USERS[args.id];\n      if (!foundUser) throw new Error(`User ${args.id} not found`);\n      return foundUser;\n    },\n  },\n  User: {\n    // Called for each User object to resolve its 'tweets' field\n    // 'parent' is the User object returned by the Query.user resolver\n    tweets: (parent, args) => {\n      const allTweets = TWEETS_BY_USER[parent.id] || [];\n      // Respect the 'limit' argument the client passed in\n      return allTweets.slice(0, args.limit);\n    },\n  },\n};\n\n// --- Step 4: Server with depth limiting ---\n// Without this, a client could send a query like:\n// { user { followers { following { tweets { author { followers { ... } } } } } } }\n// ...and recursively query until your DB cries.\nconst server = new ApolloServer({\n  typeDefs,\n  resolvers,\n  validationRules: [\n    depthLimit(5), // Reject any query nested deeper than 5 levels\n  ],\n});\n\nserver.listen({ port: 4000 }).then(({ url }) => {\n  console.log(`GraphQL API ready at ${url}`);\n});",
        "output": "GraphQL API ready at http://localhost:4000/\n\n# Send this query via curl or Apollo Sandbox:\n# query { user(id: \"42\") { name avatarUrl followerCount tweets(limit: 3) { body likeCount } } }\n#\n# Response:\n# {\n#   \"data\": {\n#     \"user\": {\n#       \"name\": \"Maya Patel\",\n#       \"avatarUrl\": \"https://cdn.example.com/avatars/42.jpg\",\n#       \"followerCount\": 8321,\n#       \"tweets\": [\n#         { \"body\": \"Just shipped a new feature!\", \"likeCount\": 14 },\n#         { \"body\": \"GraphQL resolvers are wild.\",  \"likeCount\": 31 },\n#         { \"body\": \"Coffee > sleep.\",              \"likeCount\": 7  }\n#       ]\n#     }\n#   }\n# }"
      }

The Hybrid Architecture — GraphQL Gateway + REST Microservices

You don't have to pick one. The most mature production architectures use both: REST between internal microservices and GraphQL at the edge. Here's why that pattern wins.

Internal services talk to each other over REST (or gRPC). These calls are simple, cacheable at the service proxy level, and don't benefit from GraphQL's flexibility because each service has exactly one consumer. Adding GraphQL between internal services adds schema overhead and latency with no payoff.

At the edge, a GraphQL gateway sits between your clients and your internal services. The gateway resolves queries by making REST calls to the backend services. Clients get the benefits of GraphQL — declarative data fetching, reduced overfetching, single round trip — while your internal architecture stays simple and decoupled.

This pattern is what GitHub, Shopify, and Netflix run in production. GitHub's v4 GraphQL API is a gateway that aggregates data from over 200 internal REST services. Clients never know. They just send a query and get back exactly what they need.

graphql_gateway_resolver.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Example: GraphQL resolver that calls a REST microservice.
// The gateway doesn't talk to databases directly — it aggregates REST APIs.

const { ApolloServer, gql } = require('apollo-server');
const axios = require('axios');

const typeDefs = gql`
  type Product {
    id: ID!
    name: String!
    price: Float!
    # This field comes from a different microservice
    stockLevel: Int
  }

  type Query {
    product(id: ID!): Product
  }
`;

const resolvers = {
  Query: {
    product: async (_, { id }) => {
      // Call the internal Product REST API
      const { data } = await axios.get(`http://product-service:3000/api/products/${id}`);
      return data;
    },
  },
  Product: {\n    stockLevel: async (parent) => {\n      // Separate call to Inventory microservice\n      const { data } = await axios.get(`http://inventory-service:3001/api/stock/${parent.id}`);
      return data.quantity;
    },
  },
};

const server = new ApolloServer({ typeDefs, resolvers });
server.listen(4000).then(() => console.log('Gateway ready'));
Output
Gateway ready at http://localhost:4000
# Client query:
# query { product(id: "42") { name price stockLevel } }
# -> internally calls product-service (REST) and inventory-service (REST)
# -> returns combined response to client
Why This Works in Production:
Each internal microservice team owns a REST API with a stable contract. The GraphQL gateway team is the only team that needs GraphQL expertise. Changes to client data requirements only affect the gateway resolvers, not the internal services. This is the separation of concerns that actually scales.
Production Insight
The hybrid pattern isolates GraphQL complexity to one team.
Internal teams can ignore GraphQL entirely.
Rule: Use an API gateway (Apollo Federation, GraphQL Mesh) to keep layers decoupled.
Key Takeaway
GraphQL at the edge, REST in the core.
This is the production architecture that scales team size.
Don’t force GraphQL on every service.

Production Considerations — Caching, Security, Monitoring

Both REST and GraphQL have production pitfalls that you won't find in tutorials. Here's what actually matters when your API is handling millions of requests.

Caching is the biggest practical difference. REST endpoints are trivially cacheable at the CDN layer because each URL is unique. CDNs can cache GET /users/42 for 300 seconds and serve the cached copy to the next thousand requests. GraphQL's single endpoint means every request is a POST with a different query body — CDN caching is useless unless you use persisted queries (where each query has a unique hash ID). If your team cares about CDN cache hit rates, REST wins hands down.

Security: REST benefits from the full HTTP security toolkit — rate limiting by URL, WAF rules per path, IP allowlisting per resource. GraphQL requires you to implement depth limiting, query cost analysis, and allowlisting of known operations. Without these, a single malicious query can DDoS your entire API.

Monitoring: REST’s HTTP status codes map cleanly to error budgets and alerting — 5xx rate goes up, pager goes off. GraphQL returns 200 even for partial failures, so your monitoring must parse response bodies for errors. Most teams miss this and only realize after an incident that their error rate looked fine while users were seeing partial data.

Neither architecture is more secure or more observable by nature — they just require different tooling. The team that plans for these differences will sleep better at night.

monitoring_middleware.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Middleware to alert on GraphQL errors that return HTTP 200.
// Without this, your monitoring won't detect partial failures.

app.use('/graphql', (req, res, next) => {
  const originalJson = res.json.bind(res);
  res.json = function (body) {
    if (body && body.errors && body.errors.length > 0) {
      // Log all errors
      console.error('[GraphQL Error]', JSON.stringify(body.errors));
      
      // Trigger alert if there are any non-client errors
      const serverErrors = body.errors.filter(e => e.extensions?.code !== 'BAD_USER_INPUT');
      if (serverErrors.length > 0) {
        // Send to PagerDuty / Opsgenie
        metricClient.increment('graphql.server_error', serverErrors.length);
      }
    }
    return originalJson(body);
  };
  next();
});
Critical: GraphQL Error Monitoring
If your monitoring only tracks HTTP 5xx status codes, you will miss GraphQL errors. Many teams have had a 'quiet' partial outage where every query returned HTTP 200 with errors in the body. Always parse the response body for the errors array and alert on it. Treat that as a non-negotiable part of your GraphQL production checklist.
Production Insight
CDN caching is REST’s superpower that GraphQL cannot match.
GraphQL requires query analysis and persisted queries to even approach it.
Rule: If your business depends on CDN cache hit rates, put REST in front.
Key Takeaway
Caching, security, and monitoring are not equal.
REST: URL-level caching, HTTP errors, simple WAF rules.
GraphQL: query analysis, persisted queries, body parsing for errors. Plan accordingly.

The Schema Contract: Why Your Code Editor Becomes Your Best Debugger

REST APIs are basically a gentleman's agreement. You hope the docs are accurate, you pray the endpoint returns what you expect, and you lose a Friday afternoon tracing a missing field. GraphQL forces honesty through a strongly typed schema. That schema isn't just documentation — it's the source of truth your client and server both compile against. When we shipped a GraphQL gateway over twenty REST microservices, the schema caught three data shape mismatches before they hit production. The tooling matters more than you think. IDEs auto-complete queries. Linters reject invalid field requests at build time. You shift failure from runtime to compile time. That changes your entire deployment cadence. The trade-off? Schema design requires upfront discipline. REST lets you throw endpoints together. GraphQL demands you define your data graph before you write resolvers. Most teams underestimate that cost until they're rewriting schemas in month two.

orderResolver.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// io.thecodeforge
// Schema-first approach catches missing fields at compile time
import { ApolloError } from 'apollo-server-express';

// This resolver fails at build if orderQuery returns wrong shape
const orderResolver = {
  Query: {
    order: async (_, { id }, { dataSources }) => {
      const raw = await dataSources.orderAPI.fetchById(id);
      // Schema type: Order { id, status, total, items[] }
      // If raw.total is undefined, we crash fast instead of silently null
      if (!raw || !raw.id) {
        throw new ApolloError('Order not found', 'ORDER_NOT_FOUND');
      }
      return {
        id: raw.id,
        status: raw.status || 'UNKNOWN',
        total: parseFloat(raw.total || '0'),
        items: raw.items ? raw.items.map(mapItem) : [],
      };
    },
  },
};

export default orderResolver;
Output
GraphQL Inspector: 'Order.items' type mismatch — expected '[Item!]!' but resolver returned nullable array
Production Trap:
A misaligned schema in REST returns an unexpected null field. The frontend renders a blank page. You spend two hours tracing network logs. GraphQL's type system catches that at build time — but only if you enforce strict schema validation in your CI pipeline. Most teams skip that step. Don't.
Key Takeaway
If your schema compiles, your API contract is already validated. REST makes you trust docs. GraphQL makes the compiler your QA team.

Overfetching Is the Symptom — The Real Disease Is N+1 Queries in GraphQL

Everyone loves to hate REST for overfetching. Fair. But GraphQL introduces a nastier problem: N+1 queries. Your client requests a list of orders, each with line items. One query becomes one-plus-N database calls. Your response time explodes from 50ms to 2 seconds. REST does this too with nested resources, but at least REST forces you to think about endpoints. GraphQL's flexibility makes it too easy to write an innocent-looking query that murders your database. We fixed this with DataLoader — batching and caching per request. But here's the thing: you don't know you have an N+1 problem until it hits production under load. REST's endpoint-per-resource pattern limits the blast radius. GraphQL's single endpoint means one bad query takes down everything. You need query cost analysis, depth limiting, and pagination enforcement. Otherwise, your users trigger an accidental DDoS every time they refresh a dashboard.

dataLoaderPattern.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// io.thecodeforge
// DataLoader batches N+1 queries into one SQL IN clause
import DataLoader from 'dataloader';
import db from '../db/pool';

// Without this: N queries for line items
// With this: 1 query for all line items
const batchLineItems = async (orderIds) => {
  const result = await db.query(
    `SELECT * FROM line_items WHERE order_id = ANY($1)`,
    [orderIds]
  );
  // Group by order_id to return arrays per key
  const grouped = {};
  result.rows.forEach(row => {
    if (!grouped[row.order_id]) grouped[row.order_id] = [];
    grouped[row.order_id].push(row);
  });
  return orderIds.map(id => grouped[id] || []);
};

export const createLoaders = () => ({
  lineItemLoader: new DataLoader(batchLineItems),
});

// In resolver: return context.loaders.lineItemLoader.load(parent.id);
Output
Before DataLoader: 102 queries (1 order fetch + 101 line item fetches in a loop). After DataLoader: 2 queries total. Response time: 1.8s → 120ms.
Production Trap:
Your staging environment has 10 orders. Your local DB has 5. Everything looks fast. Congratulations — you've built a performance bomb that detonates when your first real customer loads 500 orders. Always test N+1 with production-scale data volumes before shipping.
Key Takeaway
GraphQL shifts the performance burden from the network to the database. If you don't batch, cache, and limit query depth, you're trading endpoint explosion for query complexity — and that's not a win.
● Production incidentPOST-MORTEMseverity: high

The N+1 Query That Took Down a Product Page at 2 PM

Symptom
Product page response time jumped from ~200ms to over 8 seconds around 2 PM on a Tuesday. The REST API was fine. The new GraphQL gateway was the bottleneck.
Assumption
The team assumed that because each resolver was simple — one database call per field — the overall query would be fast. They didn’t account for list resolution.
Root cause
The GraphQL resolver for «reviews» on the «Product» type was calling the database once per product. A query for 50 products fired 1 query for the products and 50 individual queries for reviews. That’s 51 database round trips for a single client request.
Fix
Implemented DataLoader with batching via Dataloader for Node.js. The 50 individual review queries collapsed into 1 SQL IN (...) query. Response time dropped back to under 300ms.
Key lesson
  • Any resolver that fetches a list of related entities (reviews per product, tweets per user) is a candidate for the N+1 problem.
  • Never ship a GraphQL resolver that does a single DB call per parent item without batching.
  • Add query depth limiting (max 5 levels) and complexity analysis to every production GraphQL server.
Production debug guideDiagnose and fix GraphQL performance issues before they cause an outage.5 entries
Symptom · 01
A specific query is slow, but others are fast
Fix
Enable Apollo Studio or GraphQL tracing. Look for resolvers with high «resolveTime«. The slow resolver is your root cause.
Symptom · 02
Response time scales linearly with list size
Fix
Check for the N+1 pattern. Use Dataloader batch functions. Monitor «number of DB queries« per GraphQL request via a logging middleware.
Symptom · 03
All queries are slow after a schema change
Fix
Check if a new resolver introduced a non-optimized join or a missing index. Use «EXPLAIN ANALYZE« on the underlying SQL generated by your ORM.
Symptom · 04
Only some clients experience timeouts
Fix
Those clients are sending deeply nested or expensive queries. Enforce query depth limiting and cost analysis. Return HTTP 429 for abusive clients.
Symptom · 05
REST endpoints are fast, but GraphQL gateway is not
Fix
The gateway may be processing too much data in memory. Check if resolvers are overfetching from underlying REST services. Use «@defer« (if your client supports it) to stream large fields.
★ Quick Debug Cheat Sheet for GraphQL Production IssuesCommands and actions to diagnose the most common GraphQL performance and reliability problems in production.
High latency on a list query
Immediate action
Count DB queries for a single parent item (e.g. 1 product with reviews).
Commands
npx apollo studio agent --graph=MyGraph --variants=production trace <query_id>
tail -f /var/log/app/graphql.log | grep 'resolver' | awk '{print $NF}' | sort | uniq -c | sort -rn
Fix now
Wrap the list resolver in a Dataloader batch function. Example: loaders.reviews.load(productId).
Bad client hitting deep nested query+
Immediate action
Temporarily disable query execution from that client IP.
Commands
iptables -A INPUT -s <client_ip> -p tcp --dport 4000 -j DROP
graphql-depth-limit 5 (add to validationRules in Apollo Server)
Fix now
Enforce depth limit: depthLimit(5). Also add query cost analysis using graphql-validation-complexity.
GraphQL returning HTTP 500 for valid queries+
Immediate action
Check resolver error handling — uncaught exceptions in resolvers produce 500.
Commands
grep '"extensions".*"code".*INTERNAL_SERVER_ERROR' /var/log/app/graphql.json.log
curl -X POST -H 'Content-Type: application/json' -d '{"query": "query { __typename }"}' http://localhost:4000/graphql -v 2>&1 | tail -20
Fix now
Wrap each resolver with a try/catch and return a structured GraphQL error with appropriate extensions.code.
CDN not caching REST responses+
Immediate action
Check Cache-Control headers on REST endpoints.
Commands
curl -I https://api.example.com/v1/products/42 | grep -i cache
curl -X POST -H 'Content-Type: application/json' -d '{"query": "{ product(id: 42) { name } }"}' http://localhost:4000/graphql -v 2>&1 | grep -i cache
Fix now
REST: add Cache-Control: public, max-age=300. GraphQL: implement persisted queries and cache the persisted query responses at the CDN level.
GraphQL vs REST Comparison Table
Feature / AspectRESTGraphQL
Data fetching modelServer defines fixed response shapes per endpointClient declares exactly what fields it needs
Number of endpointsMany (one per resource/action)One (typically POST /graphql)
OverfetchingCommon — endpoint returns all fields regardlessEliminated — client requests only what it needs
Underfetching / N requestsCommon — often needs multiple round tripsSolved — nested queries fetch related data in one request
HTTP cachingNative and trivial (GET requests cache at URL level)Requires extra tooling (persisted queries, Apollo cache)
VersioningRequires versioning (/v1/, /v2/) as schema evolvesNon-breaking by default — add fields, deprecate old ones
Error handlingHTTP status codes (200, 404, 500) are the standardAlways returns HTTP 200; errors live inside the response body
Learning curveLow — every dev knows GET/POST/PUT/DELETEMedium — schema design, resolvers, and DataLoader take time
Tooling ecosystemMature — Swagger/OpenAPI, Postman, curlRich — Apollo, GraphiQL, code generation, introspection
Best forSimple CRUD, public APIs, microservice-to-microserviceComplex clients, mobile apps, rapid iteration, BFF pattern
Real-world adoptersTwitter v1, Stripe, most microservicesGitHub v4, Shopify Storefront, Facebook, Airbnb

Key takeaways

1
REST's core weakness isn't the protocol
it's that fixed resource shapes create a mismatch between what the server returns and what different clients actually need. GraphQL solves this by inverting control to the client.
2
GraphQL's single endpoint breaks HTTP-layer caching. In REST, a CDN can cache GET /products/42 trivially. With GraphQL you need query-level caching strategies like Apollo's persisted queries or a normalized client-side cache.
3
The N+1 problem is GraphQL's most dangerous production pitfall. Every GraphQL server resolving lists of related data must implement DataLoader batching, or a query for 100 users will silently fire 101 database queries.
4
The smartest production architecture often uses both
REST for internal microservice communication (simple, fast, easy to cache) and a GraphQL gateway at the edge for client-facing APIs where different clients need different data shapes.
5
GraphQL error handling requires body parsing for monitoring. REST errors map to HTTP status codes; GraphQL errors hide inside HTTP 200 responses. Your monitoring setup must account for this or you will miss partial failures.

Common mistakes to avoid

5 patterns
×

Using GraphQL for microservice-to-microservice communication

Symptom
Teams add a full GraphQL layer between internal services, adding schema overhead and resolver complexity where a simple REST or gRPC call would be 10x faster and easier.
Fix
GraphQL shines at the client-facing edge (your API gateway or BFF). Between internal services that you control, REST or gRPC is almost always the right call. GraphQL's flexibility is only valuable when the consumer's needs are unpredictable — internal services usually have exactly one consumer with known needs.
×

Ignoring the N+1 query problem in GraphQL resolvers

Symptom
A query for 50 users and their tweets fires 51 database queries, and your API that seemed fast in development becomes dangerously slow under real traffic. You might not even notice until you load test it.
Fix
Install and configure DataLoader from day one. Wrap every resolver that fetches a related entity in a DataLoader batch function. It batches all the individual ID lookups from a single request into one SQL IN (...) query. This is non-negotiable in production GraphQL.
×

Assuming REST endpoints always need versioning

Symptom
Teams pre-emptively build /v1/ into every REST API and then maintain parallel endpoint versions forever, even when changes are purely additive.
Fix
Additive changes (new fields in a response, new optional query params) don't require versioning in REST either. Version only when you need to make a breaking change — removing a field, changing a field's type, or restructuring the response shape. GraphQL makes this easier with the @deprecated directive, but disciplined REST teams can avoid version sprawl too.
×

Not implementing query depth limits or cost analysis on GraphQL

Symptom
A client sends a deeply nested query like { user { friends { posts { comments { author { friends {...} } } } } } } and your database crashes because the query generates exponential resolution.
Fix
Add graphql-depth-limit middleware (set max depth to 5 or 7) and use graphql-validation-complexity or Apollo’s built-in cost analysis. Without these, your GraphQL server is one accidental query away from an outage.
×

Treating GraphQL error handling like REST error handling

Symptom
Monitoring shows 0% error rate because all responses return HTTP 200. Users are seeing partial data with errors in the response body, but no one alerts for it.
Fix
Parse the response body's errors array in your monitoring middleware. Alert on any non-client errors (extensions.code !== 'BAD_USER_INPUT'). Train your on-call team to check the errors array first, not the HTTP status code.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
You're designing the API layer for an e-commerce platform with an iOS ap...
Q02SENIOR
GraphQL always returns HTTP 200 even when an error occurs. How does erro...
Q03SENIOR
A colleague says 'GraphQL is always better than REST because it eliminat...
Q04SENIOR
Describe a production incident where GraphQL's N+1 problem caused a perf...
Q01 of 04SENIOR

You're designing the API layer for an e-commerce platform with an iOS app, an Android app, a web storefront, and a third-party affiliate integration. Each client needs different subsets of product data. Walk me through how you'd decide between REST and GraphQL, and what architecture you'd land on.

ANSWER
I'd start by listing the client types and their data needs. Four clients with different subsets of product data is a strong signal for GraphQL at the edge. I'd put a GraphQL gateway (Apollo Federation) in front, which aggregates data from internal REST microservices (product service, inventory service, pricing service). The internal services stay REST — they're simple, cacheable, and each has one consumer (the gateway). The clients benefit from declarative queries, no overfetching, and reduced round trips. I'd also use persisted queries for the mobile apps to improve caching and security. Finally, I'd enforce query depth limiting (max 5) and cost analysis to protect the backend.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Is GraphQL faster than REST?
02
Can I use GraphQL and REST together in the same application?
03
Why does GraphQL always return HTTP 200 even for errors?
04
Does GraphQL work well with relational databases?
05
Which one should I learn first for my career?
N
Naren Founder & Principal Engineer

20+ years shipping large-scale distributed systems. Notes here come from systems that actually shipped.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's Architecture. Mark it forged?

8 min read · try the examples if you haven't

Previous
REST API Design
3 / 13 · Architecture
Next
Event-Driven Architecture