Skip to content

Automatic Persisted Queries

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:

Terminal window
# 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:

Terminal window
# 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:

Terminal window
FRAISEQL_APQ_ENABLED=false fraiseql run

For 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:

Terminal window
REDIS_URL=redis://your-redis:6379 fraiseql run

APQ uses extensions to send the query hash:

{
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "abc123def456..."
}
},
"variables": {
"id": "user-123"
}
}
  1. First request: Client sends hash only (no query string)
  2. Cache miss: Server returns PersistedQueryNotFound error
  3. Retry with query: Client sends hash + full query string
  4. Cached: Server caches query, returns data
  5. Subsequent requests: Client sends hash only, server uses cache
import { 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)
});

You can test the full APQ flow manually to verify your configuration:

Terminal window
# 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 it
curl -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 = true
allow_get = true
GET /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 = true
require_prepopulated = true # Reject queries not in cache

For production, prepopulate the APQ cache at build time:

extract-queries.js
const fs = require('fs');
const crypto = require('crypto');
const glob = require('glob');
const queries = {};
// Find all GraphQL queries in source
glob.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));
Terminal window
# Upload extracted queries
fraiseql apq upload --file queries.json

APQ 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:

MetricDescription
fraiseql_apq_hits_totalCache hits
fraiseql_apq_misses_totalCache misses
fraiseql_apq_cache_sizeCurrent cache size
fraiseql_apq_bytes_savedBandwidth saved
# APQ hit rate
sum(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 / 1024

Typical GraphQL queries are 1–10 KB. Hashes are 64 bytes.

Query SizeWith APQSavings
1 KB64 B94%
5 KB64 B99%
10 KB64 B99%
  • Less data to transmit — Faster parsing after first request
  • CDN caching — With GET requests enabled
  • Mobile — Reduced cellular data usage and better battery life
.github/workflows/deploy.yml
- name: Extract and upload queries
run: |
npm run extract-queries
fraiseql apq upload --file queries.json

Target 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 queries

Single-instance memory cache doesn’t share across servers:

# Production with multiple instances
[fraiseql.apq]
backend = "redis"
redis_url = "${REDIS_URL}"
  1. Check client is sending hashes correctly
  2. Verify cache TTL isn’t too short
  3. Check for query variations (different whitespace, variable names)
Terminal window
# Check APQ status
fraiseql apq status
# Test a query
fraiseql apq lookup --hash "abc123..."

Ensure client and server use the same hashing:

  • Algorithm: SHA-256
  • Input: Query string with normalized whitespace
  • Output: Hex-encoded lowercase

Caching

Caching — Response caching

Deployment

Deployment — Production configuration