Lesson 4
Using Apollo Server 4 to Solve the N+1 Problem with Data Loaders
Deprecation Notice

This course path teaches Apollo Server 3, which has been deprecated. To access the new course paths covering Apollo Server 4, use this course path.

Introduction to the N+1 Problem and Data Loaders

In this lesson, we’re going to address a common performance issue in GraphQL known as the N+1 problem and how to solve it using Data Loaders.

The N+1 problem occurs when your GraphQL server makes an excessive number of database or API calls to satisfy nested queries. For example, if you fetch a list of books along with their authors, your server might make one query to get the books (1 query) and then one additional query per book to get the author (N queries), leading to a total of N+1 queries. This can significantly degrade the performance of your application.

Data Loaders help to batch and cache the requests, effectively reducing the number of queries made and improving performance.

Benefits of Using Data Loaders:

  • Batching: Combines multiple requests into a single batch query.
  • Caching: Reduces redundant queries by remembering previously fetched results.
Defining the GraphQL Schema

In GraphQL, the schema defines the shape of the data and the queries you can perform. To illustrate how Data Loaders can solve the N+1 problem, we’ll create a simple GraphQL schema with authors and books.

Here’s how you can define the schema:

TypeScript
1import { ApolloServer } from '@apollo/server'; 2 3// Sample data 4const authors = [ 5 { id: '1', name: 'J.R.R. Tolkien' }, 6 { id: '2', name: 'J.K. Rowling' } 7]; 8const books = [ 9 { id: '1', title: 'The Hobbit', author: '1' }, 10 { id: '2', title: 'Harry Potter', author: '2' } 11]; 12 13// Define schema 14const typeDefs = `#graphql 15 type Author { 16 id: ID! 17 name: String! 18 } 19 20 type Book { 21 id: ID! 22 title: String! 23 author: Author 24 } 25 26 type Query { 27 books: [Book] 28 author(id: ID!): Author 29 } 30`;

In this schema:

  • We define Author and Book types.
  • The Book type has a nested Author type.
  • The Query type fetches a list of books and a single author by ID.
Data Loaders Primary Functions

Data Loaders serve two primary functions: batching and caching requests.

  • Batching: Data Loaders collect multiple requests made in a single event loop tick and combine them into a single query, reducing the total number of database/API calls.

For example, consider the difference:

SQL
1SELECT id, name FROM books WHERE id in (1, 2, 3, ..., 100);

versus

SQL
1SELECT id, name FROM books WHERE id = 1; 2SELECT id, name FROM books WHERE id = 2; 3SELECT id, name FROM books WHERE id = 3; 4... 5SELECT id, name FROM books WHERE id = 100;
  • Caching: Once a piece of data is fetched, Data Loaders cache the result. If the same data is requested again, the Data Loader returns the cached value instead of making another request.
Implementing Resolvers with Data Loaders

Before implementing the resolvers, we need to initialize a Data Loader for batching and caching authors:

TypeScript
1import DataLoader from 'dataloader'; 2 3// Initialize DataLoader 4const authorLoader = new DataLoader(async (ids: readonly string[]) => { 5 return ids.map(id => authors.find(author => author.id === id) as typeof authors[0]); 6});

Here’s how you can implement resolvers using Data Loaders:

TypeScript
1// Resolvers 2const resolvers = { 3 Query: { 4 books: () => books, 5 author: async (_: unknown, { id }: { id: string }, { authorLoader }: { authorLoader: DataLoader<string, typeof authors[0]> }) => authorLoader.load(id), 6 }, 7 Book: { 8 author: async (book: { author: string }, _: unknown, { authorLoader }: { authorLoader: DataLoader<string, typeof authors[0]> }) => { 9 return authorLoader.load(book.author) 10 } 11 } 12};

In this example:

  • Query Resolvers:
    • books resolver: Directly returns the full list of books without any need for batching or caching.
    • author resolver uses authorLoader.load(id) to fetch an author by ID. The data loader collects all author requests made during the same event loop tick, batches them into a single operation, and caches the results.
  • Nested Resolvers:
    • Book.author resolver:
      • Executed when a query requests the author of a Book, calls authorLoader.load(book.author) to batch and cache the results.
      • If multiple books request their authors, the Data Loader collects all these requests, batches them into a single operation, fetching all authors together.
Initializing and Using Data Loaders

Now, let’s integrate the initialized Data Loader into our Apollo Server using Apollo Server 4:

TypeScript
1import { startStandaloneServer } from '@apollo/server/standalone'; 2 3// Initialize Apollo Server 4const server = new ApolloServer({ 5 typeDefs, 6 resolvers 7}); 8 9// Start the server 10startStandaloneServer(server, { 11 context: async () => ({ authorLoader }), 12 listen: { port: 4000 } 13}).then(({ url }) => { 14 console.log(`🚀 Server ready at ${url}`); 15});

Here’s what’s happening:

  • The context function adds the Data Loader to the context so it is available to the resolvers.
  • We've used startStandaloneServer from '@apollo/server/standalone' to start the server, which also takes care of server creation and context configuration in one step.
Testing Your Implementation

Finally, let's test our implementation by running some queries:

TypeScript
1import fetch from 'node-fetch'; 2 3const query = ` 4 query { 5 books { 6 title 7 author { 8 name 9 } 10 } 11 } 12`; 13 14const url = 'http://localhost:4000/'; 15 16fetch(url, { 17 method: 'POST', 18 headers: { 19 'Content-Type': 'application/json', 20 }, 21 body: JSON.stringify({ 22 query, 23 }), 24}) 25 .then(response => response.json()) 26 .then(data => console.log(JSON.stringify(data, null, 2))) 27 .catch(error => console.error('Error:', error)); 28 29const authorQuery = ` 30 query { 31 author(id: "1") { 32 name 33 } 34 } 35`; 36 37fetch(url, { 38 method: 'POST', 39 headers: { 40 'Content-Type': 'application/json', 41 }, 42 body: JSON.stringify({ 43 query: authorQuery, 44 }), 45}) 46 .then(response => response.json()) 47 .then(data => console.log(JSON.stringify(data, null, 2))) 48 .catch(error => console.error('Error:', error));

Expected output:

JSON
1{ 2 "data": { 3 "books": [ 4 { 5 "title": "The Hobbit", 6 "author": { 7 "name": "J.R.R. Tolkien" 8 } 9 }, 10 { 11 "title": "Harry Potter", 12 "author": { 13 "name": "J.K. Rowling" 14 } 15 } 16 ] 17 } 18}
JSON
1{ 2 "data": { 3 "author": { 4 "name": "J.R.R. Tolkien" 5 } 6 } 7}

Everything should work correctly, fetching the required data while only making the necessary requests.

Summary and Next Steps

In this lesson, you learned about the N+1 problem in GraphQL and how to resolve it efficiently using Data Loaders. By defining the schema, implementing resolvers, integrating Data Loaders, and testing, you now have the skills to optimize data fetching in GraphQL applications.

You’ve reached the end of this course! Congratulations on making it this far. Now, dive into the practice exercises to reinforce your new skills and prepare for creating more powerful and efficient GraphQL APIs.

Enjoy this lesson? Now it's time to practice with Cosmo!
Practice is how you turn knowledge into actual skills.