Building a GraphQL API with Node.js
Schema-first design with Apollo Server — type definitions, resolver arguments, the context object, mutations, subscriptions, and DataLoader to prevent the N+1 problem.

The first time you wire up a GraphQL resolver for a list of posts and each post’s author, everything looks clean. Your schema is typed, the query is flexible, and the client gets exactly what it asked for. Then you check your database logs and find 51 queries for what should have been two.
That problem has a name — the N+1 problem — and it’s built into how GraphQL executes resolvers. Understanding it before you write your first resolver is the difference between a schema that scales and one that silently hammers your database at 50 concurrent users. If you haven’t decided between GraphQL and REST yet, the GraphQL vs REST comparison covers those trade-offs. This post assumes you’ve made that call and walks through the actual build.
Quick answer: To build a GraphQL API with Node.js, define your schema using SDL type definitions, write resolver functions that match each field with
(parent, args, context, info)parameters, pass shared resources like a database client through a context factory function, and install DataLoader for any resolver that loads related records from a list. Apollo Server handles the HTTP layer and wires all three together.
On this page
- Start with the schema, not the resolvers
- What the four resolver arguments actually do
- The context object: shared resources without globals
- Mutations and input types
- Subscriptions need a stateful transport
- DataLoader: fix N+1 before it ships
- The schema is a contract, not just a description
Start with the schema, not the resolvers
The schema is the contract your API signs with every client. Once it’s published, changing a field name or removing a non-null field is a breaking change. That makes the shape of your types important — get it right before writing a single resolver.
GraphQL uses SDL (Schema Definition Language) for this. Every type is a named object with typed fields. Three special root types — Query, Mutation, and Subscription — define what operations clients can execute.
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
author: User!
}
type Query {
user(id: ID!): User
posts: [Post!]!
}
The ! means non-null — the server promises never to return null for that field. [Post!]! is a non-null list of non-null posts. Fields without ! are nullable, and clients must handle that case. The GraphQL type system documentation covers the full nullability rules, including the subtle difference between [Post]! and [Post!]!.
In Apollo Server, pass the SDL string as typeDefs. Adding #graphql at the start of the template literal turns on syntax highlighting in supporting editors without changing anything about how the code runs.
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
const typeDefs = `#graphql
type User {
id: ID!
name: String!
posts: [Post!]!
}
type Query {
user(id: ID!): User
posts: [Post!]!
}
`;
The schema is also introspectable by default — clients can query __schema to discover all types and fields at runtime. This is how tools like code generators work. They build TypeScript types directly from the schema, not from separate documentation.
What the four resolver arguments actually do
A resolver is the function that provides data for a field. You only write resolvers where the default behaviour isn’t enough. The default for a field named name on a type is: return parent.name. That covers most scalar fields on simple types.
Every resolver receives four arguments, as documented in the GraphQL execution specification:
parent: The resolved value of the parent field. For rootQueryfields, this is usually unused.args: The field’s arguments from the query, as an object.context: Shared state for the entire request — your database connection, auth info, DataLoader instances.info: Metadata about the current execution. Most resolvers ignore this.
const resolvers = {
Query: {
user: (_parent, args: { id: string }, context) => {
return context.db.findUser(args.id);
},
posts: (_parent, _args, context) => {
return context.db.findAllPosts();
},
},
User: {
posts: (parent, _args, context) => {
return context.db.findPostsByUserId(parent.id);
},
},
};
The User.posts resolver is where the N+1 problem enters. If Query.posts returns 50 posts and each post has an author field, GraphQL calls the Post.author resolver once per post. That’s 50 separate database round trips — and the logs look fine until traffic rises.
The context object: shared resources without globals
The context object is how you pass your database connection, authenticated user, or any other request-scoped resource to every resolver without using module-level globals or dependency injection frameworks.
In Apollo Server 4/5, you provide a context function to startStandaloneServer. It runs once per request and returns whatever object your resolvers will receive as their third argument.
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
const token = req.headers.authorization ?? '';
const currentUser = await verifyToken(token).catch(() => null);
return {
db,
currentUser,
};
},
});
This is also the right place to attach the authenticated user. Resolvers check context.currentUser for authorization without each one parsing the token separately. Handle auth failures here — verifyToken returning null for an invalid token is cleaner than letting it throw into every resolver.
DataLoader instances belong here too. Create them inside the context factory so each request gets fresh loaders. If you create loaders at module level, cache entries from one user’s request leak into another’s.
Mutations and input types
A mutation writes data. The structure mirrors queries: define fields on the Mutation type, wire up resolvers.
The one structural requirement that trips people up: complex mutation arguments need an input type, not a regular type. The input keyword marks it as input-only — it can’t have resolvers and can’t be used as an output type.
input CreatePostInput {
title: String!
authorId: ID!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
}
const resolvers = {
Mutation: {
createPost: (_parent, { input }, context) => {
return context.db.insertPost({
title: input.title,
authorId: input.authorId,
});
},
},
};
One behaviour worth knowing: when a client sends multiple mutations in a single request, GraphQL runs them serially — one after the other, in order. Queries run in parallel. The GraphQL specification mandates this because mutations produce side effects and running two writes to the same record concurrently would produce unpredictable results.
Subscriptions need a stateful transport
A subscription is a real-time operation. The client opens a persistent connection and receives data pushed from the server as events occur.
type Subscription {
postCreated: Post!
}
Unlike queries and mutations, subscriptions require a transport that can maintain state across requests — WebSockets or Server-Sent Events. The GraphQL subscriptions documentation notes the architectural consequence: each subscribed client must stay bound to a specific server instance. Horizontal scaling requires a shared pub/sub layer — typically Redis — to fan events out across instances.
Apollo Server supports subscriptions via graphql-ws. The setup is more involved than the standalone HTTP server: separate HTTP and WebSocket handlers on the same port, a pub/sub event emitter, and subscription resolvers that return async iterators rather than plain values.
I’d start without subscriptions and add them only when polling genuinely doesn’t meet the latency requirement. The infrastructure overhead is real, and debugging a broken subscription transport is harder than debugging a broken query. For most features that feel like they “need” real-time, a polling interval under five seconds works fine until you have evidence otherwise.
DataLoader: fix N+1 before it ships
The GraphQL performance documentation describes the N+1 problem clearly: a query for 50 posts with each post’s author triggers 1 query for all posts, then 50 individual queries for each author. That’s 51 database round trips instead of 2.
DataLoader, a library originally created by Facebook, fixes this through batching. It collects all individual load(id) calls that happen within a single event loop tick and issues them as one batch query.
import DataLoader from 'dataloader';
const createUserLoader = (db: Database) =>
new DataLoader<string, User | null>(async (ids) => {
const users = await db.findUsersByIds(ids as string[]);
// Must return values in the same order as the ids array
return ids.map((id) => users.find((u) => u.id === id) ?? null);
});
Here’s the part that surprises most people: the batch function must return values in the exact same order as the input IDs array. Position 0 in the return array must match position 0 in the IDs array. If a requested ID has no matching record, return null or an Error at that position — never just skip it. Getting the ordering wrong produces silently incorrect data. DataLoader doesn’t validate it.
Use the loader in resolvers through the context:
const resolvers = {
Post: {
author: (parent, _args, context) => {
return context.userLoader.load(parent.authorId);
},
},
};
And create a fresh instance per request in the context factory:
context: async () => ({
db,
userLoader: createUserLoader(db),
}),
Check this before going to production
- Every resolver loading a related object by ID uses DataLoader, not a direct database query
- DataLoader instances live inside the context factory — not at module level
- The batch function returns values in the same order as the input IDs
- Subscriptions have a defined pub/sub transport and a horizontal scaling plan if needed
- The context factory handles auth failures gracefully rather than throwing into resolvers
The schema is a contract, not just a description
Adding fields to a GraphQL schema is safe — existing clients ignore fields they don’t request. But removing a non-null field, renaming a type, or making a nullable field non-null breaks clients without warning. The type system isn’t documentation. It’s an enforcement layer.
Use the @deprecated directive to retire fields gradually:
type User {
name: String @deprecated(reason: "Use fullName instead.")
fullName: String!
}
Clients still see deprecated fields in introspection results, but GraphQL-aware tooling warns them to migrate. This gives teams a window to update before you remove the field entirely.
For the data layer, a Prisma-backed schema maps cleanly to GraphQL resolvers — the Prisma query API handles relationships in a way that plays well with DataLoader’s batching. If you’re using TypeScript, graphql-code-generator can derive TypeScript types directly from your schema and keep your resolver function signatures type-checked end to end. The Express REST API guide is useful context if you need to run GraphQL alongside REST endpoints in the same Node.js project.
The practical rule: treat schema changes the way you’d treat a database migration. Think about what you’re removing and who depends on it before you merge. The schema is the public API surface, and breaking it silently is how you get hard-to-trace errors in production clients.
Sources
- Schemas and Types — GraphQL official documentation
- Execution — GraphQL official documentation
- Subscriptions — GraphQL official documentation
- Performance and the N+1 Problem — GraphQL official documentation
- Get Started with Apollo Server — Apollo documentation
- GraphQL Specification, October 2021 Edition — The GraphQL Foundation