GraphQL — Deep Dive

GraphQL looks simple from the outside. One endpoint, flexible queries, nice schema. But production GraphQL is a different story — it has a set of well-known footguns that catch teams who migrate from REST without understanding the execution model. This piece covers the internals that matter.

Execution Model: How a Query Actually Runs

When a GraphQL server receives a query, it runs through four phases:

  1. Parse — The query string is parsed into an AST (Abstract Syntax Tree)
  2. Validate — The AST is checked against the schema. Invalid field names, wrong types, depth limit violations — all caught here, before any data fetching
  3. Execute — Resolvers are called for each field
  4. Serialize — The result is serialized to JSON

The execute phase is where things get interesting. Every field in a GraphQL schema has a resolver function — a function responsible for returning that field’s value. The root query fields are resolved first, then their children, depth-first.

const resolvers = {
  Query: {
    user: (parent, args, context, info) => {
      return db.users.findOne({ id: args.id });
    }
  },
  User: {
    posts: (parent, args, context, info) => {
      // parent is the user object returned above
      return db.posts.findAll({ authorId: parent.id });
    }
  }
}

The four resolver arguments:

  • parent — the result returned by the parent resolver
  • args — arguments passed to the field in the query
  • context — shared across all resolvers in a request (auth token, DB connection, etc.)
  • info — metadata about the current query (field name, return type, the full query AST)

The N+1 Problem (and Why It’ll Kill Your API)

This is the most important thing to understand about GraphQL performance and almost every team hits it.

Given this query:

query {
  posts(last: 10) {
    title
    author {
      name
    }
  }
}

A naive resolver implementation does this:

  1. Query DB for 10 posts → 1 SQL query
  2. For each post, query DB for the author → 10 SQL queries

11 queries instead of 2. With 100 posts, it’s 101 queries. This is the N+1 problem.

DataLoader is the standard fix, invented by Facebook alongside GraphQL. It batches resolver calls within a single event loop tick.

const DataLoader = require('dataloader');

const userLoader = new DataLoader(async (userIds) => {
  // Called once with ALL userIds accumulated this tick
  const users = await db.users.findAll({ id: { $in: userIds } });
  // Must return results in same order as input ids
  return userIds.map(id => users.find(u => u.id === id));
});

const resolvers = {
  Post: {
    author: (post) => userLoader.load(post.authorId)
  }
}

Now 10 posts → 1 query for posts + 1 query for all 10 authors. DataLoader batches and caches per-request.

The key insight: DataLoader exploits Node.js’s event loop. All load() calls in the same tick get batched into one loadMany() call on the next tick.

Schema Design Patterns

Relay-style pagination — The most common pattern for lists. Uses cursor-based pagination instead of page numbers:

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

This handles real-time data better than offset pagination (if items are inserted mid-stream, offset pagination skips or duplicates items; cursors don’t).

Input types — For mutations, always use input types rather than individual scalar arguments. They’re reusable and versionable:

input CreatePostInput {
  title: String!
  body: String!
  tags: [String!]
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
}

Union types vs interfaces — Interfaces define shared fields; unions allow completely different types in one field. Classic use case: a search result that could be a User, a Post, or a Product.

union SearchResult = User | Post | Product

type Query {
  search(query: String!): [SearchResult!]!
}

Clients use inline fragments to handle each type:

query {
  search(query: "graphql") {
    ... on User { name }
    ... on Post { title }
    ... on Product { price }
  }
}

Caching: The Hard Part

REST caches naturally because each URL maps to a specific resource. GET /users/123 is always cacheable by its URL. GraphQL’s single endpoint blows this up.

Approaches:

Persisted Queries — Instead of sending the full query string on every request, clients pre-register queries and send a hash. Apollo calls this APQ (Automatic Persisted Queries). Benefits: CDN-cacheable (GET request with hash in URL), smaller request payload, prevents arbitrary query execution in production.

Response caching — Libraries like graphql-response-cache (RedwoodJS), Stellate (formerly GraphCDN), or Apollo Server’s cache hints let you annotate types/fields with cache TTLs. The server caches the serialized response by query+variables hash.

Field-level caching — Using @cacheControl directives:

type User @cacheControl(maxAge: 300) {
  id: ID!
  name: String!
  profileViews: Int @cacheControl(maxAge: 0)  # Never cache
}

Security Considerations

Query depth limiting — A malicious client can craft deeply nested queries that explode your resolvers. Libraries like graphql-depth-limit cap this:

app.use('/graphql', graphqlHTTP({
  validationRules: [depthLimit(10)]
}));

Query complexity analysis — Depth alone isn’t enough. A shallow but wide query (requesting 100 fields on 100 objects) can be expensive. Libraries assign cost values to fields and reject queries that exceed a budget.

Disabling introspection in production — The schema introspection system is great for development but exposes your entire API surface to attackers. Most teams disable it in production:

const server = new ApolloServer({
  introspection: process.env.NODE_ENV !== 'production'
});

Authorization — The most common mistake is handling auth in resolvers. If a field is requested but the user can’t see it, the resolver silently returns null, but the field was still resolved (potentially hitting your DB). Better pattern: use a library like graphql-shield to declare permission rules on the schema level, and reject unauthorized fields before execution.

Federation: GraphQL at Scale

When a company has multiple teams and services, a single monolithic GraphQL schema becomes a bottleneck. Apollo Federation solves this by splitting the schema across services while keeping a unified API.

Each service declares its own part of the schema and which fields it “owns”. A gateway stitches them together at request time. Shopify’s API, for example, runs a federated GraphQL architecture across dozens of internal services.

The alternative — schema stitching (manually merging schemas in the gateway) — is older and more fragile. Federation is now the standard for multi-team GraphQL.

REST vs GraphQL: The Honest Comparison

RESTGraphQL
CachingEasy (HTTP)Hard (requires tooling)
File uploadsEasy (multipart)Awkward
DiscoverabilityOpenAPI/SwaggerIntrospection
Learning curveLowMedium
Over/underfetchingCommonSolved
Real-timeWebhooks/SSESubscriptions built-in
Error handlingHTTP status codesAlways 200, errors in body

The “always 200” error handling is one of GraphQL’s genuine rough edges. A partial response (some fields resolved, some errored) returns HTTP 200 with an errors array in the body. This breaks standard monitoring tools that watch for 4xx/5xx responses.

One thing to remember: The N+1 problem is non-negotiable — if you ship a GraphQL API without DataLoader and your schema has any nested relationships, you will have performance problems at scale. It’s not optional complexity, it’s table stakes.

graphqlapisresolversdataloadern+1performanceweb-development

See Also

  • Apis What is an API? Think of it as a waiter who takes your order and brings back exactly what you asked for.
  • Encryption Encryption explained: how your messages and passwords stay secret even when strangers can see them.
  • Git Why do millions of programmers obsess over a tool that saves old versions of their work? Because without it, one bad day can delete months of effort.