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 1,000 query strings in memory with a 1-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, cached queries can be sent as GET requests:

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" }
}]
}

APQ caching is additive by default — any query can be registered on first use. For deployments where you want to restrict execution to known queries only, use the redis-apq feature flag in combination with infrastructure-level controls (e.g., only pre-seeding the Redis cache before deploy and blocking the registration flow via a reverse proxy).

APQ queries are registered automatically on first use — no batch upload API is needed. To warm the cache after a deploy, run your queries once against the live server:

Terminal window
# Warm cache by executing each query once after deploy
for hash in $(cat queries.json | jq -r 'keys[]'); do
query=$(cat queries.json | jq -r --arg h "$hash" '.[$h]')
curl -s -X POST $FRAISEQL_URL/graphql \
-H "Content-Type: application/json" \
-d "{\"query\": $(echo "$query" | jq -R -s '.'), \
\"extensions\": {\"persistedQuery\": {\"version\": 1, \"sha256Hash\": \"$hash\"}}}" \
> /dev/null
done

Monitor APQ performance:

MetricDescription
fraiseql_apq_hits_totalCache hits
fraiseql_apq_misses_totalCache misses
fraiseql_apq_stored_totalQueries stored
fraiseql_apq_redis_errors_totalRedis backend errors (only present with redis-apq feature)
# APQ hit rate
sum(rate(fraiseql_apq_hits_total[5m])) /
(sum(rate(fraiseql_apq_hits_total[5m])) + sum(rate(fraiseql_apq_misses_total[5m])))
# Queries stored over time
rate(fraiseql_apq_stored_total[5m])

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

After deploying, run your queries once against the live server so subsequent requests can use the hash-only path:

.github/workflows/deploy.yml
- name: Warm APQ cache
run: npm run warm-apq-cache # script that POSTs each query+hash once to $FRAISEQL_URL/graphql

Target greater than 90% hit rate in production:

fraiseql_apq_hits_total / (fraiseql_apq_hits_total + fraiseql_apq_misses_total) > 0.9

The in-memory APQ store uses an LRU cache with a default capacity of 1,000 query entries. This is not currently configurable via TOML or environment variables — if you need a larger cache, use the redis-apq feature flag (Redis has no fixed capacity limit).

Single-instance memory cache doesn’t share across servers. Enable the redis-apq Cargo feature and set REDIS_URL:

Terminal window
REDIS_URL=redis://your-redis:6379 fraiseql run
  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 hit/miss counts via the metrics endpoint
curl -s http://localhost:8080/metrics | grep fraiseql_apq
# Test manually: send hash + query to register, then hash-only to verify
curl -s -X POST http://localhost:8080/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{__typename}","extensions":{"persistedQuery":{"version":1,"sha256Hash":"ecf4edb46db40b5132295c0291d62fb65d6759a9aeacfe1f108c30a4740c5f71"}}}'

Ensure client and server use the same hashing:

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

APQ (Automatic Persisted Queries) is GraphQL-only. REST endpoints are identified by path, gRPC endpoints by service/method name — neither uses query hashing. APQ configuration has no effect on REST or gRPC transports.

Caching

Caching — Response caching

Deployment

Deployment — Production configuration