RUBY ON RAILS · 18 MIN READ ·

Rails GraphQL: Production Setup with graphql-ruby, Batch Loading, and Persisted Queries

Rails GraphQL with graphql-ruby done right — schema design, N+1 prevention with batch loading, persisted queries, and production trade-offs vs REST APIs.

Rails GraphQL: Production Setup with graphql-ruby, Batch Loading, and Persisted Queries

A scaleup CTO called me last December with a problem that had been bleeding their team for six months. They had migrated their mobile app’s REST API to GraphQL because their iOS team complained about over-fetching, and the migration had gone smoothly — until traffic doubled around Black Friday. Their Rails app started timing out. Their database CPU hit 100 percent. Skylight showed individual GraphQL requests fanning out into hundreds of small queries. The team had built a beautiful schema, exposed it to clients, and walked straight into the most predictable trap GraphQL has to offer. I opened their app/graphql/ directory, spent forty minutes adding two gems and rewriting six resolvers, and their P95 latency dropped from 4.1 seconds to 180 milliseconds. The schema did not need to change. The way they loaded data did.

After nineteen years of Rails I have a strong opinion about Rails GraphQL: it is an excellent tool when you need it and a foot-gun when you do not. This post is the production playbook I hand to teams when they tell me they are about to ship graphql-ruby — what to set up, what to avoid, and where the real performance work lives. If you are weighing GraphQL against REST for a new API, my notes on Rails API rate limiting and the Rails technical due diligence checklist cover the surrounding context.

Why Rails GraphQL Is Worth the Complexity (Sometimes)

GraphQL is not a better REST. It is a different shape of API with different trade-offs. The clients win because they ask for exactly the fields they need and the server returns exactly that. The server team eats the complexity of resolving arbitrary query shapes efficiently, validating depth and complexity, and securing every field individually. If your API is consumed by one or two clients that you control, REST plus a few well-designed endpoints is almost always less work. If your API is consumed by multiple front-end teams, native mobile clients, and third parties — and those clients are evolving on different release cycles — GraphQL pays its rent.

In practice the Rails GraphQL teams I work with are doing one of three things: powering a mobile app that needs strict shape control, building a public API for partners who want flexibility, or unifying several internal services behind a single gateway. The first two are great fits. The third is usually a federation problem, and federation in Ruby is still rougher than the marketing suggests — proceed carefully.

Setting Up graphql-ruby in a Modern Rails App

The de facto library is graphql-ruby, maintained by Robert Mosolgo and battle-tested in production at GitHub, Shopify, and basically every Rails shop running GraphQL at scale. Installation is genuinely a one-liner, but the default scaffold leaves you exposed in several ways that matter in production.

# Gemfile
gem "graphql", "~> 2.3"
gem "graphql-batch", "~> 0.6"
bundle install
bin/rails generate graphql:install

The generator creates app/graphql/, a controller, the schema, and base classes for Object, Query, Mutation, InputObject, and Enum. The controller it ships with is fine for development. For production I always lock it down on day one.

# app/controllers/graphql_controller.rb
class GraphqlController < ApplicationController
  protect_from_forgery with: :null_session

  def execute
    result = MyAppSchema.execute(
      params[:query],
      variables: prepare_variables(params[:variables]),
      context: {
        current_user: current_user,
        request_id: request.request_id
      },
      operation_name: params[:operationName]
    )
    render json: result
  rescue StandardError => e
    raise e unless Rails.env.production?

    Rails.logger.error("[graphql] #{e.class}: #{e.message}")
    render json: { errors: [{ message: "Internal server error" }] }, status: 500
  end

  private

  def prepare_variables(input)
    case input
    when String then input.present? ? JSON.parse(input) : {}
    when Hash, ActionController::Parameters then input
    else {}
    end
  end
end

The two things to notice: context carries the current user and request ID into every resolver (no Thread.current hacks), and we never leak internal exception details to clients in production.

The N+1 Problem Is the Whole Game

The reason the scaleup I mentioned earlier was burning down their database is the single most common Rails GraphQL bug: every resolver fires its own query. With REST you write one controller action, you preload your associations, and you are done. With GraphQL a single client query can touch ten different resolvers, each of which independently asks the database for the same parent record’s children.

query {
  orders(last: 50) {
    nodes {
      id
      total
      customer { id name email }
      lineItems { id product { id name sku } }
    }
  }
}

Naive resolvers will issue one query for orders, fifty for customers, fifty for line items, and another N for products. That is the entire n+1 problem multiplied across every association in the schema. Adding an .includes(...) in the orders resolver does not help — GraphQL does not know at the top level which associations the query asked for.

The right tool is a batch loader. graphql-batch (from Shopify) is the one I reach for first. It groups individual loads inside a single resolution pass into one SQL query each.

# app/graphql/loaders/record_loader.rb
class Loaders::RecordLoader < GraphQL::Batch::Loader
  def initialize(model)
    super()
    @model = model
  end

  def perform(ids)
    @model.where(id: ids).each { |record| fulfill(record.id, record) }
    ids.each { |id| fulfill(id, nil) unless fulfilled?(id) }
  end
end

# app/graphql/loaders/association_loader.rb
class Loaders::AssociationLoader < GraphQL::Batch::Loader
  def initialize(model, association_name)
    super()
    @model = model
    @association_name = association_name
  end

  def perform(records)
    ActiveRecord::Associations::Preloader
      .new(records: records, associations: @association_name)
      .call

    records.each { |record| fulfill(record, record.public_send(@association_name)) }
  end
end

Then in your types:

# app/graphql/types/order_type.rb
class Types::OrderType < Types::BaseObject
  field :id, ID, null: false
  field :total, Float, null: false
  field :customer, Types::CustomerType, null: false
  field :line_items, [Types::LineItemType], null: false

  def customer
    Loaders::RecordLoader.for(Customer).load(object.customer_id)
  end

  def line_items
    Loaders::AssociationLoader.for(Order, :line_items).load(object)
  end
end

And wire the schema:

# app/graphql/my_app_schema.rb
class MyAppSchema < GraphQL::Schema
  use GraphQL::Batch
  query Types::QueryType
  mutation Types::MutationType
end

That change alone — replacing direct association access with batch loaders — was the entire fix for the scaleup. Forty minutes of work, an order of magnitude in latency, and the database CPU graph that night looked like someone had unplugged it.

Rails GraphQL Authorization Is a Per-Field Problem

REST authorization is convenient because every endpoint has a clear scope: you check the user in the controller, scope the records, and return. GraphQL flattens that. A single request can touch dozens of fields across different types, and each field needs to be authorized independently. If you only check at the resolver entry point, a clever client can navigate through associations to data they should never see.

graphql-ruby gives you three places to authorize: type-level (authorized?), field-level (authorized?), and resolver-level (authorized?). I use Pundit for the underlying policy logic and let graphql-ruby invoke it.

class Types::OrderType < Types::BaseObject
  def self.authorized?(object, context)
    super && OrderPolicy.new(context[:current_user], object).show?
  end

  field :internal_notes, String, null: true do
    def authorized?(_object, _args, context)
      context[:current_user]&.staff?
    end
  end
end

Type-level checks prevent the whole object from rendering. Field-level checks hide individual fields. The combination is what you actually need in a multi-tenant SaaS where customer A’s user can never see customer B’s order, and only staff can see the internal_notes field on any order.

The trap here is forgetting that graphql-ruby returns null for unauthorized fields unless they are non-nullable, in which case the whole parent object is dropped. Audit your schema for accidental nulls being interpreted as “the customer has no notes” rather than “you are not allowed to see this.” I have seen sales teams build dashboards on top of these false nulls and reach badly wrong conclusions.

Query Complexity, Depth, and the DoS Vector

A REST endpoint has a fixed cost. A GraphQL endpoint has a cost that depends on what the client asked for. Without limits, a single malicious or curious client can craft a query that joins your entire schema together and brings your database to its knees in one HTTP request. This is not theoretical. I have responded to two incidents in the last three years where a customer service rep ran a deeply nested query in GraphiQL and blew up production.

graphql-ruby has built-in protection. Use it.

class MyAppSchema < GraphQL::Schema
  use GraphQL::Batch

  max_depth 12
  max_complexity 300
  default_max_page_size 50

  query Types::QueryType
  mutation Types::MutationType
end

max_depth caps how nested a query can go. max_complexity is a weighted score — each field has a complexity value, and the planner adds them up before execution. default_max_page_size is the most important one for any field returning a connection. Without it, a client can request first: 100000 and you will obligingly try to serialize 100,000 records.

Tune complexity per field where it matters. A field that triggers a recursive tree walk should cost more than a scalar.

field :descendants, [Types::CategoryType], null: false, complexity: 10

Persisted Queries: The Production Win Nobody Talks About

If you ship a public GraphQL endpoint without persisted queries, you are accepting two problems. First, the query body is sent over the wire on every request, which is wasteful on mobile networks. Second, any client — including ones you do not control — can send any query, and you cannot block malicious shapes ahead of time.

Persisted queries solve both. At build time, the client tooling extracts every GraphQL operation from the codebase, hashes it, and registers it with the server. At runtime, the client sends only the hash and the variables. The server looks up the operation by hash and executes it. Unknown hashes are rejected.

The pattern with graphql-ruby looks like this:

# app/graphql/persisted_queries.rb
class PersistedQueries
  def self.lookup(hash)
    Rails.cache.fetch("graphql:persisted:#{hash}", expires_in: 1.day) do
      PersistedQuery.find_by(hash: hash)&.body
    end
  end
end
# app/controllers/graphql_controller.rb
def execute
  query = if params[:query].present?
    raise "Ad-hoc queries disabled" if Rails.env.production? && !current_user&.staff?
    params[:query]
  else
    PersistedQueries.lookup(params[:queryId]) or raise "Unknown query"
  end

  result = MyAppSchema.execute(
    query,
    variables: prepare_variables(params[:variables]),
    context: { current_user: current_user },
    operation_name: params[:operationName]
  )
  render json: result
end

Apollo Client, Relay, and graphql-client all support automatic persisted queries on the client side. The migration is mostly a build-pipeline change, and the latency improvement on mobile is immediate. I would not run a public Rails GraphQL API in production without it.

Tracing, Metrics, and Knowing What Your Clients Are Doing

Once your GraphQL endpoint is real, the next operational pain is that all traffic flows through one route. Your APM dashboard shows POST /graphql taking 800ms on average, which tells you nothing. You need per-operation tracing.

graphql-ruby has first-class instrumentation hooks. The simplest production setup is to tag each request with the operation name and emit a metric.

class MyAppSchema < GraphQL::Schema
  use GraphQL::Tracing::ActiveSupportNotificationsTracing
end

ActiveSupport::Notifications.subscribe("execute_query.graphql") do |_name, started, finished, _id, payload|
  duration_ms = (finished - started) * 1000
  operation = payload[:query].operation_name || "anonymous"

  StatsD.timing("graphql.execute.#{operation}", duration_ms)
  Rails.logger.info("[graphql] #{operation} #{duration_ms.round(1)}ms")
end

Combine that with OpenTelemetry on Rails 8 and you can finally tell whether the slow request was the OrdersDashboard query or the MobileFeed query without digging through logs.

When Rails GraphQL Is The Wrong Answer

I would be doing you a disservice if I did not mention this. After helping a dozen Rails teams ship GraphQL, I have walked at least three back to REST. The signals that GraphQL is wrong for a given codebase:

  • A single Rails team owns both the API and the only client, and the client team is happy with the REST endpoints they have. GraphQL is solving a coordination problem they do not have.
  • The data model is dominated by complex aggregations, reports, and analytics queries. Those are awkward in GraphQL and natural in a few well-designed REST endpoints.
  • The team does not have the appetite for the operational work — persisted queries, complexity limits, batch loaders, per-field authorization. GraphQL is not lower effort than REST. It is a different effort.

If any of those describe you, do not adopt GraphQL because it is fashionable. Ship a clean REST API with proper rate limiting and revisit the question when you actually feel the pain it solves.

FAQ

Should I use GraphQL or REST for my Rails API in 2026?

REST for internal APIs with one or two clients you control. GraphQL when you have multiple front-ends — web, iOS, Android, third-party — evolving on different release cycles and complaining about over-fetching. The complexity GraphQL adds on the Rails side only pays off when client diversity is real. If your iOS team and your React team are happy with REST, do not migrate.

How do I prevent N+1 queries in graphql-ruby?

Use graphql-batch from Shopify. Wrap individual record lookups in a RecordLoader and association lookups in an AssociationLoader. graphql-ruby will collect all loads in a single resolution pass and execute them as one query per association. Never call object.some_association directly in a resolver in production code — that is the N+1 path.

Is graphql-ruby production-ready?

Yes. It powers GitHub, Shopify, Gusto, Toast, and a long list of large Rails shops. The library is actively maintained, the documentation is excellent, and the patterns for batching, authorization, complexity limits, and persisted queries are mature. The question is not whether the library is ready — it is whether your team has the operational discipline GraphQL requires.

What’s the difference between max_depth and max_complexity in graphql-ruby?

max_depth limits how many levels deep a query can nest — useful for preventing infinite traversal through self-referential types. max_complexity is a weighted score across the whole query — useful for limiting total work even when depth is shallow. You want both. A query can be shallow and expensive (returning 10,000 records at one level) or deep and cheap (returning one scalar five levels down).

Need help shipping a Rails GraphQL API that survives Black Friday? TTB Software specializes in Rails performance, API design, and the operational discipline GraphQL demands. We’ve been doing this for nineteen years.

#rails-graphql #graphql-ruby-rails #graphql-batch-loader #persisted-queries-rails #graphql-n-plus-one #rails-graphql-authorization #rails-api-design

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software