GraphQL vs REST: Choosing the Right API Architecture

Your Name
February 20, 2025
graphql
rest
api
architecture

A comprehensive comparison of GraphQL and REST APIs, exploring when to use each approach for modern applications.

The choice between GraphQL and REST is one of the most important architectural decisions in modern API design. This guide helps you understand when to use each approach.

Understanding REST

REST (Representational State Transfer) has been the dominant API architecture for over a decade.

REST Principles

  • Stateless: Each request contains all necessary information
  • Resource-based: URLs represent resources
  • HTTP methods: Use standard HTTP verbs (GET, POST, PUT, DELETE)
  • Uniform interface: Consistent interaction patterns

REST Example

// User API endpoints
GET / api / users; // Get all users
GET / api / users / 123; // Get specific user
POST / api / users; // Create user
PUT / api / users / 123; // Update user
DELETE / api / users / 123; // Delete user

// Related resources
GET / api / users / 123 / posts; // Get user's posts
GET / api / posts / 456 / comments; // Get post comments

Express.js REST implementation:

const express = require("express");
const app = express();

// User routes
app.get("/api/users", async (req, res) => {
  const page = req.query.page || 1;
  const limit = req.query.limit || 10;

  const users = await User.find()
    .skip((page - 1) * limit)
    .limit(limit)
    .select("-password");

  res.json({
    data: users,
    pagination: {
      page,
      limit,
      total: await User.countDocuments(),
    },
  });
});

app.get("/api/users/:id", async (req, res) => {
  try {
    const user = await User.findById(req.params.id).select("-password");
    if (!user) {
      return res.status(404).json({ error: "User not found" });
    }
    res.json(user);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

app.post("/api/users", async (req, res) => {
  try {
    const user = new User(req.body);
    await user.save();
    res.status(201).json(user);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

Understanding GraphQL

GraphQL provides a more flexible approach to API design with a single endpoint.

GraphQL Principles

  • Single endpoint: All operations through /graphql
  • Strongly typed: Schema defines exact data structure
  • Query language: Clients specify exactly what data they need
  • Introspective: Schema is queryable

GraphQL Example

Schema definition:

type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
  createdAt: String!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  comments: [Comment!]!
  publishedAt: String
}

type Comment {
  id: ID!
  content: String!
  author: User!
  post: Post!
  createdAt: String!
}

type Query {
  user(id: ID!): User
  users(limit: Int, offset: Int): [User!]!
  post(id: ID!): Post
  posts(authorId: ID, limit: Int): [Post!]!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): Boolean!
  createPost(input: CreatePostInput!): Post!
}

input CreateUserInput {
  name: String!
  email: String!
  password: String!
}

input UpdateUserInput {
  name: String
  email: String
}

input CreatePostInput {
  title: String!
  content: String!
  authorId: ID!
}

Resolver implementation:

const {
  GraphQLSchema,
  GraphQLObjectType,
  GraphQLString,
  GraphQLID,
  GraphQLList,
} = require("graphql");

const resolvers = {
  Query: {
    user: async (parent, { id }) => {
      return await User.findById(id);
    },
    users: async (parent, { limit = 10, offset = 0 }) => {
      return await User.find().skip(offset).limit(limit);
    },
    post: async (parent, { id }) => {
      return await Post.findById(id);
    },
    posts: async (parent, { authorId, limit = 10 }) => {
      const filter = authorId ? { authorId } : {};
      return await Post.find(filter).limit(limit);
    },
  },
  Mutation: {
    createUser: async (parent, { input }) => {
      const user = new User(input);
      return await user.save();
    },
    updateUser: async (parent, { id, input }) => {
      return await User.findByIdAndUpdate(id, input, { new: true });
    },
    deleteUser: async (parent, { id }) => {
      await User.findByIdAndDelete(id);
      return true;
    },
    createPost: async (parent, { input }) => {
      const post = new Post(input);
      return await post.save();
    },
  },
  User: {
    posts: async (user) => {
      return await Post.find({ authorId: user.id });
    },
  },
  Post: {
    author: async (post) => {
      return await User.findById(post.authorId);
    },
    comments: async (post) => {
      return await Comment.find({ postId: post.id });
    },
  },
  Comment: {
    author: async (comment) => {
      return await User.findById(comment.authorId);
    },
    post: async (comment) => {
      return await Post.findById(comment.postId);
    },
  },
};

Client queries:

# Get user with posts
query GetUserWithPosts($userId: ID!) {
  user(id: $userId) {
    id
    name
    email
    posts {
      id
      title
      publishedAt
      comments {
        id
        content
        author {
          name
        }
      }
    }
  }
}

# Get minimal user list
query GetUserList {
  users(limit: 20) {
    id
    name
    email
  }
}

# Create new post
mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
    author {
      name
    }
  }
}

Feature Comparison

Data Fetching

REST:

// Multiple requests needed
const user = await fetch("/api/users/123");
const posts = await fetch("/api/users/123/posts");
const comments = await fetch("/api/posts/456/comments");

// Over-fetching - gets all user fields
const userData = await user.json();

GraphQL:

// Single request with exact data needed
const query = `
    query {
        user(id: "123") {
            name
            email
            posts {
                title
                comments {
                    content
                }
            }
        }
    }
`;

const response = await fetch("/graphql", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ query }),
});

Caching

REST:

// HTTP caching works naturally
app.get("/api/users/:id", (req, res) => {
  res.set("Cache-Control", "public, max-age=300");
  // ... return user data
});

// Client-side caching
const cache = new Map();

async function getUser(id) {
  if (cache.has(`user:${id}`)) {
    return cache.get(`user:${id}`);
  }

  const user = await fetch(`/api/users/${id}`).then((r) => r.json());
  cache.set(`user:${id}`, user);
  return user;
}

GraphQL:

// More complex caching
const DataLoader = require("dataloader");

const userLoader = new DataLoader(async (ids) => {
  const users = await User.find({ _id: { $in: ids } });
  return ids.map((id) => users.find((user) => user.id === id));
});

// Resolver with DataLoader
const resolvers = {
  Post: {
    author: async (post) => {
      return await userLoader.load(post.authorId);
    },
  },
};

// Client-side normalized caching (Apollo Client)
const client = new ApolloClient({
  cache: new InMemoryCache({
    typePolicies: {
      User: {
        fields: {
          posts: {
            merge(existing = [], incoming) {
              return [...existing, ...incoming];
            },
          },
        },
      },
    },
  }),
});

Error Handling

REST:

app.get("/api/users/:id", async (req, res) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) {
      return res.status(404).json({
        error: "User not found",
        code: "USER_NOT_FOUND",
      });
    }
    res.json(user);
  } catch (error) {
    res.status(500).json({
      error: "Internal server error",
      code: "INTERNAL_ERROR",
    });
  }
});

GraphQL:

const resolvers = {
    Query: {
        user: async (parent, { id }) => {
            try {
                const user = await User.findById(id);
                if (!user) {
                    throw new Error('User not found');
                }
                return user;
            } catch (error) {
                throw new Error(`Failed to fetch user: ${error.message}`);
            }
        }
    }
};

// GraphQL response with partial data and errors
{
    "data": {
        "user": {
            "name": "John Doe",
            "posts": null
        }
    },
    "errors": [
        {
            "message": "Failed to fetch posts",
            "path": ["user", "posts"],
            "extensions": {
                "code": "DATABASE_ERROR"
            }
        }
    ]
}

When to Choose REST

REST is ideal when:

  1. Simple CRUD operations: Straightforward resource manipulation
  2. HTTP caching important: Leveraging browser/CDN caching
  3. File uploads/downloads: REST handles binary data naturally
  4. Team familiarity: Existing REST expertise
  5. External integrations: Many third-party services use REST

REST Benefits:

  • Mature ecosystem: Extensive tooling and libraries
  • Simple caching: HTTP caching works out of the box
  • Stateless: Easy to scale horizontally
  • Standards compliance: Follows HTTP conventions

When to Choose GraphQL

GraphQL is ideal when:

  1. Complex data relationships: Nested, interconnected data
  2. Mobile applications: Minimizing data transfer
  3. Rapid iteration: Frontend teams need flexible data access
  4. Multiple clients: Different apps need different data shapes
  5. Real-time features: Subscriptions for live updates

GraphQL Benefits:

  • Precise data fetching: No over/under-fetching
  • Strong typing: Schema provides clear contracts
  • Introspection: Self-documenting APIs
  • Single endpoint: Simplified client-server communication

Hybrid Approaches

GraphQL over REST

// Wrap REST endpoints with GraphQL
const resolvers = {
  Query: {
    user: async (parent, { id }) => {
      const response = await fetch(`/rest-api/users/${id}`);
      return response.json();
    },
    posts: async (parent, { authorId }) => {
      const response = await fetch(`/rest-api/posts?author=${authorId}`);
      return response.json();
    },
  },
};

REST with GraphQL-like features

// Sparse fieldsets
app.get('/api/users/:id', (req, res) => {
    const fields = req.query.fields?.split(',') || [];
    const projection = fields.length ? fields.join(' ') : '';

    const user = await User.findById(req.params.id).select(projection);
    res.json(user);
});

// Include related data
app.get('/api/users/:id', (req, res) => {
    const include = req.query.include?.split(',') || [];

    let query = User.findById(req.params.id);
    if (include.includes('posts')) {
        query = query.populate('posts');
    }
    if (include.includes('comments')) {
        query = query.populate('posts.comments');
    }

    const user = await query.exec();
    res.json(user);
});

Performance Considerations

REST Performance

// Implement ETags for caching
app.get("/api/users/:id", async (req, res) => {
  const user = await User.findById(req.params.id);
  const etag = generateETag(user);

  if (req.headers["if-none-match"] === etag) {
    return res.status(304).end();
  }

  res.set("ETag", etag);
  res.json(user);
});

// Rate limiting
const rateLimit = require("express-rate-limit");
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
});

app.use("/api/", limiter);

GraphQL Performance

// Query complexity analysis
const depthLimit = require("graphql-depth-limit");
const costAnalysis = require("graphql-cost-analysis");

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(10),
    costAnalysis.createRateLimitRule({
      maximumCost: 1000,
      scalarCost: 1,
      objectCost: 2,
      listFactor: 10,
    }),
  ],
});

// Query timeouts
const resolvers = {
  Query: {
    users: async (parent, args, context) => {
      return Promise.race([
        User.find().limit(args.limit),
        new Promise((_, reject) =>
          setTimeout(() => reject(new Error("Query timeout")), 5000)
        ),
      ]);
    },
  },
};

Decision Framework

Choose REST when:

  • ✅ Simple CRUD operations
  • ✅ HTTP caching is crucial
  • ✅ File handling required
  • ✅ Team prefers familiar patterns
  • ✅ Integration with REST services

Choose GraphQL when:

  • ✅ Complex data relationships
  • ✅ Multiple client types
  • ✅ Bandwidth optimization needed
  • ✅ Rapid frontend development
  • ✅ Real-time features required

Consider hybrid approaches when:

  • 🔄 Migrating from REST to GraphQL
  • 🔄 Different parts of app have different needs
  • 🔄 Want GraphQL benefits with REST infrastructure

Conclusion

Both REST and GraphQL have their place in modern API architecture. REST excels in simplicity and caching, while GraphQL shines in flexibility and efficiency. The best choice depends on your specific requirements, team expertise, and application constraints.

Consider starting with REST for simpler applications and evolving to GraphQL as complexity grows, or implement a hybrid approach that leverages the strengths of both paradigms.