What is GraphQL?
GraphQL is a query language and runtime for APIs. Instead of exposing many fixed endpoints, a GraphQL server exposes a single typed schema, and clients send queries that select exactly the fields they need. The server resolves each field and returns a response shaped precisely like the request.
Facebook built it in 2012 to solve a mobile problem: REST endpoints returned too much or too little data, and fetching a screen’s worth of related information meant many round trips. GraphQL fixed both by letting the client describe its data requirements in one document.
The schema
The schema is the contract. It defines the types and the operations available.
# schema.graphql
type User {
id: ID!
name: String!
email: String
posts(first: Int = 10): [Post!]!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
}
type Query {
user(id: ID!): User
posts(limit: Int): [Post!]!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
}
The ! marks a field as non-null. Types compose into a graph, which is why a single query can walk from a user to their posts and back to authors. The schema is introspectable, so tooling can generate documentation, types and autocompletion automatically.
Queries
A query selects fields from the root query type, with arguments and variables.
# posts.graphql
query RecentPosts($limit: Int!) {
posts(limit: $limit) {
id
title
author {
name
}
}
}
The response mirrors the selection exactly: no extra fields, no missing ones. Variables keep queries reusable and let the server validate argument types. Fragments extract repeated selections for reuse.
# fragment.graphql
fragment PostCard on Post {
id
title
author { name }
}
query Feed {
posts { ...PostCard }
}
Mutations and subscriptions
A mutation changes data and returns a defined shape, often including the updated object so the client can update its cache.
# create.graphql
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
}
}
A subscription streams real-time updates over a persistent connection, such as new messages or live notifications. Use it when the server must push to the client; for occasional refreshes, polling or a refetch is simpler.
Resolvers
Resolvers are where the schema meets your data. Each field has a resolver that returns its value.
// resolvers.js
const resolvers = {
Query: {
user: (_parent, { id }, { db }) => db.users.findById(id),
posts: (_parent, { limit = 10 }, { db }) => db.posts.findMany({ limit }),
},
User: {
posts: (user, { first }, { db }) =>
db.posts.findMany({ where: { authorId: user.id }, limit: first }),
},
Mutation: {
createPost: (_parent, { input }, { db }) => db.posts.create(input),
},
};
The context object carries shared resources such as the database and the authenticated user. Resolvers should be small, and authorization checks belong here or in the service layer they call — never on the client.
The N+1 problem
The most common GraphQL performance trap is N+1 queries. If a query returns ten posts and each post’s author resolver hits the database, you run eleven queries.
DataLoader solves it by batching and caching calls within a single request.
// loaders.js
import DataLoader from "dataloader";
function createUserLoader(db) {
return new DataLoader(async (ids) => {
const users = await db.users.findByIds(ids);
return ids.map((id) => users.find((u) => u.id === id));
});
}
The author resolver then calls loaders.user.load(post.authorId), and DataLoader collects all the loads in the tick into one query. This is the single most important optimisation in a GraphQL server.
Clients and caching
Client libraries such as Apollo Client and urql provide caching, normalisation, loading and error state, and integration with UI frameworks. A normalised cache stores entities by type and id, so a mutation that returns an updated post automatically updates every query that referenced it.
Because GraphQL typically uses a single POST endpoint, HTTP caching is less effective than with REST. Clients compensate with normalised caches, and servers use persisted queries and response caching. Design your mutations to return the changed objects so the client cache can stay consistent.
GraphQL or REST?
GraphQL shines when:
- Clients need different fields for different screens.
- A screen needs data from many related resources.
- You want a single, typed, self-documenting API.
REST is simpler when:
- Resources map cleanly to endpoints.
- HTTP caching and status codes matter.
- The API is small and stable.
Many teams run both, using GraphQL for the product API and REST for webhooks, file uploads and simple public endpoints.
Best practices
- Design the schema around the client’s needs, not the database tables.
- Use non-null types deliberately and version the schema with deprecations.
- Keep resolvers thin and push logic into services.
- Solve N+1 with DataLoader from the start.
- Add query depth and complexity limits to prevent abuse.
- Return changed objects from mutations for cache consistency.
- Use persisted queries in production to reduce payload size.
Common mistakes
- Exposing the database schema directly as the GraphQL schema.
- Ignoring N+1 until performance collapses.
- Fetching everything in every query and losing the main benefit.
- Trusting client-supplied authorization or field-level permissions.
- Building deeply nested queries with no depth limit.
- Assuming HTTP caching works the same as with REST.
Where to go next
GraphQL is a powerful way to shape APIs around clients. Ground it in the HTTP protocol, build the server on Node.js, and consume it from React with a client library. Then model a screen you know well as a schema and query, and see how naturally the data requirements map.