Avoiding N+1 queries in Rails GraphQL APIs

GraphQL is becoming an ever-more popular way to build modern APIs. In contrast to REST's more rigid route definitions, GraphQL allows you to describe the way your data is actually structured and the relationships between your different resources. The upshot of this is that you end up with a highly flexible API where it is possible to write queries to request any combination of data. The downside is, as the details of any query are unpredictable, it is more complicated to avoid N+1 queries, which can mean your API's increased utility comes at the cost of sluggish performance.

Facebook, the original creators of GraphQL, solve this problem in JavaScript with DataLoader, but how about in Ruby? There are a few gems that exist; for example Shopify's graphql-batch is a headline offering, and is lightweight and flexible. However, in this blog we're going to be using Universe's batch-loader, primarily because it is ready to go "out of the box" for ActiveRecord associations without having to define additional loader classes. If you'd prefer to use graphql-batch, see here and here for pointers.

N.B. It is relatively straightforward to add a simple GraphQL API to a Rails app using GraphQL Ruby. See their guides for detail on getting set up, the process will also add the GraphiQL editor to your app, which is awesome!

Imagine we have an app cataloguing museums and their exhibits, with a GraphQL API setup for querying them. Our basic museum type might look a little like this:


# app/graphql/types/museum_type.rb

module Types
  class MuseumType < GraphQL::Schema::Object
    field :name, String, null: false
    field :exhibits, [ExhibitType], null: true
  end
end

And our exhibit type. Let's keep this simple and only declare the association in a single direction for now:


# app/graphql/types/exhibit_type.rb

module Types
  class ExhibitType < GraphQL::Schema::Object
    field :name, String, null: false
  end
end

And also our root query type (note for this example we are only declaring a root query for museums, not exhibits):


# app/graphql/types/query_type.rb

module Types
  class QueryType < GraphQL::Schema::Object
    field :museums, [MuseumType], null: true

    def museums
      Museum.all
    end
  end
end

Say we now want to run a query retrieving all of our museums and the name of each of their exhibits. Executing the following GraphQL query in our GraphiQL editor:


{
  museums {
    name
    exhibits {
      name
    }
  }
}
Fig. 1

Will produce N+1 queries on the exhibits table (fig. 1). Boo! To modify our setup to use batch loading and zap that bug, add the following line to your Gemfile:


gem 'batch-loader'

And run bundle install, then make the following changes to the museum type:


# app/graphql/types/museum_type.rb

module Types
  class MuseumType < GraphQL::Schema::Object
    field :name, String, null: false

    field :exhibits, [ExhibitType], null: true, resolve: -> (museum, args, ctx) do
      BatchLoader::GraphQL.for(museum.id).batch(default_value: []) do |museum_ids, loader|
        Exhibit.where(museum_id: museum_ids).each do |exhibit|
          loader.call(exhibit.museum_id) { |memo| memo << exhibit }
        end
      end
    end
  end
end

BatchLoader is evaluated lazily, which means rather than the exhibits being loaded from the database instantly for each museum, the necessary museum IDs are stored and then executed in a single call to Exhibit.where, eliminating the N+1. BatchLoader will also cache the result of this query, so subsequent requests will be even faster as the database won't get hit at all. You can keep track of the caching behaviour by providing a key: option to .batch, and can clear the cache manually if desired by calling BatchLoader::Executor.clear_current.

Finally, in our schema we need to make the following change:


# app/graphql/app_schema.rb

class AppSchema < GraphQL::Schema
  query(Types::QueryType)

  # enable batch loading
  use BatchLoader::GraphQL
end

Run the same query again:


{
  museums {
    name
    exhibits {
      name
    }
  }
}
Fig. 2

Yay! No N+1 queries (fig. 2) and a nice, fast API response. As an additional exercise, try implementing a root :exhibits query type and batching in the opposite direction, such that you would be able to run the query:


{
  exhibits {
    name
    museum {
      name
    }
  }
}

And avoid the dreaded N+1. The BatchLoader block will be slightly simpler in this case, but the principles are the same. Enjoy!