The Runtime Theory
API Design

GraphQL Field Cost and Overfetching: What Resolution Actually Costs

GraphQL field resolution cost explained: N+1 queries, depth limits, batching, and why overfetching has a price at scale — with realistic resolver code.

The Runtime Theory Team3 min read#graphql#api-design#n-plus-one#performance#resolvers
On this page

GraphQL's marketing promise is that the client asks for exactly what it wants and the server sends exactly that. The mechanics are more interesting: every field in the response is produced by a resolver, every resolver is a potential database query, and the client controls how many of them run in a single request. The trade is real — overfetching is cheaper, but under-fetching with fan-out — the N+1 — is where requests die. This article is about what field resolution actually costs on the wire and in the database, and how to price it before it prices you.

Why overfetching has a price

In a REST world, the client overfetches because the server shapes the response. GET /users/42 returns the whole user row even when the client renders one field. That cost is bounded and predictable: one query, fixed size.

GraphQL inverts the problem. The client stops overfetching — but now the shape of the query determines the cost, and the cost of a query is whatever the resolvers do. Consider:

graphql
{
  team(id: "t_1") {
    name
    members {
      name
      recentPosts(limit: 5) {
        title
        comments { body }
      }
    }
  }
}

If team is one query, members is one query per team (or one batched), and recentPosts is one query per member, comments is one per post — the server may execute hundreds of queries to answer a query the client wrote in three lines. This is the fan-out: the cost of the request is the product of the branch sizes, not the sum.

The N+1: where requests die

The N+1 is the canonical GraphQL failure mode. Naive resolvers fetch their own data:

javascript
// Naive: 1 query for the team + N queries for members
const membersResolver = async (team, args, ctx) => {
  return ctx.db.query(
    "SELECT * FROM members WHERE team_id = $1",
    [team.id]
  );
};

With 200 members, that's 200 round trips to Postgres before you've touched recentPosts. Fixing it means moving the resolution up a level: resolve the parent list once, then fetch all children for all parents in one query, and hand each parent its slice. That is batching — the DataLoader pattern:

javascript
const memberLoader = new DataLoader(async (teamIds) => {
  const rows = await ctx.db.query(
    "SELECT * FROM members WHERE team_id = ANY($1)",
    [teamIds]
  );
  return teamIds.map((id) => rows.filter((r) => r.team_id === id));
});
 
// Members resolver: one query total, however many teams
const membersResolver = async (team) => memberLoader.load(team.id);

DataLoader gives you per-request caching and deduplication: identical keys load once, and parent resolvers that would issue the same child query merge. The point is architectural, not just a library: resolvers must be written as batch operations over the parent's keys, never as per-item queries.

Depth limits and query cost: pricing the attack surface

Since the client defines the shape, the client defines the cost — and hostile clients are the least of it; well-meaning clients with nested views are the usual culprits. Two defense layers matter:

Depth limiting. A hard cap on nesting depth (typically 6-8) stops the worst queries structurally:

javascript
// Enforce depth 6; reject the query before any resolver runs
const depthLimit = (root, args, context) => {
  if (root.depth > 6) throw new Error("Query exceeds maximum depth of 6");
  return context;
};

Cost analysis. Depth limits still allow breadth: 1,000 items × 50 fields each is legal under a depth limit. Real GraphQL gateways compute a weighted cost per field (each field has a unit cost, list fields multiply) and reject queries above a budget. This is the only defense that matches the actual pricing model of the system — and it must run against the shape of the query before execution, never mid-flight.

The subtle version of this: alias multiplication. a: user { ... } b: user { ... } duplicates subtrees under different aliases, bypassing naive dedup checks. Cost analysis must count aliases as separate field instances, or the limit is decorative.

Response size and the "just in time" benefit

Done right, GraphQL's real win is not performance — it is reduced overfetching, which is a bandwidth, serialization, and parse-time win, and it shows up at scale as faster time-to-first-pixel and cheaper egress. Overfetching has a price because every extra byte travels through the DB row buffer, the application heap, the serializer, the network, and the client's JSON parser. Fixed, bounded responses from REST have a predictable price; GraphQL's price is whatever the client's query says it is.