Caching
Caching — Response caching
Automatic Persisted Queries (APQ) reduce bandwidth and improve latency by caching query strings on the server.
Instead of sending the full query text on every request, clients send a query hash:
First Request: The client sends the query along with its hash. The server caches the query and returns data.
Subsequent Requests: The client sends only the hash (much smaller). The server looks up the query from cache and returns data.
Without APQ, every request carries the full query string:
# Without APQ — full query on every request (~1.2 KB body)curl -s -X POST https://api.example.com/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "query GetUserProfile($id: ID!) { user(id: $id) { id name email createdAt orders(last: 5) { id total status } } }", "variables": { "id": "user-123" } }'With APQ, after the first request only the 64-byte hash is sent:
# With APQ — hash only (~120 byte body, 90%+ smaller)curl -s -X POST https://api.example.com/graphql \ -H "Content-Type: application/json" \ -d '{ "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd" } }, "variables": { "id": "user-123" } }'
# Response (same data, no query overhead):# { "data": { "user": { "id": "user-123", "name": "Alice", ... } } }APQ is enabled by default with an in-memory LRU cache. No TOML configuration is required for single-instance deployments.
No configuration needed. The server caches up to 10,000 query strings in memory with a 24-hour TTL.
To disable APQ (e.g., in testing), set the environment variable:
FRAISEQL_APQ_ENABLED=false fraiseql runFor deployments behind a load balancer, use the Redis backend via the redis-apq Cargo feature. Set REDIS_URL in your environment — FraiseQL picks it up automatically when the feature is enabled:
REDIS_URL=redis://your-redis:6379 fraiseql runAPQ uses extensions to send the query hash:
{ "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc123def456..." } }, "variables": { "id": "user-123" }}PersistedQueryNotFound errorimport { ApolloClient, InMemoryCache } from '@apollo/client';import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';import { createHttpLink } from '@apollo/client/link/http';import { sha256 } from 'crypto-hash';
const httpLink = createHttpLink({ uri: '/graphql' });const persistedQueriesLink = createPersistedQueryLink({ sha256 });
const client = new ApolloClient({ cache: new InMemoryCache(), link: persistedQueriesLink.concat(httpLink)});import { createClient, fetchExchange } from 'urql';import { persistedExchange } from '@urql/exchange-persisted';
const client = createClient({ url: '/graphql', exchanges: [ persistedExchange({ preferGetForPersistedQueries: true }), fetchExchange ]});const sha256 = require('crypto-js/sha256');
async function executeQuery(query, variables) { const hash = sha256(query).toString();
// Try with hash only let response = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ extensions: { persistedQuery: { version: 1, sha256Hash: hash } }, variables }) });
let result = await response.json();
// If not found, send with full query if (result.errors?.[0]?.message === 'PersistedQueryNotFound') { response = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, extensions: { persistedQuery: { version: 1, sha256Hash: hash } }, variables }) }); result = await response.json(); }
return result;}You can test the full APQ flow manually to verify your configuration:
# Step 1: Attempt hash-only request (expect PersistedQueryNotFound)curl -s -X POST http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -d '{ "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9aeacfe1f108c30a4740c5f71" } } }'# Response: {"errors":[{"message":"PersistedQueryNotFound","extensions":{"code":"PERSISTED_QUERY_NOT_FOUND"}}]}
# Step 2: Send hash + query to register itcurl -s -X POST http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "{ __typename }", "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9aeacfe1f108c30a4740c5f71" } } }'# Response: {"data":{"__typename":"Query"}}
# Step 3: Now hash-only works (query is cached)curl -s -X POST http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -d '{ "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9aeacfe1f108c30a4740c5f71" } } }'# Response: {"data":{"__typename":"Query"}}With APQ, you can use GET requests for cached queries:
[fraiseql.apq]enabled = trueallow_get = trueGET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"abc123"}}&variables={"id":"123"}FraiseQL verifies that the query matches the hash when both are sent together:
{ "query": "{ users { id } }", "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "wrong-hash-value-here" } }}Response:
{ "errors": [{ "message": "Provided sha does not match query", "extensions": { "code": "BAD_USER_INPUT" } }]}For maximum security, only allow prepopulated queries:
[fraiseql.apq]enabled = truerequire_prepopulated = true # Reject queries not in cacheFor production, prepopulate the APQ cache at build time:
const fs = require('fs');const crypto = require('crypto');const glob = require('glob');
const queries = {};
// Find all GraphQL queries in sourceglob.sync('src/**/*.graphql').forEach(file => { const query = fs.readFileSync(file, 'utf8'); const hash = crypto.createHash('sha256').update(query).digest('hex'); queries[hash] = query;});
fs.writeFileSync('queries.json', JSON.stringify(queries, null, 2));# Upload extracted queriesfraiseql apq upload --file queries.jsonAPQ cache management is handled via the REST API at /api/v1/apq/ when the server is running. See CLI Reference for full command reference. There is no fraiseql apq CLI subcommand — use the REST API directly.
Monitor APQ performance:
| Metric | Description |
|---|---|
fraiseql_apq_hits_total | Cache hits |
fraiseql_apq_misses_total | Cache misses |
fraiseql_apq_cache_size | Current cache size |
fraiseql_apq_bytes_saved | Bandwidth saved |
# APQ hit ratesum(rate(fraiseql_apq_hits_total[5m])) /(sum(rate(fraiseql_apq_hits_total[5m])) + sum(rate(fraiseql_apq_misses_total[5m])))
# Bandwidth saved (MB/s)sum(rate(fraiseql_apq_bytes_saved[5m])) / 1024 / 1024Typical GraphQL queries are 1–10 KB. Hashes are 64 bytes.
| Query Size | With APQ | Savings |
|---|---|---|
| 1 KB | 64 B | 94% |
| 5 KB | 64 B | 99% |
| 10 KB | 64 B | 99% |
- name: Extract and upload queries run: | npm run extract-queries fraiseql apq upload --file queries.jsonTarget greater than 90% hit rate in production:
fraiseql_apq_hits_total / (fraiseql_apq_hits_total + fraiseql_apq_misses_total) > 0.9# Estimate: unique queries x 2 (for safety margin)[fraiseql.apq]cache_size = 10000 # For ~5000 unique queriesSingle-instance memory cache doesn’t share across servers:
# Production with multiple instances[fraiseql.apq]backend = "redis"redis_url = "${REDIS_URL}"# Check APQ statusfraiseql apq status
# Test a queryfraiseql apq lookup --hash "abc123..."Ensure client and server use the same hashing:
Caching
Caching — Response caching
Performance
Performance — Additional optimizations
Deployment
Deployment — Production configuration