Quick start (up and running in 5 minutes)
Want to quickly spin up a GraphQL server to experiment with without dealing with cumbersome configuration? The trio of Node.js, TypeScript, and Apollo Server v4 lets you build a working prototype in just a few minutes.
First, initialize the project directory and install the necessary dependencies:
mkdir graphql-ts-quickstart && cd graphql-ts-quickstart
npm init -y
npm install @apollo/server graphql
npm install -D typescript @types/node tsx
npx tsc --init
Create a src/index.ts file and paste the minimal code below:
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
// 1. Define Schema
const typeDefs = `#graphql
type Book {
id: ID!
title: String!
author: String!
}
type Query {
books: [Book!]!
}
`;
// 2. Mock data
const books = [
{ id: '1', title: 'Clean Architecture', author: 'Robert C. Martin' },
{ id: '2', title: 'Refactoring', author: 'Martin Fowler' },
];
// 3. Define Resolvers
const resolvers = {
Query: {
books: () => books,
},
};
// 4. Start the server
async function bootstrap() {
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`🚀 Server ready at: ${url}`);
}
bootstrap();
Start the server with the command:
npx tsx src/index.ts
Open http://localhost:4000 in your browser. The Apollo Sandbox interface will appear immediately. Now, simply test the query { books { title author } } and click Run to verify the result.
Deep dive: Schema and Resolvers
The problem: Why do REST APIs struggle in complex applications?
Imagine you are building a feed screen for a mobile app. The UI needs to assemble various pieces of data: user profile info, a list of posts, recent comments, and like counts. Under a traditional REST architecture, the frontend team repeatedly runs into two major bottlenecks:
- Over-fetching: You only need
nameandavatarUrlto display a friends list. Yet the/api/usersendpoint generously bundles phone numbers, birthdates, home addresses, and transaction histories. The payload bloats from a few kilobytes to hundreds of kilobytes unnecessarily. - Under-fetching: A single endpoint cannot aggregate all the needed data. To construct a profile page, the client must reluctantly fire 3 to 4 sequential requests: fetch
/users/1, wait, call/users/1/posts, and then loop through each post to retrieve comments. Over unstable 3G/4G connections, cumulative latency easily exceeds 800ms.
GraphQL completely changes the game. The client has full authority to specify the exact data structure it requires. Whatever fields the client requests, the server returns—not a single byte more, not a single byte less.
Schema design: The standard contract
In GraphQL, the Schema serves as a binding contract between frontend and backend (Contract-Driven Development). Apollo Server uses SDL (Schema Definition Language), enabling you to define types, queries, and mutations in a clear, readable syntax.
Let’s look at an example schema for a blog feature with a relationship between User and Post:
export const typeDefs = `#graphql
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
authorId: ID!
author: User!
}
type Query {
posts: [Post!]!
user(id: ID!): User
}
input CreatePostInput {
title: String!
content: String!
authorId: ID!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
}
`;
Writing type-safe Resolvers with TypeScript
If the Schema is the blueprint, the Resolver is the builder. Each Resolver is a function responsible for fetching data for a specific field. It consistently takes 4 standard parameters: (parent, args, contextValue, info).
In large projects, you should use GraphQL Code Generator to automatically generate types from your schema. For the scope of this article, defining TypeScript interfaces for our in-memory models is sufficient to keep code clear and leverage compile-time type safety:
export interface UserModel {
id: string;
name: string;
email: string;
}
export interface PostModel {
id: string;
title: string;
content: string;
authorId: string;
}
// Simulated in-memory database
export const fakeUsers: UserModel[] = [
{ id: 'u1', name: 'Nguyen Van A', email: '[email protected]' },
{ id: 'u2', name: 'Tran Thi B', email: '[email protected]' },
];
export const fakePosts: PostModel[] = [
{ id: 'p1', title: 'Where to start with TypeScript?', content: 'Post 1 content...', authorId: 'u1' },
{ id: 'p2', title: 'Optimizing Node.js Backends', content: 'Post 2 content...', authorId: 'u1' },
{ id: 'p3', title: 'Developer interview tips', content: 'Post 3 content...', authorId: 'u2' },
];
Advanced: Eliminating N+1 Queries with DataLoader
The N+1 Query problem: A ticking time bomb in GraphQL
Most developers new to GraphQL tend to write resolvers with an intuitive, straightforward approach. Look at how data is mapped between Post and Author below:
export const resolvers = {
Query: {
posts: (): PostModel[] => {
console.log('Query: Fetching posts list');
return fakePosts;
},
},
Post: {
author: (parent: PostModel): UserModel | undefined => {
console.log(`Field Resolver: Finding user id ${parent.authorId}`);
return fakeUsers.find((u) => u.id === parent.authorId);
},
},
};
The client sends a very typical nested query:
query GetPostsWithAuthors {
posts {
id
title
author {
name
}
}
}
Observing the terminal, you will see output like this:
Query: Fetching posts list (1 time)
Field Resolver: Finding user id u1
Field Resolver: Finding user id u1
Field Resolver: Finding user id u2
Three posts trigger the child resolver 3 times. What happens if the query returns 100 posts? The Post.author resolver will fire exactly 100 times.
In a production application connected to PostgreSQL or MySQL, the server executes 1 query to fetch posts + 100 individual queries to fetch author details. Under a load of 100 requests/second, the database suddenly bears over 10,000 queries/second. Consequently, connection pools are depleted, CPU hits 100%, and the server quickly becomes unresponsive.
What is the root cause?
GraphQL resolves data via depth-first tree traversal. Each child resolver executes completely independently. They only know the data passed down from their parent, with zero awareness of what sibling resolvers are fetching to batch requests together.
The solution: Batching & Caching with DataLoader
To break this deadlock, Meta created the dataloader library. Its mechanism is remarkably smart: leveraging the microtask queue in the Node.js Event Loop to perform batching.
Within the same execution tick, instead of firing individual queries immediately, DataLoader defers execution by a few microseconds. It waits for all sibling resolvers to register their requested IDs and bundles them into a single array. From 100 piecemeal queries, DataLoader consolidates them into a single query: SELECT * FROM users WHERE id IN ('u1', 'u2', ...).
Install the package into your project:
npm install dataloader
Initialize DataLoader and attach it to Apollo Server’s context:
import DataLoader from 'dataloader';
import { UserModel, fakeUsers, PostModel, fakePosts } from './data';
// Batch load function: Accepts an array of IDs and returns the matching User array
async function batchUsers(userIds: readonly string[]): Promise<(UserModel | null)[]> {
console.log(`--> DataLoader batching query for IDs: [${userIds.join(', ')}]`);
// Simulated SQL query: SELECT * FROM users WHERE id IN (...)
const users = fakeUsers.filter((u) => userIds.includes(u.id));
// REQUIRED: The returned array must match the length and index order of userIds
return userIds.map((id) => users.find((u) => u.id === id) || null);
}
export interface GraphQLContext {
userLoader: DataLoader<string, UserModel | null>;
}
export function createContext(): GraphQLContext {
return {
userLoader: new DataLoader<string, UserModel | null>(batchUsers),
};
}
Update the resolver to access DataLoader via context:
export const resolvers = {
Query: {
posts: (): PostModel[] => fakePosts,
},
Post: {
author: async (parent: PostModel, _: unknown, context: GraphQLContext) => {
// Enqueue ID into DataLoader instead of querying the DB directly
return context.userLoader.load(parent.authorId);
},
},
};
Reconfigure the server initialization with the context factory:
const server = new ApolloServer<GraphQLContext>({
typeDefs,
resolvers,
});
const { url } = await startStandaloneServer(server, {
context: async () => createContext(),
listen: { port: 4000 },
});
Running the previous query with 3 posts again, the terminal will now display:
--> DataLoader batching query for IDs: [u1, u2]
DataLoader just accomplished two tasks simultaneously: automatically batching IDs and deduplicating them. Instead of 3 isolated queries, the server now performs only 1 single data fetch.
Battle-tested practices for running GraphQL in production
- DataLoader lifecycle (request-scoped): Never create DataLoader as a global singleton. You must always instantiate a fresh DataLoader inside the
createContext()function for each request. Sharing it across requests can easily cause User B to read cached sensitive data belonging to User A from a previous query. - The vital ordering rule in batch functions: DataLoader strictly requires the returned array to have the exact same length as the input keys array and match its index order. If the database returns records out of order and you fail to remap them to the input ID array, one user’s post will mistakenly display someone else’s author name.
- Managing JSON payloads: Debugging nested resolvers often produces complex JSON payloads. You can use handy online utilities like toolcraft.app (such as the JSON Formatter at
toolcraft.app/en/tools/developer/json-formatter) to quickly format, validate, or inspect data without having to install heavy browser extensions. - Preventing DoS with Query Depth Limiting: Schemas with bidirectional relationships are vulnerable to DoS attacks via cyclical nested queries:
posts { author { posts { author { posts { ... } } } } }. Install thegraphql-depth-limitpackage immediately to restrict maximum query depth (typically 4 to 5 nested levels) before a request ever reaches your resolvers.
