Phase 2

GraphQL

Ask an API for exactly the data you need, while avoiding slow or overly expensive requests.

#GraphQL#resolvers#N+1

The core pitch: ask for exactly what you need

A typical REST screen - say, a user profile page showing name, recent posts, and follower count - often needs multiple round trips: GET /users/42, then GET /users/42/posts, then GET /users/42/followers. Or a single bloated endpoint that returns far more than the screen needs, just to avoid the extra round trips (over-fetching).

GraphQL replaces that with one endpoint and one query describing exactly the shape of data wanted:

graphql
query {
  user(id: 42) {
    name
    posts(limit: 5) {
      title
    }
    followerCount
  }
}

One HTTP request (almost always POST /graphql), one response, shaped exactly like the query - no unused fields, no follow-up requests for related data. That's the whole pitch: the client controls the shape of the response instead of the server dictating it through a fixed set of endpoints.

Resolvers: a function per field

Every field in a GraphQL schema is backed by a resolver - a function that knows how to fetch that specific piece of data. For the query above, the schema might define:

js
const resolvers = {
  Query: {
    user: (parent, args) => db.users.findById(args.id),
  },
  User: {
    posts: (user, args) => db.posts.findByAuthorId(user.id, args.limit),
    followerCount: (user) => db.follows.countByUserId(user.id),
  },
};

GraphQL executes resolvers top-down: first Query.user runs and returns the user object, then User.posts and User.followerCount run, each receiving that user object as their parent argument. Each field resolves independently - which is exactly where the well-known problem shows up.

The N+1 problem, concretely

Say the query asks for a list of 20 users, each with their postCount:

graphql
query {
  users(limit: 20) {
    name
    postCount
  }
}

If User.postCount's resolver is written naively -

js
User: {
  postCount: (user) => db.posts.count({ authorId: user.id }), // one query per user
}
  • GraphQL runs this resolver once per user in the list. One query fetches the 20 users (1), then 20 separate queries run to get each user's post count (N), for 21 total database round trips to answer what looks like a single request. This scales linearly with list size and is the single most common GraphQL performance bug in production - it's invisible in a query that returns 3 users and crippling in one that returns 300.

DataLoader (or equivalent batching utilities in other languages) fixes this by deferring and batching resolver calls that happen within the same tick:

js
const postCountLoader = new DataLoader(async (userIds) => {
  const counts = await db.posts.countByAuthorIds(userIds); // one query, all IDs
  return userIds.map((id) => counts[id] ?? 0);
});
 
User: {
  postCount: (user) => postCountLoader.load(user.id),
}

Instead of firing a query the moment each resolver runs, DataLoader collects every .load() call issued during the same execution tick, then issues a single batched query with all the collected IDs once the tick ends. Same 20 users, one batched follow-up query instead of 20 - 2 total round trips instead of 21.

Both versions are worth reading with the query count written out next to them, because the two resolvers look almost identical and the difference in database load is an order of magnitude:

Counting the queries, naive versus batched

js

The list resolver runs once

Query.users is called a single time and returns 20 user rows. That is query number one, and it is the only part of this that behaves the way the query text suggests it should.

1 / 6

When REST is still the better choice

GraphQL isn't a strict upgrade over REST, despite its popularity. REST tends to win when: HTTP caching matters and you want to lean on CDN/browser caching by URL (GraphQL's single POST /graphql endpoint makes that kind of caching much harder, since the URL no longer identifies what's being requested); the APIAPIA defined way for one piece of code to ask another to do something, without needing to know how it happens internally. Not a specific technology - a function signature, a library's exports, and a REST endpoint are all APIs. surface is small and stable enough that over-fetching isn't a real problem; file uploads or binary payloads are common (REST handles these natively, GraphQL needs workarounds); or the team doesn't want to take on the operational cost of query complexity limits, depth limiting, and resolver-level authorization checks that GraphQL APIs need to stay safe from expensive or malicious queries. GraphQL earns its complexity when clients genuinely have very different data needs per screen - a mobile app and a web dashboard hitting the same API - which is the scenario it was originally built for at Facebook.