Solving the N Plus One Problem

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 batching and lazy execution.

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, especially as n grows, resulting in a time complexity of O(n)O(n) for the number of queries.

In Ruby's graphql-ruby gem, we can solve this problem using the graphql-batch gem, which provides batching and caching capabilities to optimize data fetching.

Benefits of Using Batching:

  • 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 batching can solve the N+1 problem, we'll create a simple GraphQL schema with authors and books.

First, let's set up our sample data:

Ruby
# Sample data
AUTHORS = [
  { id: '1', name: 'J.R.R. Tolkien' },
  { id: '2', name: 'J.K. Rowling' }
]

BOOKS = [
  { id: '1', title: 'The Hobbit', author_id: '1' },
  { id: '2', title: 'Harry Potter', author_id: '2' }
]

Now, let's define our GraphQL types using graphql-ruby's class-based syntax:

Ruby
require 'graphql'

class AuthorType < GraphQL::Schema::Object
  field :id, ID, null: false
  field :name, String, null: false
end

class BookType < GraphQL::Schema::Object
  field :id, ID, null: false
  field :title, String, null: false
  field :author, AuthorType, null: true
  
  def author
    AuthorLoader.for.load(object[:author_id])
  end
end

class QueryType < GraphQL::Schema::Object
  field :books, [BookType], null: false
  field :author, AuthorType, null: true do
    argument :id, ID, required: true
  end
  
  def books
    BOOKS
  end
  
  def author(id:)
    AuthorLoader.for.load(id)
  end
end

A Note on Data Structure

In previous lessons, we stored the author's name directly on each book (e.g., author: "J.R.R. Tolkien"). Here, we've shifted to a more realistic relational structure where books reference authors by author_id, and authors are stored in a separate collection.

This is how data is typically organized in databases—it avoids duplicating author information and keeps data consistent. For example, if an author's name needs to be updated, you only change it in one place rather than on every book.

However, this relational structure is exactly what creates the N+1 problem: fetching books and their authors now requires looking up each author separately. If you have 100 books, a naive implementation would make 1 query for the books plus 100 queries for the authors. This is the problem we'll solve with batching.

In this schema:

  • We define AuthorType and BookType classes that inherit from GraphQL::Schema::Object.
  • The BookType has a nested author field that returns an AuthorType. The resolver uses object[:author_id] to look up the associated author.
  • The QueryType defines queries to fetch a list of books and a single author by ID.

Implementing Resolvers with Data Loaders

To implement batching and caching, we'll use the graphql-batch gem. This gem provides a GraphQL::Batch::Loader class that we can extend to create custom loaders.

First, let's create an AuthorLoader that batches and caches author requests:

Ruby
require 'graphql/batch'

class AuthorLoader < GraphQL::Batch::Loader
  def perform(ids)
    ids.each do |id|
      author = AUTHORS.find { |a| a[:id] == id }
      fulfill(id, author)
    end
  end
end

Here's how the AuthorLoader works:

  1. Batching: The perform method receives an array of all author IDs requested during a single GraphQL execution. Instead of making separate lookups for each ID, we process them all at once.
  2. Caching: Once an author is fetched, graphql-batch automatically caches the result. If the same author ID is requested again in the same query, the cached value is returned.
  3. Fulfillment: The fulfill method associates each ID with its corresponding author data.

The resolvers in our type classes use AuthorLoader.for.load(id) to fetch authors. The .for method creates a loader instance for the current GraphQL execution context, and .load(id) queues the ID for batching.

Initializing and Using Data Loaders

Now, let's integrate our schema with graphql-batch and set up a Sinatra server to handle GraphQL requests:

Ruby
require 'sinatra'
require 'json'

class AppSchema < GraphQL::Schema
  query QueryType
  use GraphQL::Batch
end

post '/graphql' do
  request_payload = JSON.parse(request.body.read)
  query = request_payload['query']
  variables = request_payload['variables'] || {}
  
  result = AppSchema.execute(
    query,
    variables: variables,
    context: {}
  )
  
  content_type :json
  result.to_json
end

Here's what's happening:

  • We define AppSchema, which includes our QueryType and enables graphql-batch with use GraphQL::Batch.
  • The Sinatra POST route /graphql handles incoming GraphQL requests.
  • We parse the JSON request body to extract the query and variables.
  • We execute the query using AppSchema.execute and return the result as JSON.

To start the server, add this at the end of your file:

Ruby
set :port, 4000
set :bind, '0.0.0.0'

Testing Your Implementation

Finally, let's test our implementation by running some queries in a separate Ruby file:

Ruby
require 'net/http'
require 'json'
require 'uri'

url = URI('http://localhost:4000/graphql')

# Query for books with authors
query = <<~GRAPHQL
  query {
    books {
      title
      author {
        name
      }
    }
  }
GRAPHQL

http = Net::HTTP.new(url.host, url.port)
request = Net::HTTP::Post.new(url.path, 'Content-Type' => 'application/json')
request.body = { query: query }.to_json

response = http.request(request)
puts JSON.pretty_generate(JSON.parse(response.body))

# Query for a specific author
author_query = <<~GRAPHQL
  query {
    author(id: "1") {
      name
    }
  }
GRAPHQL

request = Net::HTTP::Post.new(url.path, 'Content-Type' => 'application/json')
request.body = { query: author_query }.to_json

response = http.request(request)
puts JSON.pretty_generate(JSON.parse(response.body))

Expected output:

JSON
{
  "data": {
    "books": [
      {
        "title": "The Hobbit",
        "author": {
          "name": "J.R.R. Tolkien"
        }
      },
      {
        "title": "Harry Potter",
        "author": {
          "name": "J.K. Rowling"
        }
      }
    ]
  }
}
JSON
{
  "data": {
    "author": {
      "name": "J.R.R. Tolkien"
    }
  }
}

Everything should work correctly, fetching the required data while only making the necessary requests. The AuthorLoader batches all author lookups together, preventing the N+1 problem.

Summary and Next Steps

In this lesson, you learned about the N+1 problem in GraphQL and how to resolve it efficiently using graphql-batch in Ruby. By defining the schema with graphql-ruby's class-based syntax, implementing custom loaders for batching and caching, integrating graphql-batch into your schema, and testing with Sinatra, 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.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal