GraphQL vs REST — When to Use Each, N+1 Problem, and DataLoader

A practical comparison of GraphQL and REST: the problems each solves, when to choose one over the other, the infamous N+1 problem, and how DataLoader fixes it.

Introduction

GraphQL and REST are both ways to build APIs, but they solve different problems. REST has been the default for 20 years for good reason. GraphQL emerged from Facebook's pain with REST at scale and solves specific problems that REST handles awkwardly. Choosing between them should be a deliberate decision, not a cargo cult.

REST — The Baseline

REST (Representational State Transfer) maps resources to URLs. Each URL represents a thing; HTTP verbs (GET, POST, PUT, DELETE) represent actions on that thing.

GET    /users              # list users
GET    /users/123          # get user 123
POST   /users              # create user
PUT    /users/123          # update user 123
DELETE /users/123          # delete user 123
GET    /users/123/posts    # get posts for user 123

REST's strengths:

  • Simple, stateless, cacheable
  • HTTP caching works out of the box
  • Every developer and tool understands it
  • CDN and proxy support for free
  • Easy to debug with curl or browser dev tools

The Problems REST Struggles With

Over-fetching

You need the user's name and avatar for a list view, but GET /users returns 50 fields including address, payment info, preferences, and metadata you don't need. You fetch 10x more data than necessary on every page load.

Under-fetching (Chatty APIs)

You need to display a blog post with its author and comments. That requires:

GET /posts/42          # get the post
GET /users/7           # get the author
GET /posts/42/comments # get comments
GET /users/12          # get comment author 1
GET /users/19          # get comment author 2
GET /users/31          # get comment author 3

Six round trips for one screen. On mobile with high latency, this is a UX disaster.

API Versioning

When requirements change, you end up with /v1/users, /v2/users, /v3/users. Deprecation is painful. Multiple API versions multiply maintenance burden.

GraphQL — The Solution

GraphQL is a query language for APIs. Clients specify exactly what data they need; the server returns exactly that — nothing more, nothing less.

# One request for all the data the blog post page needs
query BlogPost($postId: ID!) {
  post(id: $postId) {
    title
    body
    publishedAt
    author {
      name
      avatar
    }
    comments {
      text
      createdAt
      author {
        name
        avatar
      }
    }
  }
}

One request. Exactly the fields you need. No over-fetching, no under-fetching.

The N+1 Problem — GraphQL's Achilles Heel

This is the most important concept for anyone implementing GraphQL. Given the query above, a naive resolver runs:

// Resolver pseudocode — DON'T DO THIS
const resolvers = {
  Query: {
    posts: () => db.query("SELECT * FROM posts")  // 1 query
  },
  Post: {
    author: (post) => db.query(
      "SELECT * FROM users WHERE id = ?", post.authorId
    )  // N queries — one per post!
  }
}

// For 100 posts: 1 + 100 = 101 database queries
// For 1000 posts: 1 + 1000 = 1001 queries
// This will kill your database

DataLoader — The N+1 Solution

DataLoader is a batching and caching utility originally built by Facebook. It collects individual data loads within a single tick of the event loop, then calls a batch function once with all collected keys.

import DataLoader from 'dataloader'

// Create a DataLoader for users
const userLoader = new DataLoader(async (userIds) => {
  // Called once per tick with ALL requested user IDs
  const users = await db.query(
    "SELECT * FROM users WHERE id = ANY(?)",
    [userIds]
  )
  // Return in same order as userIds
  return userIds.map(id => users.find(u => u.id === id))
})

// Resolver using DataLoader
const resolvers = {
  Post: {
    author: (post) => userLoader.load(post.authorId)
    // Each call is batched — results in 2 queries total
    // regardless of how many posts:
    // 1 query for posts, 1 batched query for all authors
  }
}

With DataLoader, fetching 100 posts with authors takes exactly 2 queries instead of 101.

DataLoader in Python (Strawberry)

from strawberry.dataloader import DataLoader
import strawberry
from typing import List

async def load_users(keys: List[int]) -> List[User]:
    """Batch load users by IDs."""
    users = await User.filter(id__in=keys)
    user_map = {u.id: u for u in users}
    return [user_map.get(key) for key in keys]

@strawberry.type
class Post:
    id: int
    title: str
    author_id: int

    @strawberry.field
    async def author(self, info: strawberry.types.Info) -> User:
        return await info.context["user_loader"].load(self.author_id)

# In context setup
context = {
    "user_loader": DataLoader(load_fn=load_users)
}

When to Choose GraphQL

GraphQL is worth the complexity when you have:

  • Multiple clients with different data needs — mobile apps, web apps, and desktop clients that need different subsets of the same data
  • Rapidly evolving schemas — where REST versioning would create maintenance hell
  • Complex, interconnected data — social graphs, content with many relationships
  • Developer experience as a priority — introspection, type safety, self-documenting APIs
  • Bandwidth-constrained clients — mobile apps where every byte counts

When to Stick With REST

REST remains the better choice when:

  • Caching is critical — CDN caching, browser caching, and HTTP proxies work natively with REST
  • Simple CRUD operations — REST is simpler and well-understood for basic operations
  • Public APIs with external consumers — REST is more universally understood
  • File uploads/downloads — REST handles binary data more naturally
  • Small teams with limited GraphQL expertise — the learning curve is real
  • Webhooks and event-driven patterns — REST verbs map directly

GraphQL Caching — The Tradeoff

GraphQL's biggest technical disadvantage is caching. REST GET requests are automatically cached by browsers, CDNs, and proxies. GraphQL queries are typically POST requests and cannot use HTTP caching natively.

Workarounds:

  • Persisted Queries — hash the query, send only the hash. Allows CDN caching.
  • Client-side caching — Apollo Client, urql, and Relay have sophisticated normalized caches
  • Response caching — cache at the resolver level with Redis

DevKits Tools for API Development

Whether you use GraphQL or REST, these DevKits tools streamline your workflow:

Summary — Decision Framework

Factor Choose REST Choose GraphQL
ClientsOne or twoMany, diverse
CachingCriticalLess critical
Data shapeStableEvolving
TeamFamiliar with RESTWilling to learn
BandwidthNot constrainedMobile, constrained