Lighthouse GraphQL RailsLighthouse GraphQL Rails
Docs
Directives
Example app
Changelog
RubyGems
GitHub
Docs
Directives
Example app
Changelog
RubyGems
GitHub
  • Getting Started

    • Motivation
    • Getting Started
    • Configuration
  • The Basics

    • Resolvers, Mutations and Builders
    • Generators
    • Unions, Interfaces and Scalars
    • Relay: Global Object Identification
    • Relationships
    • Filtering & Ordering
    • Mutations
    • File Uploads
    • Validation
    • Subscriptions
  • Security

    • Authentication & Authorization
    • Query Complexity & Depth
  • Performance

    • Performance at scale
    • Caching
  • Digging Deeper

    • Schema Organisation
    • Custom Directives
    • Events
    • Apollo Federation
    • Best Practices
  • API Reference

    • Directives Reference

Performance at scale

A Rails process is long-lived: it builds the schema once at boot (before forking, with Puma's preload_app!) and reuses it for every request. So PHP Lighthouse's per-request schema and query caches mostly solve a problem Rails does not have. Large schemas and high traffic still benefit from three options. All are off by default; enable the ones your app needs.

OptionWhat it doesTurn it on when
schema_cacheCaches the built schema document in tmp/cacheThe schema is large and boot time matters (autoscaling, serverless, CI)
watch_sdlReloads the app when a .graphql file changesDeveloping; typically Rails.env.development?
persisted_queriesApollo's Automatic Persisted Queries, optionally an allowlistLarge queries, CDN caching of GETs, locking the API down to known operations

Schema cache

Lighthouse.configure { |config| config.schema_cache = Rails.env.production? }

Building a schema parses the SDL, injects directive definitions, runs the manipulators (pagination types, whereConditions inputs, …), parses the result and builds the executable schema. The cache stores the parsed, manipulated document, so a boot skips everything up to that last step.

Measured on synthetic schemas (one machine; orders of magnitude, not promises):

SchemaWithout cacheCached boot
150 models (≈1,000 types)260 ms145 ms
600 models (≈4,300 types)1,060 ms560 ms

It works the way Bootsnap does: a content-addressed file in tmp/cache/lighthouse (config.schema_cache_path to move it).

  • The key. It covers the SDL, the Lighthouse, graphql-ruby and Ruby versions, the registered directive and type definitions, the manipulators' source and the configuration they read. Change any of them and the next boot rebuilds, so a stale schema cannot be served and there is nothing to clear by hand.
  • Writes. They are atomic, so concurrent boots never read half a file. Only the current entry is kept.
  • Errors. An unreadable file is a cache miss, never an error.

To ship a warm cache, build it where the release is built (e.g. a Docker image layer):

bin/rails lighthouse:cache        # writes tmp/cache/lighthouse/schema-<key>.bin
bin/rails lighthouse:clear_cache  # deletes it (never required: keys change on their own)

Unlike PHP Lighthouse's lighthouse:cache, forgetting to run it is harmless: the first boot writes the cache.

Reloading the SDL in development

Lighthouse.configure { |config| config.watch_sdl = Rails.env.development? }

Rails' reloader watches Ruby files, not .graphql ones, so without this an SDL edit only shows up after restarting the server. With it, the folder of config.schema_path joins config.watchable_dirs, and an edit to any .graphql file below it reloads the app, and with it the schema, on the next request, like a Ruby change. The reload also rewrites the IDE helper files (config.ide_helper). This relies on the schema class living in an autoloaded file, as app/graphql/app_schema.rb does.

Persisted queries

Lighthouse.configure do |config|
  config.persisted_queries = true
  config.persisted_queries_ttl = 1.week.to_i   # optional; default: until the store evicts
end

This is the Automatic Persisted Queries protocol, as Apollo Client (createPersistedQueryLink), urql and PHP Lighthouse speak it:

  1. The client sends extensions: { persistedQuery: { version: 1, sha256Hash } } without the query.
  2. A known hash runs. An unknown one answers PersistedQueryNotFound, and the client retries with the query, which is checked against the hash and stored in config.persisted_queries_store (default: Rails.cache).
  3. With the feature off, clients get PersistedQueryNotSupported and simply keep sending queries.

The generated controller runs requests through Lighthouse::GraphQL::PersistedQueries.execute. A hash fits in a URL, so add a GET route and CDNs can cache responses under the Cache-Control header that @cacheControl computes. Rack's ETag middleware adds an ETag too:

# config/routes.rb
post "/graphql", to: "graphql#execute"
get  "/graphql", to: "graphql#execute"

Parsing and validating a query costs well under a millisecond, so this is about bandwidth, CDN caching and control, not CPU.

Trusted documents: a manifest and an allowlist

Large APIs often accept only the operations their own clients ship. Point Lighthouse at a manifest generated by the client build, either Apollo's persisted query manifest ({ "operations": [{ "id": "<sha256>", "body": "query …" }] }) or a plain { "<sha256>": "query …" } object:

config.persisted_queries_manifest = Rails.root.join('persisted-queries.json')
config.persisted_queries_only = Rails.env.production?
  • The manifest's operations are served from memory, so a cache eviction can never drop them. The file is reloaded when it changes, and an id that is not the SHA-256 of its body is rejected.
  • With persisted_queries_only, nothing else runs, and arbitrary queries get Only persisted queries are allowed. (PERSISTED_QUERY_NOT_IN_LIST).

Check in CI that a schema change does not break an operation already shipped to clients:

bin/rails lighthouse:persisted_queries:verify SCHEMA=AppSchema

What is deliberately not cached

  • Query ASTs and validation results. PHP Lighthouse caches them because PHP starts from scratch on every request. Here, parsing and validating a sizeable query takes about 0.2 ms and 0.6 ms. A shared store (Redis, Memcached) costs a network round trip, which is more than parsing, and validation can depend on the request's context, which a cache keyed by query text would skip.
  • Compiled Ruby. Your app's code, and Lighthouse's, are already compiled and cached by Bootsnap (instruction sequences) and YJIT. The SDL is not Ruby, so there is nothing for them to compile.
Edit this page on GitHub
Next
Caching