GraphQL: Query Language & API Design Guide

intermediate 13 min read Updated 2026-02-11

After this topic, you will be able to:

  • Explain GraphQL’s schema-first approach and how it enables flexible client-driven queries
  • Evaluate GraphQL’s solutions to over-fetching and under-fetching compared to REST
  • Assess the trade-offs of GraphQL including the N+1 query problem and caching complexity

TL;DR

GraphQL is a query language and runtime for APIs that lets clients request exactly the data they need in a single round-trip. Unlike REST’s fixed endpoints, GraphQL exposes a strongly-typed schema where clients compose queries that traverse relationships, eliminating over-fetching and under-fetching. The trade-off: complexity in caching, authorization, and the infamous N+1 query problem that requires careful resolver design.

Cheat Sheet: Schema defines types → Client sends query → Server executes resolvers → Returns shaped data. Single endpoint /graphql, POST requests, introspection built-in. Watch for N+1 queries (use DataLoader), complex authorization (field-level), and cache invalidation (no URL-based caching).

Background

Facebook created GraphQL in 2012 to solve a fundamental problem with their mobile apps: REST APIs were forcing clients to make multiple round-trips and download massive payloads just to render a single screen. A news feed might need /posts, /users, /comments, and /likes endpoints, each returning more data than needed. Mobile networks made this latency catastrophic.

The insight was to flip the model: instead of the server deciding what data to return (REST resources), let the client declare its data requirements in a hierarchical query. The server would then fetch exactly that shape of data in one request. Facebook open-sourced GraphQL in 2015, and it quickly spread to companies with complex, relationship-heavy data models.

GraphQL solves three core REST problems: over-fetching (getting fields you don’t need), under-fetching (making multiple requests to assemble data), and versioning (evolving APIs without breaking clients). The cost is operational complexity: you’re essentially exposing a query language to the internet, which brings performance, security, and caching challenges that REST’s simplicity avoids.

The name “GraphQL” reflects its core philosophy: your data is a graph of interconnected entities, and queries traverse that graph. This matches how UIs actually work—a user profile page shows a user, their posts, comments on those posts, and likes from other users. GraphQL lets you fetch this entire tree in one query shaped exactly like your UI component tree.

Architecture

GraphQL architecture centers on three components: the schema, the resolver layer, and the execution engine. The schema is a strongly-typed contract written in GraphQL Schema Definition Language (SDL) that defines all available types, queries, mutations, and subscriptions. Think of it as your API’s type system and documentation in one.

A typical schema defines object types with fields: type User { id: ID!, name: String!, posts: [Post!]! }. The Query type defines entry points: type Query { user(id: ID!): User }. The Mutation type handles writes: type Mutation { createPost(title: String!): Post }. The Subscription type enables real-time updates: type Subscription { postAdded: Post }.

Resolvers are functions that fetch data for each field. When a query arrives, the execution engine parses it, validates it against the schema, and calls resolvers in a specific order. Each resolver receives four arguments: parent (the result from the parent field), args (field arguments), context (shared state like auth), and info (query metadata).

The execution engine is the magic. It builds a query plan, calls resolvers in parallel where possible, and assembles the response tree. If a query asks for user { name, posts { title } }, it calls the user resolver, then for each post in user.posts, calls the post.title resolver. This recursive resolution is powerful but creates the N+1 problem.

GraphQL typically runs over HTTP POST to a single endpoint (usually /graphql), though the spec is transport-agnostic. Subscriptions often use WebSockets for real-time push. See Long Polling, WebSockets & SSE for subscription transport details.

GraphQL Request Flow: Schema, Resolvers, and Execution

graph LR
    Client["Client<br/><i>Web/Mobile App</i>"]
    Endpoint["/graphql Endpoint<br/><i>Single HTTP POST</i>"]
    
    subgraph GraphQL Server
        Parser["Parser<br/><i>Query → AST</i>"]
        Validator["Validator<br/><i>Check Schema</i>"]
        Schema["Schema<br/><i>Type Definitions</i>"]
        Engine["Execution Engine<br/><i>Resolver Orchestration</i>"]
        
        subgraph Resolver Layer
            QueryResolver["Query Resolvers<br/><i>Entry Points</i>"]
            FieldResolver["Field Resolvers<br/><i>Data Fetching</i>"]
        end
    end
    
    subgraph Data Sources
        DB[("Database<br/><i>PostgreSQL</i>")]
        Cache[("Cache<br/><i>Redis</i>")]
        API["External API<br/><i>Microservice</i>"]
    end
    
    Client --"1. POST query"--> Endpoint
    Endpoint --"2. Parse"--> Parser
    Parser --"3. Validate"--> Validator
    Validator --"4. Check types"--> Schema
    Validator --"5. Execute"--> Engine
    Engine --"6. Call resolvers"--> QueryResolver
    QueryResolver --"7. Resolve fields"--> FieldResolver
    FieldResolver --"8. Fetch data"--> DB
    FieldResolver --"9. Check cache"--> Cache
    FieldResolver --"10. Call service"--> API
    Engine --"11. Assemble JSON"--> Client

GraphQL processes requests through four phases: parsing the query string into an AST, validating against the schema, executing resolvers in a depth-first traversal, and assembling the JSON response. All requests go through a single /graphql endpoint, unlike REST’s multiple resource endpoints.

GraphQL Schema Structure: Types, Queries, and Mutations

graph TB
    subgraph Schema Definition
        Schema["GraphQL Schema<br/><i>Strongly-typed contract</i>"]
        
        subgraph Object Types
            User["type User {<br/>  id: ID!<br/>  name: String!<br/>  email: String<br/>  posts: [Post!]!<br/>}"]  
            Post["type Post {<br/>  id: ID!<br/>  title: String!<br/>  author: User!<br/>  comments: [Comment!]!<br/>}"]
            Comment["type Comment {<br/>  id: ID!<br/>  text: String!<br/>  author: User!<br/>}"]
        end
        
        subgraph Entry Points
            Query["type Query {<br/>  user(id: ID!): User<br/>  post(id: ID!): Post<br/>  posts(limit: Int): [Post!]!<br/>}"]
            Mutation["type Mutation {<br/>  createPost(title: String!): Post<br/>  updateUser(id: ID!, name: String): User<br/>  deleteComment(id: ID!): Boolean<br/>}"]
            Subscription["type Subscription {<br/>  postAdded: Post<br/>  commentAdded(postId: ID!): Comment<br/>}"]
        end
    end
    
    Schema --> User
    Schema --> Post
    Schema --> Comment
    Schema --> Query
    Schema --> Mutation
    Schema --> Subscription
    
    User --"Relationship"--> Post
    Post --"Relationship"--> User
    Post --"Relationship"--> Comment
    Comment --"Relationship"--> User
    
    Query -."Read operations".-> User
    Mutation -."Write operations".-> Post
    Subscription -."Real-time updates".-> Comment

GraphQL schemas define object types with fields and their relationships, plus three entry point types: Query for reads, Mutation for writes, and Subscription for real-time updates. The exclamation mark (!) indicates non-nullable fields. This strongly-typed contract enables introspection, auto-generated documentation, and compile-time validation.

Internals

GraphQL’s execution model is a depth-first traversal of the query tree. When a query arrives, the server performs four phases: parsing (query string → AST), validation (check against schema), execution (call resolvers), and response formatting (assemble JSON).

Parsing uses a recursive descent parser to build an Abstract Syntax Tree. The query { user(id: "1") { name } } becomes a tree with a root Query node, a user field node with argument id: "1", and a name field node. This AST is cached by query string for performance.

Validation checks that requested fields exist, arguments match types, fragments are valid, and the query doesn’t exceed complexity limits. This is where GraphQL’s type system shines—invalid queries are rejected before execution. Contrast this with REST, where you only discover a 404 or 500 at runtime.

Execution is where resolvers run. The engine starts at the root Query type and calls the user resolver with args: { id: "1" }. That resolver might query a database: SELECT * FROM users WHERE id = 1. The result becomes the parent for child resolvers. If the query asks for posts, the User.posts resolver runs with parent being the user object.

Here’s the N+1 trap: if User.posts naively queries SELECT * FROM posts WHERE user_id = ? for each user, a query fetching 100 users and their posts makes 1 query for users + 100 queries for posts = 101 queries. This is catastrophic at scale.

GraphQL’s type system is structural, not nominal. A type is defined by its fields, not its name. This enables powerful features like interfaces (interface Node { id: ID! }) and unions (union SearchResult = User | Post). The schema is introspectable—clients can query __schema to discover all types, enabling tools like GraphQL Playground.

Subscriptions use a pub/sub model. When a client subscribes, the server maintains a long-lived connection (usually WebSocket) and pushes updates when events occur. The resolver returns an AsyncIterator that yields values over time. This is fundamentally different from queries and mutations, which are one-shot request-response.

GraphQL Query Execution Tree: Resolver Call Order

graph TB
    Query["Query Root<br/><i>Entry Point</i>"]
    UserResolver["user(id: '1')<br/><i>Resolver Call 1</i>"]
    UserObject["User Object<br/>{id: '1', name: 'Alice'}"]
    
    subgraph Parallel Execution
        NameResolver["User.name<br/><i>Resolver Call 2a</i>"]
        PostsResolver["User.posts<br/><i>Resolver Call 2b</i>"]
    end
    
    NameResult["'Alice'"]
    PostsArray["[Post, Post, Post]"]
    
    subgraph For Each Post
        Post1Title["Post.title<br/><i>Resolver Call 3a</i>"]
        Post2Title["Post.title<br/><i>Resolver Call 3b</i>"]
        Post3Title["Post.title<br/><i>Resolver Call 3c</i>"]
    end
    
    Response["Final Response<br/>{user: {name: 'Alice',<br/>posts: [{title: '...'}, ...]}}"]
    
    Query --"Execute"--> UserResolver
    UserResolver --"Returns parent"--> UserObject
    UserObject --> NameResolver
    UserObject --> PostsResolver
    NameResolver --> NameResult
    PostsResolver --> PostsArray
    PostsArray --"parent[0]"--> Post1Title
    PostsArray --"parent[1]"--> Post2Title
    PostsArray --"parent[2]"--> Post3Title
    Post1Title & Post2Title & Post3Title --> Response
    NameResult --> Response

GraphQL executes resolvers in a depth-first traversal. The user resolver runs first, returning a User object that becomes the parent for child resolvers. Field resolvers at the same level (name, posts) execute in parallel. For list fields, the resolver runs once per item, creating the N+1 problem if not batched.

Performance Characteristics

GraphQL’s performance profile is highly variable and depends entirely on resolver implementation. A well-optimized GraphQL API can outperform REST by eliminating round-trips, but a naive implementation can be 10-100x slower due to N+1 queries.

Latency: Single query latency ranges from 10ms (simple, cached) to 500ms+ (complex, uncached). The key factor is resolver efficiency. A query fetching a user and their 10 most recent posts might take 15ms with proper batching (1 DB query for user + 1 batched query for all posts), but 150ms with N+1 (1 + 10 queries). Facebook reports p99 latencies under 100ms for most queries with aggressive caching and DataLoader batching.

Throughput: GraphQL servers typically handle 1,000-10,000 requests/second per instance, depending on query complexity. Simple queries (single resolver, cached) can hit 50,000+ req/s. Complex queries with deep nesting might drop to 100 req/s. The bottleneck is usually database queries, not GraphQL parsing. GitHub’s public GraphQL API handles millions of requests per day with rate limiting at 5,000 points/hour (each field costs points based on complexity).

Scalability: GraphQL scales horizontally well because it’s stateless (except subscriptions). The challenge is backend data source scaling. A query joining 5 microservices requires all 5 to respond, so p99 latency is determined by the slowest service. Netflix’s Federated GraphQL layer aggregates 50+ backend services, using aggressive timeouts (50-100ms) and fallbacks to maintain reliability.

Parsing overhead: Query parsing is 1-5ms for typical queries. Production systems cache parsed queries by hash to amortize this cost. Apollo Server reports 0.5ms parse time for cached queries.

Memory: GraphQL’s in-memory query plan and result assembly can consume 10-100MB per complex query. Deeply nested queries (10+ levels) can cause stack overflows or OOM errors, so production systems enforce depth limits (typically 7-10 levels) and complexity budgets (GitHub uses a points system).

Trade-offs

GraphQL excels at:

Flexible data fetching: Clients get exactly what they need. A mobile app can request { user { id, name } } while a desktop app requests { user { id, name, email, posts { title, createdAt } } }. No over-fetching, no under-fetching. This is transformative for teams with multiple clients (iOS, Android, web) hitting the same API.

Strong typing and introspection: The schema is self-documenting. Tools like GraphQL Playground auto-generate documentation and provide autocomplete. Breaking changes are caught at build time, not runtime. This reduces integration bugs by 50-80% compared to REST in practice.

Rapid iteration: Adding a new field to a type doesn’t break existing queries. Clients opt-in to new fields. This enables continuous deployment without versioning. Facebook has evolved their GraphQL schema for 10+ years without a v2.

Relationship traversal: Fetching nested data is natural. { user { posts { comments { author { name } } } } } is one query. In REST, this requires 4 round-trips or complex endpoint design (/users/1?include=posts.comments.author).

GraphQL struggles with:

Caching complexity: REST benefits from HTTP caching (CDNs, browser cache) because URLs map to resources. GraphQL uses POST to /graphql, so URL-based caching doesn’t work. You need application-level caching (Apollo Client, Relay) with normalized stores keyed by id and __typename. This is complex and error-prone.

N+1 queries: The default resolver model creates N+1 problems everywhere. Every production GraphQL API needs DataLoader or similar batching. This adds cognitive load and debugging difficulty.

Authorization complexity: REST authorizes at the endpoint level. GraphQL requires field-level authorization because clients compose arbitrary queries. A user might be able to see User.name but not User.email. This logic must live in every resolver or a shared authorization layer.

Query complexity attacks: Malicious clients can craft deeply nested queries that exhaust server resources: { users { posts { comments { author { posts { comments { ... } } } } } } }. Production systems need query complexity analysis, depth limiting, and rate limiting based on cost, not just request count.

Monitoring and debugging: REST has clear metrics (endpoint, status code, latency). GraphQL has one endpoint, so you need query-level tracing. Tools like Apollo Studio parse queries to extract operation names and field usage, but this is more complex than REST’s access logs.

GraphQL vs REST: Data Fetching Comparison

graph TB
    subgraph REST - Multiple Round-Trips
        Client1["Client"]
        R1["/users/1<br/><i>GET Request 1</i>"]
        R2["/users/1/posts<br/><i>GET Request 2</i>"]
        R3["/posts/123/comments<br/><i>GET Request 3</i>"]
        Over["Over-fetching:<br/>Gets all user fields<br/>even if only need name"]
        Under["Under-fetching:<br/>3 round-trips<br/>for related data"]
        
        Client1 --"1. Fetch user"--> R1
        R1 --"2. Fetch posts"--> R2
        R2 --"3. Fetch comments"--> R3
        R1 -.-> Over
        R3 -.-> Under
    end
    
    subgraph GraphQL - Single Request
        Client2["Client"]
        GQL["/graphql<br/><i>POST Request</i>"]
        Query["query {<br/>  user(id: 1) {<br/>    name<br/>    posts {<br/>      title<br/>      comments {<br/>        text<br/>      }<br/>    }<br/>  }<br/>}"]
        Exact["Exact data:<br/>Only requested fields<br/>in one round-trip"]
        Cache["⚠️ Trade-off:<br/>No URL-based caching<br/>Complex authorization"]
        
        Client2 --"1. Single query"--> GQL
        GQL --> Query
        Query -.-> Exact
        Query -.-> Cache
    end

REST requires multiple round-trips to fetch related data (under-fetching) and returns all fields even if only some are needed (over-fetching). GraphQL solves both by letting clients specify exactly what data they need in a single request, but trades REST’s simple URL-based caching for complex application-level caching.

Solving the N+1 Query Problem

The N+1 problem is GraphQL’s most notorious pitfall. It occurs when a resolver for a list field triggers individual queries for each item. Example: fetching 100 users and their posts makes 1 query for users, then 100 queries for posts (one per user).

DataLoader is the canonical solution, created by Facebook and now a standard pattern. It batches and caches requests within a single query execution. When a resolver calls postLoader.load(userId), DataLoader doesn’t immediately query the database. Instead, it waits until the current tick of the event loop completes, collects all load() calls, and makes one batched query: SELECT * FROM posts WHERE user_id IN (1,2,3,...,100).

DataLoader also provides per-request caching. If multiple fields request the same user, DataLoader returns the cached result. This is crucial because GraphQL’s execution model might call the same resolver multiple times in one query.

Implementation pattern: Create a DataLoader instance per request (stored in GraphQL context) with a batch function: new DataLoader(async (userIds) => { const posts = await db.query('SELECT * FROM posts WHERE user_id = ANY($1)', [userIds]); return userIds.map(id => posts.filter(p => p.user_id === id)); }). The batch function receives an array of keys and must return an array of values in the same order.

Database-level batching: Some ORMs (Prisma, TypeORM) automatically batch queries. When you call user.posts in a loop, they detect the pattern and issue a single WHERE user_id IN (...) query. This is less flexible than DataLoader but requires less code.

Query optimization: For simple cases, use SQL joins or GraphQL’s @include directive to conditionally fetch data. If a query doesn’t ask for posts, don’t join the posts table. This requires resolver logic that inspects the info argument to see which fields were requested.

Lookahead and query planning: Advanced implementations parse the entire query tree before execution and generate optimal SQL with joins. Hasura and Prisma do this automatically. The trade-off is less flexibility—you’re tightly coupling GraphQL to your database schema.

Monitoring: Track resolver execution counts and database query counts per GraphQL query. If a query makes 100+ DB queries, you have an N+1 problem. Tools like Apollo Studio show resolver-level metrics and flag expensive fields.

N+1 Problem and DataLoader Batching Solution

graph TB
    subgraph Without DataLoader - N+1 Problem
        Query1["Query: users { posts }"]
        DB1[("Database")]
        Q1["SELECT * FROM users<br/><i>1 query</i>"]
        Users1["100 users returned"]
        Q2["SELECT * FROM posts WHERE user_id = 1<br/>SELECT * FROM posts WHERE user_id = 2<br/>...<br/>SELECT * FROM posts WHERE user_id = 100<br/><i>100 queries!</i>"]
        Result1["Total: 101 queries<br/>⚠️ Performance disaster"]
        
        Query1 --> Q1
        Q1 --> DB1
        DB1 --> Users1
        Users1 --> Q2
        Q2 --> DB1
        DB1 --> Result1
    end
    
    subgraph With DataLoader - Batched
        Query2["Query: users { posts }"]
        DB2[("Database")]
        Q3["SELECT * FROM users<br/><i>1 query</i>"]
        Users2["100 users returned"]
        Loader["DataLoader<br/><i>Collects load() calls</i>"]
        Q4["SELECT * FROM posts<br/>WHERE user_id IN (1,2,...,100)<br/><i>1 batched query</i>"]
        Result2["Total: 2 queries<br/>✓ 50x faster"]
        
        Query2 --> Q3
        Q3 --> DB2
        DB2 --> Users2
        Users2 --"postLoader.load(userId)"--> Loader
        Loader --"Waits for event loop tick"--> Q4
        Q4 --> DB2
        DB2 --> Result2
    end

The N+1 problem occurs when resolvers for list fields make individual database queries per item (1 query for users + N queries for each user’s posts). DataLoader solves this by batching all load() calls within a single event loop tick into one query with WHERE IN, reducing 101 queries to just 2.

When to Use (and When Not To)

Choose GraphQL when:

Multiple clients with different data needs: You have iOS, Android, web, and internal tools hitting the same API. Each needs different fields and relationships. GraphQL eliminates the need for client-specific endpoints or massive over-fetching. Airbnb uses GraphQL to serve 10+ client platforms from one schema.

Relationship-heavy data models: Your domain is a graph of interconnected entities (social networks, e-commerce, content platforms). Fetching a user’s feed with posts, comments, likes, and authors is natural in GraphQL, painful in REST. Twitter’s GraphQL API powers their web and mobile apps with deeply nested queries.

Rapid frontend iteration: Your frontend team deploys daily and needs to add fields without backend changes. GraphQL’s additive evolution (new fields don’t break old queries) enables this. Shopify’s GraphQL API has 1000+ types and evolves continuously without versioning.

Strong typing and tooling: You want compile-time safety and auto-generated documentation. GraphQL’s introspection enables code generation (TypeScript types, React hooks) that catches bugs before production. GitHub’s GraphQL API generates TypeScript types for their web app.

Avoid GraphQL when:

Simple CRUD APIs: If your API is just create/read/update/delete on a few resources, REST is simpler. GraphQL’s complexity (schema, resolvers, DataLoader) isn’t justified. A blog API with posts and comments is better served by REST.

Heavy caching requirements: If you rely on CDN caching for performance (public content, high read:write ratio), REST’s URL-based caching is superior. GraphQL requires application-level caching, which is complex. News sites and documentation often stick with REST for this reason.

File uploads and downloads: GraphQL’s JSON-centric model doesn’t handle binary data well. You’ll end up with REST endpoints for uploads or complex multipart solutions. If your API is primarily file operations, use REST or gRPC.

Third-party integrations: If external systems need to call your API, REST is more widely understood. GraphQL requires clients to understand your schema and compose queries. Public APIs often provide both REST and GraphQL (GitHub, Shopify).

Alternatives: Consider REST for simple APIs with caching needs, gRPC for high-performance internal services, or tRPC for TypeScript-only full-stack apps with end-to-end type safety.

Real-World Examples

Facebook: GraphQL was born to power Facebook’s mobile apps. Their News Feed query fetches a user’s posts, friends who liked them, comments, and reactions in one request. The schema has 10,000+ types and serves billions of queries per day. They use aggressive caching (Redis for hot data), DataLoader for batching, and a custom query complexity analyzer that rejects queries exceeding a cost budget. Interesting detail: Facebook’s GraphQL servers are written in Hack (PHP variant) and use a custom execution engine that compiles queries to bytecode for performance.

GitHub: GitHub’s public GraphQL API replaced their REST API v3 as the recommended way to integrate. Developers can fetch a repository’s issues, pull requests, and commit history in one query instead of paginating through multiple REST endpoints. They enforce rate limiting based on query complexity (each field costs points, nested fields cost more). Interesting detail: GitHub’s schema uses Relay-style pagination (edges, nodes, cursors) and global object IDs (gid://github/User/123) for client-side caching. They serve 10M+ queries per day with p99 latency under 200ms.

Shopify: Shopify’s Storefront API is GraphQL-only and powers millions of online stores. Merchants query products, variants, inventory, and pricing in one request to render product pages. The schema has 500+ types and evolves weekly with new fields for features like subscriptions and gift cards. They use GraphQL Federation to split the schema across 20+ backend services. Interesting detail: Shopify’s GraphQL servers cache parsed queries in Redis and use a custom cost analysis algorithm that charges more for expensive fields like products(first: 250). They handle 100,000+ req/s during peak shopping events like Black Friday.


Interview Essentials

Mid-Level

Explain GraphQL’s core value proposition: clients request exactly the data they need in one round-trip. Walk through a schema example with types, queries, and mutations. Describe how resolvers work and the basic execution flow. Discuss the N+1 problem and mention DataLoader as the solution (you don’t need to implement it). Compare GraphQL to REST: over-fetching, under-fetching, and versioning. Be ready to write a simple schema and query. Interviewers want to see you understand the client-driven model and can identify when GraphQL is appropriate vs. REST.

Senior

Deep dive into GraphQL’s execution model: parsing, validation, resolver execution order, and how the engine builds the response tree. Explain DataLoader in detail: batching, caching, and implementation patterns. Discuss authorization strategies (field-level vs. resolver-level) and how to prevent query complexity attacks (depth limiting, cost analysis). Compare caching in GraphQL vs. REST and explain normalized client-side caches (Apollo, Relay). Discuss schema design: when to use interfaces vs. unions, pagination patterns (offset vs. cursor), and schema evolution without breaking changes. Be ready to debug an N+1 problem and propose solutions. Interviewers expect you to have built production GraphQL APIs and understand operational challenges.

Staff+

Architect a GraphQL system for a large-scale application. Discuss federation strategies for splitting schemas across microservices (Apollo Federation, schema stitching). Explain how to monitor and optimize GraphQL performance: query tracing, resolver-level metrics, and database query analysis. Design an authorization system that works at field level without duplicating logic. Discuss trade-offs between GraphQL and alternatives (REST, gRPC, tRPC) for different use cases. Explain how to handle real-time updates (subscriptions vs. polling) and the infrastructure required (WebSocket servers, pub/sub). Discuss schema governance: how to evolve a schema with 100+ engineers contributing types. Be ready to debate when GraphQL adds more complexity than value. Interviewers want to see you can make technology selection decisions and design systems that scale to millions of users.

Common Interview Questions

How does GraphQL solve over-fetching and under-fetching? (Answer: clients specify exactly which fields they need, server returns only those fields in one request)

What is the N+1 query problem and how do you solve it? (Answer: resolvers for list fields trigger individual queries per item; solve with DataLoader batching)

How do you handle authorization in GraphQL? (Answer: field-level checks in resolvers or a shared authorization layer; can’t rely on endpoint-level auth like REST)

How does caching work in GraphQL vs. REST? (Answer: REST uses URL-based HTTP caching; GraphQL needs application-level normalized caches keyed by id and __typename)

When would you choose REST over GraphQL? (Answer: simple CRUD APIs, heavy CDN caching needs, file uploads, third-party integrations)

How do you prevent malicious queries from exhausting server resources? (Answer: query complexity analysis, depth limiting, rate limiting based on cost not request count)

Explain GraphQL subscriptions and how they differ from queries. (Answer: long-lived connections that push updates when events occur; use WebSockets and pub/sub; different from one-shot request-response)

Red Flags to Avoid

Can’t explain the N+1 problem or thinks it’s not a real issue (it’s the #1 GraphQL performance trap)

Doesn’t understand the difference between queries and mutations (mutations have side effects and run serially by default)

Thinks GraphQL replaces REST in all cases (it’s a trade-off, not a strict upgrade)

Can’t explain how resolvers work or what arguments they receive (parent, args, context, info)

Doesn’t know how to handle authorization at field level (endpoint-level auth doesn’t work in GraphQL)

Hasn’t thought about query complexity attacks or rate limiting (critical for production APIs)

Thinks GraphQL automatically solves caching (it actually makes caching harder than REST)


Key Takeaways

GraphQL is a query language where clients specify exactly what data they need, eliminating over-fetching and under-fetching. The server exposes a strongly-typed schema, and clients compose queries that traverse relationships in one round-trip.

The N+1 query problem is GraphQL’s biggest performance trap. Resolvers for list fields trigger individual queries per item. Solve it with DataLoader batching, which collects requests and issues one batched query per request.

GraphQL trades REST’s simplicity for flexibility. You gain client-driven queries, strong typing, and rapid iteration without versioning. You lose URL-based caching, simple authorization, and straightforward monitoring. Choose GraphQL for relationship-heavy data models with multiple clients; stick with REST for simple CRUD APIs with heavy caching needs.

Authorization in GraphQL requires field-level checks because clients compose arbitrary queries. You can’t rely on endpoint-level auth like REST. Implement authorization in resolvers or a shared layer, and use query complexity analysis to prevent resource exhaustion attacks.

GraphQL’s execution model is a depth-first traversal of the query tree. The engine parses the query, validates it against the schema, calls resolvers recursively, and assembles the response. Understanding this flow is critical for debugging performance issues and designing efficient resolvers.