GraphQL interviews test your understanding of schema-first design, the type system, resolver execution, performance pitfalls (especially the N+1 problem), and real-world patterns like authentication and pagination. This guide covers the 50 most common questions — with concise answers and code examples.
Quick reference
| Topic | Most asked questions |
|---|---|
| Fundamentals | SDL, types, queries vs mutations vs subscriptions |
| Schema design | Object types, input types, unions, interfaces, enums |
| Resolvers | Resolver chain, context, args, parent |
| N+1 problem | DataLoader, batching, caching |
| Authentication | JWT in context, field-level auth, directives |
| Pagination | Cursor-based vs offset, Relay connection spec |
| Error handling | Partial success, error extensions, nullable vs non-null |
| Performance | Persisted queries, query depth/complexity limits |
| Subscriptions | WebSocket, server-sent events, real-time data |
| Federation | Apollo Federation, subgraphs, supergraph |
GraphQL fundamentals
1. What is GraphQL and how does it differ from REST?
GraphQL is a query language for APIs and a server-side runtime for executing those queries. Key differences from REST:
| Dimension | REST | GraphQL |
|---|---|---|
| Data fetching | Multiple endpoints, fixed response shape | Single endpoint, client specifies shape |
| Over-fetching | Common (extra fields returned) | Eliminated (request only what you need) |
| Under-fetching | Common (multiple requests needed) | Eliminated (fetch related data in one query) |
| Versioning | URL versioning (/v1, /v2) | Schema evolution with deprecations |
| Type system | OpenAPI/Swagger (optional) | Built-in, mandatory SDL |
| Caching | HTTP caching (ETags, Cache-Control) | Requires custom cache keys (persisted queries) |
| Tooling | Postman, cURL | GraphiQL, Apollo Studio, Altair |
GraphQL shines for complex, interconnected data and mobile clients where bandwidth matters. REST is simpler for CRUD-heavy APIs with predictable access patterns.
2. What is the GraphQL Schema Definition Language (SDL)?
SDL is the language used to describe a GraphQL schema — types, fields, and relationships.
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String
}
type Post {
id: ID!
title: String!
body: String!
author: User!
tags: [String!]!
}
type Query {
user(id: ID!): User
users: [User!]!
post(id: ID!): Post
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post
deletePost(id: ID!): Boolean!
}
input CreatePostInput {
title: String!
body: String!
tags: [String!]
}
The ! suffix means non-null. [Post!]! means a non-null list of non-null Posts.
3. What are the three root operation types in GraphQL?
| Operation | Purpose | Analogous REST |
|---|---|---|
Query |
Read data (idempotent) | GET |
Mutation |
Write/modify data | POST, PUT, PATCH, DELETE |
Subscription |
Real-time event stream | WebSocket / SSE |
Subscriptions maintain a long-lived connection and push updates when data changes.
4. What is the difference between a field and an argument in GraphQL?
Fields are the properties you can request on a type. Arguments are parameters passed to a field to filter, paginate, or transform the result.
# Field: name, email, posts
# Arguments: first, after (on the posts field)
query {
user(id: "1") { # "id" is an argument on the user field
name
email
posts(first: 10, after: "cursor123") { # arguments on posts field
id
title
}
}
}
5. What is an alias in GraphQL and when would you use it?
Aliases let you rename the result of a field, which is useful when fetching the same field with different arguments.
query {
activeUser: user(id: "1") {
name
email
}
suspendedUser: user(id: "2") {
name
email
}
}
Without aliases, two user fields would conflict in the response object.
6. What are fragments in GraphQL?
Fragments are reusable units of fields that can be spread across multiple queries — reducing duplication.
fragment UserBasic on User {
id
name
email
}
query {
user(id: "1") {
...UserBasic
posts {
id
title
}
}
anotherUser: user(id: "2") {
...UserBasic
}
}
Inline fragments are used for conditional fields based on type:
query {
search(term: "foo") {
... on User { name email }
... on Post { title body }
}
}
7. What is an introspection query?
Introspection lets clients query the schema itself — discovering types, fields, and their descriptions. GraphiQL and Apollo Studio use introspection to build their explorer UIs.
query {
__schema {
types {
name
kind
fields {
name
type { name kind }
}
}
}
}
Security note: Disable introspection in production to prevent schema enumeration by attackers.
Schema design
8. What is the difference between type, interface, and union in GraphQL?
| Construct | Purpose | Shared fields |
|---|---|---|
type |
Concrete object type | N/A |
interface |
Abstract type with guaranteed fields | Yes — implementors must include all interface fields |
union |
Group of unrelated types | No — members share nothing |
# Interface — all implementors have title and publishedAt
interface Content {
id: ID!
title: String!
publishedAt: String!
}
type Article implements Content {
id: ID!
title: String!
publishedAt: String!
wordCount: Int!
}
type Video implements Content {
id: ID!
title: String!
publishedAt: String!
durationSeconds: Int!
}
# Union — no shared fields required
union SearchResult = User | Post | Comment
Use interface when types share common fields; union when they are unrelated.
9. What are input types and why are they needed?
Input types (input) are used for mutation arguments. Regular object type cannot be used as argument types because they may contain fields with their own arguments or circular references.
# WRONG — cannot use regular type as argument
type Mutation {
createUser(user: User): User # Error!
}
# CORRECT — use input type
input CreateUserInput {
name: String!
email: String!
role: UserRole = USER
}
type Mutation {
createUser(input: CreateUserInput!): User!
}
10. What are GraphQL enums and scalars?
Enums restrict a field to a set of allowed values:
enum UserRole {
ADMIN
EDITOR
VIEWER
}
Scalars are primitive leaf types. Built-in scalars: Int, Float, String, Boolean, ID. Custom scalars extend the type system:
scalar DateTime
scalar JSON
scalar Upload
Custom scalars need serialise/parse/parseLiteral implementations in the server.
11. What is the @deprecated directive?
Directives annotate schema elements. @deprecated marks a field as obsolete without removing it, allowing graceful schema evolution:
type User {
id: ID!
name: String!
username: String @deprecated(reason: "Use `name` instead")
email: String!
}
Other built-in directives: @include(if: Boolean) and @skip(if: Boolean) for conditional field fetching on the client.
12. How do you handle nullable vs non-null fields?
Design non-null (!) carefully:
| Decision | When |
|---|---|
Non-null field field: Type! |
Data is always present — absence is a server error |
Nullable field field: Type |
Data may legitimately not exist |
Non-null list [Type!]! |
List always exists and has no null members |
Principle: Prefer nullable for resilience. A non-null violation makes the entire parent object null, propagating up the response tree. If root fields are non-null and throw, the entire data object becomes null.
Resolvers
13. What is a resolver and what arguments does it receive?
A resolver is a function that returns the value for a field. Every field has a resolver — if not explicitly defined, the default resolver reads the property from the parent object.
const resolvers = {
Query: {
user: (parent, args, context, info) => {
return context.db.users.findById(args.id);
},
},
User: {
posts: (parent, args, context, info) => {
return context.db.posts.findByUserId(parent.id);
},
},
};
| Argument | Contents |
|---|---|
parent |
Return value of the parent resolver (root resolvers get {}) |
args |
Arguments passed to the field in the query |
context |
Shared object across all resolvers (DB, auth, DataLoaders) |
info |
Query AST, field path, return type metadata |
14. How does the GraphQL resolver chain work?
Resolvers execute in a top-down, breadth-first order. Each resolver returns a value (or a Promise), and child resolvers run after their parent resolves.
Query.user() → { id: "1", name: "Alice" }
User.posts() → [{ id: "10" }, { id: "11" }]
Post.title() → default resolver: parent.title
Post.author() → fetch user for each post
The engine waits for all sibling promises at each level before moving to the next level.
15. What is the N+1 problem in GraphQL?
When fetching a list and then a related field for each item, the naive approach fires one query for the list plus N queries for the related data.
query {
posts { # 1 query: SELECT * FROM posts
title
author { # N queries: SELECT * FROM users WHERE id = ?
name
}
}
}
With 100 posts, this is 101 database queries. This is the N+1 problem.
16. How does DataLoader solve the N+1 problem?
DataLoader batches and caches individual loads within a single tick of the event loop.
import DataLoader from 'dataloader';
// Batch function: receives array of keys, returns array of values (same order)
const userLoader = new DataLoader(async (userIds: readonly string[]) => {
const users = await db.users.findMany({
where: { id: { in: userIds as string[] } },
});
// Must return values in the same order as keys
return userIds.map(id => users.find(u => u.id === id) ?? null);
});
// In resolver — called N times, but only 1 DB query fires
const resolvers = {
Post: {
author: (post, _, context) => context.userLoader.load(post.authorId),
},
};
// Attach to context per request (new loader per request for cache isolation)
const context = ({ req }) => ({
db,
userLoader: new DataLoader(batchUsers),
});
Instead of N queries, DataLoader coalesces all .load() calls into a single SELECT … WHERE id IN (…).
17. What is the context object and what should go in it?
Context is created once per request and passed to every resolver. It should contain:
- Authenticated user (decoded JWT)
- Database client / ORM instance
- DataLoader instances (one per request for cache isolation)
- Feature flags, request metadata
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');
const user = token ? verifyJWT(token) : null;
return {
user,
db,
loaders: createLoaders(db),
};
},
});
Authentication & authorization
18. How do you implement authentication in GraphQL?
The standard pattern is to validate a JWT (or session) in the context function, then access context.user in resolvers.
// Context function — runs before every request
context: ({ req }) => {
const auth = req.headers.authorization ?? '';
if (auth.startsWith('Bearer ')) {
try {
const user = jwt.verify(auth.slice(7), process.env.JWT_SECRET!);
return { user, db };
} catch {
// Token invalid — context.user will be null
}
}
return { user: null, db };
},
// Resolver — checks context.user
Query: {
me: (_, __, ctx) => {
if (!ctx.user) throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
return ctx.db.users.findById(ctx.user.id);
},
},
19. How do you implement field-level authorization?
Two common approaches:
1. In-resolver checks:
Post: {
secretNotes: (post, _, ctx) => {
if (ctx.user?.role !== 'ADMIN') return null; // or throw
return post.secretNotes;
},
},
2. Schema directives (declarative):
directive @auth(requires: Role = USER) on FIELD_DEFINITION
type Post {
title: String!
secretNotes: String @auth(requires: ADMIN)
}
Libraries like graphql-shield and pothos provide structured permission systems.
20. What is the difference between authentication and authorization in GraphQL?
| Concept | What | Where |
|---|---|---|
| Authentication | Who is making the request? | Context function — runs once per request |
| Authorization | What are they allowed to do? | Resolvers or schema directives — runs per field |
Pagination
21. What are the main pagination approaches in GraphQL?
| Approach | Pros | Cons |
|---|---|---|
Offset (limit/offset) |
Simple, easy to implement | Inconsistent with inserts/deletes, slow on large offsets |
| Cursor-based | Stable with mutations, consistent | Stateful cursors, harder to jump to page N |
| Relay connection spec | Standardised, works with most clients | More verbose schema |
22. What is the Relay Cursor Connection Specification?
Relay's connection spec provides a standard cursor-based pagination shape:
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
posts(first: Int, after: String, last: Int, before: String): PostConnection!
}
A cursor is typically a base64-encoded primary key or composite value. first/after paginate forward; last/before paginate backward.
Error handling
23. How does GraphQL handle errors?
GraphQL can return partial success: valid data is returned alongside errors for failed fields. The response always has data and optionally errors.
{
"data": {
"user": {
"name": "Alice",
"posts": null
}
},
"errors": [
{
"message": "Could not load posts",
"path": ["user", "posts"],
"locations": [{ "line": 5, "column": 5 }],
"extensions": {
"code": "INTERNAL_SERVER_ERROR"
}
}
]
}
24. What are error extensions and error codes?
Extensions provide machine-readable metadata on errors:
throw new GraphQLError('Resource not found', {
extensions: {
code: 'NOT_FOUND',
http: { status: 404 },
entityType: 'Post',
entityId: args.id,
},
});
Standardised codes (Apollo convention): BAD_USER_INPUT, UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, INTERNAL_SERVER_ERROR.
25. Should you use non-null (!) for mutation return types?
Generally no for the root mutation field. Returning a nullable type allows you to communicate failure via errors while still providing partial data. A non-null mutation that throws will null-propagate and erase any other data in the response.
# Preferred — nullable allows partial response
type Mutation {
createPost(input: CreatePostInput!): Post # nullable
}
# Union error pattern (explicit error types)
union CreatePostResult = Post | ValidationError | AuthError
type Mutation {
createPost(input: CreatePostInput!): CreatePostResult!
}
Performance
26. What are persisted queries and why do they matter?
Persisted queries store query strings server-side indexed by a hash. Clients send only the hash, not the full query text.
Benefits:
- Smaller HTTP payloads (hash vs full SDL string)
- Prevents arbitrary query execution in production (security)
- Enables GET requests + HTTP caching for queries
Apollo Client supports automatic persisted queries (APQ) out of the box.
27. How do you limit query depth and complexity?
Unlimited depth or complexity can cause expensive recursive queries (DOS risk):
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(7), // max 7 levels deep
createComplexityLimitRule(1000), // max complexity score
],
});
Complexity scores assign weights to fields (e.g., list fields cost more than scalar fields).
28. How does caching work in GraphQL?
HTTP-level GET caching works with persisted queries (same hash = same cache key). For field-level caching:
# Apollo Server @cacheControl directive
type Post {
id: ID!
title: String! @cacheControl(maxAge: 60) # cache 60s
author: User @cacheControl(scope: PRIVATE) # don't cache publicly
}
For more granular caching, use DataLoader (request-level cache) or external cache (Redis) in resolvers.
29. What is query batching?
Some GraphQL clients (Apollo) can batch multiple queries into a single HTTP request:
[
{ "query": "query { user(id: \"1\") { name } }" },
{ "query": "query { posts { title } }" }
]
The server processes them and returns an array of results. Apollo Server supports batching out of the box. This reduces round-trips but increases latency for independent queries that could resolve in parallel.
Subscriptions
30. How do GraphQL subscriptions work?
Subscriptions maintain a persistent connection between client and server, pushing updates when subscribed events occur.
type Subscription {
postCreated: Post!
messageReceived(roomId: ID!): Message!
}
// Server — using graphql-ws or subscriptions-transport-ws
const resolvers = {
Subscription: {
postCreated: {
subscribe: (_, __, { pubsub }) => pubsub.asyncIterableIterator('POST_CREATED'),
},
},
Mutation: {
createPost: async (_, { input }, { db, pubsub }) => {
const post = await db.posts.create(input);
pubsub.publish('POST_CREATED', { postCreated: post });
return post;
},
},
};
Transport options:
- WebSocket (
graphql-wslibrary) — full duplex, preferred - Server-Sent Events (SSE) — one-way, works through HTTP/2 proxies
31. What are the challenges with GraphQL subscriptions at scale?
- WebSocket connections are stateful — harder to load balance than HTTP
- Each subscription event triggers N resolver calls (one per connected client)
- PubSub must be shared across server instances (Redis pub/sub or Kafka)
- Connection limits: one WS per browser tab, thousands possible per server
Production pattern: use a dedicated subscription server or a managed service (Ably, Pusher, Hasura).
Apollo & tooling
32. What is Apollo Federation?
Apollo Federation splits a large GraphQL schema into independently deployable subgraphs, unified by a supergraph through the Apollo Router.
# Users subgraph
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
# Posts subgraph — extends User from Users subgraph
type Post {
id: ID!
title: String!
author: User! @provides(fields: "name")
}
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]!
}
The Apollo Router handles query planning: splitting a client query across subgraphs and merging results.
33. What is schema stitching and how does it differ from federation?
Schema stitching (older approach) merges schemas programmatically at the gateway level using SDL merging and type merging. Schemas must be aware of each other.
Apollo Federation (newer): Each subgraph declares its own types with federation directives (@key, @external, @provides, @requires). The supergraph is composed at build time using the Rover CLI. Subgraphs are fully independent.
Federation is the recommended approach for most multi-team deployments.
34. What is the difference between Apollo Client's cache policies?
Apollo Client normalises query results in a flat in-memory cache keyed by typename:id.
| Fetch policy | Behaviour |
|---|---|
cache-first |
Read from cache; hit network only on miss (default) |
cache-and-network |
Return cached data immediately, then update from network |
network-only |
Always hit network; write to cache |
no-cache |
Always hit network; do not write to cache |
cache-only |
Read from cache only; error on miss |
standby |
Like cache-only but no automatic refresh |
Advanced topics
35. What is the SDL-first vs code-first approach?
| Approach | Description | Tools |
|---|---|---|
| SDL-first | Write .graphql schema files; resolvers implement it |
Apollo Server, graphql-tools |
| Code-first | Generate SDL from TypeScript classes/decorators | Pothos, TypeGraphQL, NestJS GraphQL |
Code-first avoids schema/resolver divergence through static types; SDL-first keeps the schema human-readable and allows non-JS teams to contribute.
36. How do you test GraphQL APIs?
// Unit test — resolver in isolation
describe('userResolver', () => {
it('returns user by id', async () => {
const mockDb = { users: { findById: jest.fn().mockResolvedValue({ id: '1', name: 'Alice' }) } };
const result = await resolvers.Query.user(null, { id: '1' }, { db: mockDb }, null);
expect(result).toEqual({ id: '1', name: 'Alice' });
});
});
// Integration test — full server
import { ApolloServer } from '@apollo/server';
const { body } = await server.executeOperation({
query: `query { user(id: "1") { name } }`,
});
expect(body.singleResult.data?.user?.name).toBe('Alice');
For E2E, use graphql-request or Apollo Client's MockedProvider.
37. What is the info argument in a resolver?
info contains the query AST and field metadata. It is rarely needed but useful for:
- Lookahead: Determine which sub-fields were requested to optimise DB queries (e.g., skip a JOIN if the field wasn't requested)
- Field path: Know where in the query tree execution is
- Return type: Inspect the expected type for dynamic logic
import { parseResolveInfo } from 'graphql-parse-resolve-info';
Post: {
author: (post, args, ctx, info) => {
const fields = parseResolveInfo(info);
// Only load profile if it was requested
const includeProfile = !!fields?.fieldsByTypeName?.User?.profile;
return ctx.db.users.findById(post.authorId, { includeProfile });
},
},
38. What are custom directives and when would you use them?
Custom directives annotate schema elements and can transform field behaviour:
directive @upper on FIELD_DEFINITION
directive @auth(requires: Role!) on FIELD_DEFINITION
directive @rateLimit(max: Int!, window: String!) on FIELD_DEFINITION
Implementation (using mapSchema from @graphql-tools/utils):
function upperDirectiveTransformer(schema: GraphQLSchema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const directive = getDirective(schema, fieldConfig, 'upper')?.[0];
if (directive) {
const { resolve = defaultFieldResolver } = fieldConfig;
return {
...fieldConfig,
resolve: async (...args) => {
const result = await resolve(...args);
return typeof result === 'string' ? result.toUpperCase() : result;
},
};
}
},
});
}
39. What is the @defer and @stream directive?
@defer defers slow parts of a response and streams them incrementally:
query {
user(id: "1") {
name # returned immediately
... @defer {
friendCount # streamed when ready
recentActivity # streamed when ready
}
}
}
@stream streams list items one by one:
query {
posts @stream(initialCount: 5) {
title
}
}
These are incremental delivery features using multipart HTTP responses. Support varies by server library.
40. What is schema introspection and should you disable it in production?
Introspection allows clients to query the full schema. In production:
- Disable for public-facing APIs to prevent schema enumeration
- Keep enabled for internal/developer tools
- Use Apollo Studio's managed schema instead of public introspection
new ApolloServer({
introspection: process.env.NODE_ENV !== 'production',
});
GraphQL vs REST
41. When should you choose REST over GraphQL?
| Choose REST when | Choose GraphQL when |
|---|---|
| Simple CRUD with predictable access patterns | Complex, interconnected data |
| Public API consumed by third parties unfamiliar with GraphQL | Mobile clients needing bandwidth efficiency |
| Heavy HTTP caching requirements | Multiple clients with different data needs |
| Team lacks GraphQL experience | Rapid UI iteration without backend changes |
| File uploads are a primary use case | Data graph / relationship-heavy domain |
| Existing REST ecosystem is sufficient | Type-safe, self-documenting API |
42. How do you handle file uploads in GraphQL?
The graphql-multipart-request-spec extends GraphQL over multipart form data:
scalar Upload
type Mutation {
uploadAvatar(file: Upload!): String!
}
import { GraphQLUpload, graphqlUploadExpress } from 'graphql-upload';
app.use(graphqlUploadExpress());
const resolvers = {
Upload: GraphQLUpload,
Mutation: {
uploadAvatar: async (_, { file }) => {
const { createReadStream, filename, mimetype } = await file;
const stream = createReadStream();
// pipe to S3, disk, etc.
return savedUrl;
},
},
};
Note: File uploads are complex with Federation; many teams use a separate REST endpoint for uploads and GraphQL only for metadata.
Practical scenarios
43. How would you design a GraphQL schema for an e-commerce site?
type Product {
id: ID!
name: String!
price: Float!
stock: Int!
category: Category!
images: [Image!]!
reviews(first: Int, after: String): ReviewConnection!
}
type Cart {
id: ID!
items: [CartItem!]!
subtotal: Float!
itemCount: Int!
}
type CartItem {
product: Product!
quantity: Int!
lineTotal: Float!
}
type Query {
product(id: ID!): Product
products(category: ID, minPrice: Float, maxPrice: Float, first: Int, after: String): ProductConnection!
cart: Cart
}
type Mutation {
addToCart(productId: ID!, quantity: Int!): Cart!
updateCartItem(productId: ID!, quantity: Int!): Cart!
removeFromCart(productId: ID!): Cart!
checkout(input: CheckoutInput!): Order!
}
type Subscription {
orderStatusChanged(orderId: ID!): Order!
}
44. How do you implement real-time notifications with GraphQL?
// Server — subscription with filtering
Subscription: {
notification: {
subscribe: withFilter(
(_, __, { pubsub }) => pubsub.asyncIterableIterator('NOTIFICATION'),
(payload, variables, context) => {
// Only push to the correct user
return payload.notification.userId === context.user.id;
}
),
},
},
// Publish from mutation or background job
await pubsub.publish('NOTIFICATION', {
notification: { userId: targetUserId, message: 'Order shipped!' },
});
45. How do you implement search with filters in GraphQL?
input ProductFilter {
category: ID
minPrice: Float
maxPrice: Float
inStock: Boolean
tags: [String!]
}
enum SortField { PRICE CREATED_AT NAME POPULARITY }
enum SortOrder { ASC DESC }
input ProductSort {
field: SortField!
order: SortOrder! = ASC
}
type Query {
products(
filter: ProductFilter
sort: ProductSort
first: Int = 20
after: String
): ProductConnection!
}
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Not using DataLoader | N+1 queries, poor performance | Create DataLoaders per request in context |
| Shared DataLoader across requests | Cache pollution between users | New DataLoader instance per request |
Over-using non-null (!) |
Error nullifies parent chain | Use nullable on complex/joined fields |
Exposing DB IDs as opaque IDs |
Client has to know they're integers | Use UUIDs or base64-encoded global IDs |
| Introspection enabled in production | Schema enumeration attack | Disable introspection in prod config |
| Mutations returning Boolean | Client can't know what changed | Return the modified object |
| Fat resolvers | Hard to test, violates SRP | Extract business logic into service layer |
| No depth/complexity limits | DoS via deeply nested queries | Add graphql-depth-limit and complexity rules |
GraphQL vs REST vs gRPC
| Dimension | REST | GraphQL | gRPC |
|---|---|---|---|
| Protocol | HTTP | HTTP (any) | HTTP/2 |
| Message format | JSON | JSON | Protocol Buffers |
| Schema | Optional (OpenAPI) | Mandatory (SDL) | Mandatory (.proto) |
| Client control | None — server decides | Full — client specifies fields | None — server decides |
| Real-time | Polling / SSE | Subscriptions (WS/SSE) | Streaming RPCs |
| Browser native | Yes | Yes | No (needs grpc-web) |
| Code generation | Optional | Optional | Mandatory |
| Best for | Public APIs, CRUD | Complex graphs, mobile | Internal microservices, streaming |
6 FAQ
Q: Is GraphQL always better than REST? No. REST is simpler, easier to cache, and better understood. GraphQL excels when multiple clients have diverging data needs, or when the data model is a graph. Use GraphQL where it solves a real problem — not by default.
Q: How do you version a GraphQL API?
GraphQL avoids versioning through deprecation: add new fields, deprecate old ones, remove only after clients migrate. If breaking changes are unavoidable, run two schemas in parallel (e.g., /graphql/v1 vs /graphql) during transition.
Q: Can GraphQL coexist with REST in the same project?
Yes. Many teams add GraphQL incrementally alongside REST, or use GraphQL as a gateway over REST microservices (using @graphql-tools/wrap).
Q: How does GraphQL affect SEO? GraphQL is a transport layer — it doesn't affect SEO directly. Server-side rendering (Next.js, Nuxt) still works; the HTML sent to crawlers is the same.
Q: What is the biggest performance risk with GraphQL? The N+1 problem and unbounded query complexity. Both are solvable (DataLoader + depth/complexity limits), but require intentional configuration.
Q: What is the difference between Apollo Server 3 and Apollo Server 4?
Apollo Server 4 introduced a framework-agnostic startStandaloneServer, removed the built-in Express integration (now expressMiddleware), deprecated apollo-server-* packages in favour of @apollo/server, improved TypeScript support, and improved context typing. The configuration API changed significantly.