A GraphQL API in Rails, and what the generator leaves you holding
A GraphQL API in Rails is one POST route and a directory of Ruby classes, and the gem that builds it will have you serving queries in about four minutes. The part nobody mentions is that those four minutes produce an endpoint with no pagination, no query cost limit, a CSRF filter that rejects every non-browser client, and an N+1 on every association you expose. All four are things the generator could have decided and deliberately did not.
Everything below ran on a scratch application generated with rails new rails-graphql
--database=postgresql, on Rails 8.1.4, graphql 2.6.11, graphiql-rails 1.10.5, Ruby 4.0.5 and
PostgreSQL 17.7, against a seeded database of 20 authors, 60 posts and 180 comments. The outputs are
pasted from bin/rails runner, bin/rails test and curl.
What rails g graphql:install actually writes
The install generator creates 16 Ruby files totalling 190 lines under app/graphql, a
GraphqlController, and one route. Twelve of those sixteen are named base_* and hold little more
than a superclass and a field_class line, which is fine: they exist so that the day you need a
custom argument type you have somewhere to put it.
Two things in that output are worth reading before you move on. The first is in
config/application.rb, where the generator appends to config.log_tags:
current_graphql_operation: -> { GraphQL::Current.operation_name },
current_graphql_field: -> { GraphQL::Current.field&.path },
current_dataloader_source: -> { GraphQL::Current.dataloader_source_class },
That turns every SQL statement in the log into something you can trace back to a field. Here is one from the run below, comment and all:
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 1 LIMIT 1 /*application='RailsGraphql',current_graphql_field='Post.author'*/
The second is that the generator adds gem "graphiql-rails", group: :development to the Gemfile,
mounts the engine in config/routes.rb, and does not run bundle install. The next command you type
fails, whatever it is:
$ bin/rails runner 'puts "booted"'
Could not find gem 'graphiql-rails' in locally installed gems. (Bundler::GemNotFound)
The fix is bundle install, and the reason to mention a thirty-second problem at all is that it is
the first signal of the generator's actual posture: it writes the code and leaves the decisions, all
of them, to you.
The object generator reads your columns and ignores your associations
rails g graphql:object Post on a model with belongs_to :author and has_many :comments produced
this, and nothing else:
module Types
class PostType < Types::BaseObject
field :id, ID, null: false
field :title, String
field :body, String
field :published, Boolean
field :author_id, Integer, null: false
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
end
end
author_id as an Integer is in the schema and author is not, because the generator reads
Post.columns and never looks at Post.reflect_on_all_associations. So the one field that makes
GraphQL worth having over a render json: is the one you write by hand, on every type, every time.
updated_at being public by default is the other half of that trade and is worth a second of
thought before you leave it there.
Sixty posts, sixty-one queries
Add field :author, Types::AuthorType, null: false to PostType, point the root field at
Post.order(:id), and ask for the obvious thing:
{ posts { title author { name } } }
Counted with an ActiveSupport::Notifications subscriber on sql.active_record, over the 60 seeded
posts, on the first execution in a cold process:
posts { title author { name } }: 61 queries, 171.9 ms
SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 1 LIMIT 1
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 2 LIMIT 1
The N+1 query itself is not news. What is specific to GraphQL is
that the usual fix does not apply. In a controller you know the shape of the response when you write
the action, so includes(:author, :comments) in the query is correct by construction. In a GraphQL
schema the client picks the shape at request time, and the root resolver runs before anybody has
looked at the selection set. Preloading in the resolver means preloading for every query, including
the ones that asked for nothing but titles.
Here is the whole thing measured, three schemas, four query shapes, each number the mean of five runs in one warm process:
| query | generated schema | includes(:author, :comments) in the resolver |
Dataloader sources |
|---|---|---|---|
posts { title } |
1 query, 0.9 ms | 3 queries, 3.1 ms | 1 query, 0.8 ms |
posts { title author { name } } |
61 queries, 24.3 ms | 3 queries, 5.6 ms | 2 queries, 4.2 ms |
posts { title comments { body } } |
61 queries, 23.3 ms | 3 queries, 8.4 ms | 2 queries, 6.7 ms |
posts { title author { name } comments { body } } |
121 queries, 39.0 ms | 3 queries, 6.0 ms | 3 queries, 6.2 ms |
Read the first row. Preloading in the resolver turned the cheapest query in the schema from one statement into three and tripled its time, in exchange for fixing rows two and three. That is the trade, and on a schema with fifteen associations instead of two it gets worse in the direction you would expect.
The Dataloader column is two small classes. Here is the belongs_to one, which is the whole of
app/graphql/sources/record_by_id.rb:
module Sources
class RecordById < GraphQL::Dataloader::Source
def initialize(model)
@model = model
end
def fetch(ids)
rows = @model.where(id: ids).index_by(&:id)
ids.map { |id| rows[id.to_i] }
end
end
end
and the has_many one, app/graphql/sources/records_by_foreign_key.rb:
module Sources
class RecordsByForeignKey < GraphQL::Dataloader::Source
def initialize(model, foreign_key)
@model = model
@foreign_key = foreign_key
end
def fetch(ids)
grouped = @model.where(@foreign_key => ids).group_by { |r| r.public_send(@foreign_key) }
ids.map { |id| grouped[id] || [] }
end
end
end
Both are wired in by overriding the field method on the type:
def author
dataloader.with(Sources::RecordById, ::Author).load(object.author_id)
end
def comments
dataloader.with(Sources::RecordsByForeignKey, ::Comment, :post_id).load(object.id)
end
fetch receives every key the whole query asked for, once. The 60 posts have 20 distinct
author_id values, and the batch runs once with 20 ids in it:
SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20) /*application='RailsGraphql',current_dataloader_source='Sources%3A%3ARecordById'*/
The cost of the Dataloader column is that every association in your schema needs a field method, and
fetch must return results in the same order as the keys it was given. Return them in database
order and you will hand post 7's comments to post 3, with no error anywhere.
use GraphQL::Dataloader batches nothing by itself
use GraphQL::Dataloader is on line 8 of the schema the generator writes, and the 61-query run above
happened with that line in place. The Dataloader is the mechanism, not the behaviour: nothing batches
until a field asks it to.
145 characters in, 1,369,850 bytes out
GraphQL's selling point is that the client asks for what it needs. The same property means a client
can ask for something no controller could have been made to return. With the Dataloader sources in
place, max_depth(15) set by the generator, and the list fields exactly as written above:
{ posts { author { posts { author { posts { author { posts { author { posts { author { posts { author { posts { title } } } } } } } } } } } } } }
query is 145 characters
errors: nil
sql queries: 22
elapsed: 823 ms
response JSON: 1369850 bytes
Twenty-two SQL queries rather than thousands, because the Dataloader caches each key for the life of the request and there are only ever 60 posts and 20 authors to load. The 1.3 MB is the response tree, which contains those same 80 rows repeated until the depth limit stops it. Add one more level of nesting and the analyzer refuses:
Query has depth of 16, which exceeds max depth of 15
That is the defence the generator gives you, and it is a limit on the query text rather than on
anything the query costs. A 145-byte request that produces 1.3 MB is a 9,400x amplification against
a seed database. Against a real posts table the same string is an outage.
max_complexity is the limit that matters, and it does nothing until your lists are paginated
RailsGraphqlSchema.max_complexity is nil in a freshly generated schema, and the advice you will
find is to set it. Setting it on the schema above accomplishes nothing, because that 1.3 MB query
scores a complexity of 14:
nest(0): complexity=2 depth=2
nest(1): complexity=4 depth=4
nest(3): complexity=8 depth=8
nest(6): complexity=14 depth=14
GraphQL::Analysis::QueryComplexity is static: it counts fields in the document. A list field whose
size is unbounded counts as one field, exactly like a scalar, because nothing in the schema tells the
analyzer how many rows it can produce. A limit low enough to reject that query would have to be 13 or
less, and on an unpaginated schema that is a cap on how many fields a client may name rather than on
how much work it may cause.
The number that teaches the analyzer to count is the page size, which means the fix for the complexity limit is pagination, not a smaller limit. Change the list fields to connections and give the schema a page size:
field :posts, Types::PostType.connection_type, null: false
max_depth(15)
max_complexity(200)
default_max_page_size(25)
default_page_size(10)
The same shapes now score the way you would hope:
posts(first:25){title author{name}}: complexity=102 depth=5
posts(first:25){title comments{body}}: complexity=102 depth=5
posts{title}: complexity=22 depth=4
one level of nested posts: complexity=242 depth=8
two levels: complexity=2442 depth=12
A page of 25 posts with their authors costs 102. One extra hop into each author's other posts costs 242 and is refused:
Query has complexity of 242, which exceeds max complexity of 200
Two behaviours of those settings are not obvious and both cost somebody an afternoon. The first:
default_max_page_size(25) truncates silently. posts(first: 30) returns 25 edges, errors is
nil, and a client paginating on the assumption that it got 30 will skip rows. The second: complexity
is scored against the first: the client wrote, not the one it will get, so posts(first: 100) is
rejected outright with Query has complexity of 202, which exceeds max complexity of 200 rather than
trimmed to 25. Requesting 30 truncates; requesting 100 fails. Same field.
Every error is a 200
GraphQL puts failures in the response body, and the generated controller renders whatever the schema
returned with no status of its own. Measured against the production-mode server with curl -o
/dev/null -w "%{http_code}":
--- failed mutation over HTTP ---
status=200
{"data":{"createPost":{"post":null,"errors":["Title can't be blank"]}}}
--- unknown field over HTTP ---
status=200
{"errors":[{"message":"Field 'nope' doesn't exist on type 'Query'","locations":[{"line":1,"column":3}],"path":["query","nope"],"extensions":{"code":"undefinedField","typeName":"Query","fieldName":"nope"}}]}
--- syntax error ---
status=200
{"errors":[{"message":"Expected NAME, not end of file at [1, 3]","locations":[{"line":1,"column":3}]}]}
A query that does not parse is a 200. That is correct by the GraphQL specification and it is a real
operational problem, because every uptime check, every load balancer health rule and every alert
threshold you own is counting status codes. Ship a schema change that breaks the mobile client's
query and your error rate stays flat at zero while the app is unusable. Whatever you use for
monitoring needs a rule on errors being present in a 200 body, and nothing in the generated
controller writes one for you.
The two error channels are worth keeping straight, because they mean different things. A top-level
errors array with no data is the request being wrong: bad syntax, a field that does not exist, a
variable of the wrong type. errors as a field inside data is your own domain failure, and it is a
field you declared:
field :post, Types::PostType, null: true
field :errors, [String], null: false
def resolve(title:, author_id:)
post = Post.new(title: title, author_id: author_id)
if post.save
{ post: post, errors: [] }
else
{ post: nil, errors: post.errors.full_messages }
end
end
Mutation arguments go under input
rails g graphql:mutation CreatePost subclasses BaseMutation, which the install generator defined
as GraphQL::Schema::RelayClassicMutation. Every argument therefore lives inside an autogenerated
input object, and the first mutation anybody writes by hand gets this back:
Field 'createPost' is missing required arguments: input
Field 'createPost' doesn't accept argument 'title'
The working call is createPost(input: { title: "x", authorId: "1" }). If you would rather write
createPost(title: "x"), change BaseMutation to inherit from GraphQL::Schema::Mutation, and
do it on day one: the input object name is part of your public schema.
The generated controller refuses curl
app/controllers/graphql_controller.rb ships with this line commented out:
# protect_from_forgery with: :null_session
So the first request any non-browser client makes gets a 422 and an HTML error page:
$ curl -s -i -X POST http://127.0.0.1:3009/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ posts { edges { node { title } } } }"}'
HTTP/1.1 422 Unprocessable Content
content-type: text/html; charset=UTF-8
content-length: 183819
ActionController::InvalidAuthenticityToken (Can't verify CSRF token authenticity.):
Uncommenting the line returns 200. The comment above it in the generated file explains the trade
accurately and does not make the decision: nullifying the session means the endpoint can no longer
read current_user from a cookie, so browser-session authentication and token authentication are a
choice you make here rather than something you get both of.
What the endpoint costs
The overhead is real and it is smaller than the arguments about it. Interleaved in one production-mode process, 1000 iterations each after 200 warmup rounds, both returning the same ten posts with their author's name:
Active Record + to_json median 0.27 ms p90 0.44 ms min 0.24 ms
RailsGraphqlSchema.execute median 0.92 ms p90 1.42 ms min 0.78 ms
ratio of medians: 3.39x
A second run of the same script gave 0.28 ms, 0.97 ms and 3.47x. So parsing, validating, analysing and executing a small query costs about 0.65 ms more than building the same hash in Ruby, per request, on an M2 Max. Against an action that spends 8 ms in PostgreSQL that is noise. Against a health check or a fully cached endpoint it is most of the request.
Over HTTP, through Puma 8.0.2 with three threads and one worker in the production environment,
/usr/sbin/ab -n 2000 -c 10 gave 972.62 req/s for the plain Rails action and 549.73 req/s for
/graphql, with p99 at 19 ms and 35 ms. That pair is reported rather than a mean of several
because the laptop was running other work throughout, and later pairs swung between 306 and 892
req/s on the plain endpoint alone. The in-process numbers above are the ones to trust; the
ApacheBench pair is there because the ratio it shows, roughly 1.8x, is smaller than the 3.4x measured
in isolation, which is what you would expect once the rest of the Rack stack is in the denominator.
One cost that does not show up in either benchmark: the generator writes post "/graphql" and
nothing else, so every read goes through a POST. bin/rails routes says so plainly.
graphql POST /graphql(.:format) graphql#execute
No CDN will cache that, no browser will cache that, and Cache-Control on the response buys you
nothing. A REST API gets HTTP caching for free and a GraphQL API buys it back with persisted queries
over GET, which is a second system.
The position
Use GraphQL when more than one client, built by more than one team, needs different shapes of the
same data, and the cost of a REST endpoint per shape is being paid in coordination rather than in
code. That is the problem it solves and it solves it well. The schema file is a contract you can diff
in CI, which is genuinely better than an OpenAPI document that drifts. The task that writes it is not
installed by the generator either; two lines in the Rakefile add it:
require "graphql/rake_task"
GraphQL::RakeTask.new(schema_name: "RailsGraphqlSchema", directory: "app/graphql")
$ bin/rake graphql:schema:idl
Schema IDL dumped into app/graphql/schema.graphql
For one Rails application with one JavaScript frontend, it is a bad trade, and the measurements above
are why. You write a field method per association to get back to the query count a render json:
gives you for free, you write pagination twice, you write the query cost limit the framework left
nil, you write the monitoring rule that notices a 200 containing errors, and you give up HTTP
caching. None of it is hard. All of it is work that buys a flexibility a single client does not use.
What would change that: a second consumer with a genuinely different shape. Two mobile apps and a web frontend arguing over whether the list endpoint should embed the author is the case GraphQL was built for, and at that point the field methods are cheaper than the arguments.
What this post does not cover
Authentication and authorization past the CSRF line, which is where most of the real design work
lives: context[:current_user] and field-level authorized?, which when it returns false puts null in
data and nothing at all in errors. Subscriptions, which
need Action Cable and a schema-wide subscription root that the install generator does not write.
Persisted queries and GET support, which is how you get caching back. The interpreter versus
class-based API split, which is history now: graphql 2.6.11 has one API. The graphql-batch gem,
which predates GraphQL::Dataloader, sits at 0.6.1 on RubyGems, and went unmeasured here.
Apollo, Relay and the client half of all of this, on which this page has nothing measured to say. And the
GraphiQL IDE past confirming that the generator mounts it at /graphiql in development only.
Comments
No comments yet. Be the first.