APQ Optimization Guide¶
FraiseQL Automatic Persisted Queries - Performance Tuning & Best Practices
Overview¶
APQ (Automatic Persisted Queries) is a GraphQL optimization technique that eliminates query parsing overhead by caching parsed queries by their SHA256 hash. FraiseQL's APQ implementation provides two layers of caching:
- Query Cache: Stores query strings by hash (always active)
- Response Cache: Stores complete query responses (optional)
APQ vs TurboRouter: Understanding the Performance Stack¶
FraiseQL offers multiple performance optimizations that work together:
APQ (Automatic Persisted Queries)¶
- What it does: Caches GraphQL query parsing by SHA256 hash
- Performance gain: Eliminates 20-80ms parsing overhead per request
- Scope: All queries (automatic)
- Storage: Query text in database or memory
TurboRouter¶
- What it does: Bypasses GraphQL parsing and validation entirely for registered queries
- Performance gain: 2-3x additional speedup beyond APQ
- Scope: Pre-registered queries only
- Storage: Pre-compiled SQL templates with parameter mapping
How They Work Together¶
# Request flow with both optimizations
query MyQuery($id: ID!) {
user(id: $id) { name email }
}
# 1. APQ: Check if query hash exists (avoids parsing)
# 2. TurboRouter: If query is registered, execute SQL directly
# 3. Otherwise: Fall back to normal GraphQL execution with APQ caching
Performance Stack: - Base GraphQL: 100ms average response - + APQ: 20-80ms faster (eliminates parsing) - + TurboRouter: Additional 2-3x speedup (bypasses GraphQL entirely) - Total: Up to 6-9x faster for registered queries
Performance Impact¶
Query Cache Benefits: - Eliminates 20-80ms query parsing overhead per request - Reduces network payload (hash instead of full query) - Target: 90%+ hit rate in production
Response Cache Benefits: - Can provide 260-460x speedup for identical queries - Bypasses GraphQL execution entirely - Best for read-heavy, cacheable data
Understanding APQ¶
Two-Layer Caching Strategy¶
FraiseQL uses a sophisticated caching approach:
┌──────────────────────────────────────────────────────────┐
│ APQ Request Flow │
└──────────────────────────────────────────────────────────┘
1. Client sends: {"extensions": {"persistedQuery": {"sha256Hash": "abc123..."}}}
2. FraiseQL checks Response Cache (if enabled)
├─ HIT → Return cached response immediately (fastest)
└─ MISS → Continue to step 3
3. FraiseQL checks Query Cache
├─ HIT → Use cached query string, execute GraphQL
└─ MISS → Request full query from client, store it
4. Execute GraphQL query → Generate response
5. Store response in Response Cache (if enabled, for future requests)
6. Return response to client
When to Use Each Layer¶
Query Cache (Always Use): - ✅ All production environments - ✅ Development (helpful for debugging) - ✅ No downside, minimal overhead - ✅ Automatic query string deduplication
Response Cache (Selective Use): - ✅ Read-heavy APIs with cacheable data - ✅ Public data that doesn't change frequently - ✅ Queries without user-specific data - ❌ User-specific queries (unless using tenant isolation) - ❌ Real-time data requirements - ❌ High mutation rate data
APQ Modes¶
New in FraiseQL v1.6.1
FraiseQL supports three APQ modes to control how queries are accepted:
Mode Overview¶
| Mode | Description | Use Case |
|---|---|---|
optional |
Accept both persisted queries and arbitrary queries (default) | Standard deployments |
required |
Only accept persisted query hashes, reject arbitrary queries | Security-hardened deployments |
disabled |
Ignore APQ extensions entirely, always require full query | Debugging, APQ bypass |
Security Hardening with required Mode¶
For security-sensitive deployments, use apq_mode="required" to ensure only pre-approved queries can execute:
from fraiseql.fastapi import FraiseQLConfig, create_fraiseql_app
# Security-hardened configuration
config = FraiseQLConfig(
database_url="postgresql://localhost/db",
apq_mode="required", # Only allow persisted queries
apq_queries_dir="./graphql/", # Auto-register queries from directory
)
app = create_fraiseql_app(config=config, types=[User, Post])
Benefits of required mode:
- ✅ Prevent arbitrary queries - Block query exploration/introspection attacks
- ✅ Audit all queries - Know exactly which queries can run in production
- ✅ Control API surface - Only approved queries from the codebase can execute
- ✅ Reduce attack surface - Eliminate GraphQL injection vectors
Query Registration Methods¶
Method 1: Auto-register from Directory¶
Load all .graphql and .gql files at startup:
config = FraiseQLConfig(
apq_mode="required",
apq_queries_dir="./graphql/queries/", # Recursively loads all .graphql files
)
Directory structure example:
graphql/
├── users/
│ ├── queries.graphql
│ └── mutations.graphql
├── posts/
│ └── operations.graphql
└── shared/
└── fragments.graphql
Method 2: Programmatic Registration¶
Register queries at startup using the backend API:
from fraiseql.storage.backends.factory import create_apq_backend
# Get the APQ backend
backend = create_apq_backend(config)
# Register allowed queries
queries = [
"query GetUsers { users { id name } }",
"query GetUser($id: ID!) { user(id: $id) { id name email } }",
"mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id } }",
]
# Returns dict mapping hash -> query
registered = backend.register_queries(queries)
print(f"Registered {len(registered)} queries")
Method 3: Load from Files Programmatically¶
from fraiseql.storage.query_loader import load_queries_from_directory
from fraiseql.storage.backends.factory import create_apq_backend
# Load queries from directory
queries = load_queries_from_directory("./graphql/")
# Register with backend
backend = create_apq_backend(config)
backend.register_queries(queries)
Error Response for Rejected Queries¶
When apq_mode="required" and an arbitrary query is sent:
{
"errors": [
{
"message": "Persisted queries required. Arbitrary queries are not allowed.",
"extensions": {
"code": "ARBITRARY_QUERY_NOT_ALLOWED",
"details": "Configure your client to use Automatic Persisted Queries (APQ) or register queries at build time."
}
}
]
}
Disabled Mode¶
Use apq_mode="disabled" to completely bypass APQ processing:
This is useful for: - Debugging APQ issues - Temporary bypass during development - Legacy client compatibility
When to Enable APQ¶
Query Cache (Default: Enabled)¶
Always enable query caching - it provides pure performance benefits with no downsides.
Benefits: - Eliminates query parsing overhead - Reduces network payload size - Improves response time consistency - Automatic deduplication of queries
Response Cache (Default: Disabled)¶
Enable response caching when you have:
- Cacheable Data Patterns:
- Public data (blogs, docs, product catalogs)
- Reference data (countries, currencies, categories)
- Aggregated statistics
-
Infrequently changing data
-
Traffic Patterns:
- Repeated identical queries
- High read-to-write ratio (>10:1)
-
Predictable query patterns
-
Performance Requirements:
- Sub-10ms response time targets
- High throughput requirements (>1000 req/s)
- Cost optimization (reduce compute)
Do NOT enable response caching when: - Data changes frequently (real-time updates) - Queries are highly personalized - Strong consistency requirements - Complex authorization rules
Configuration Guide¶
Basic Configuration¶
from fraiseql.fastapi.config import FraiseQLConfig
# Query cache only (recommended starting point)
config = FraiseQLConfig(
db_url="postgresql://...",
apq_storage_backend="memory", # or "postgresql"
apq_cache_responses=False, # Response caching disabled
)
# Full APQ with response caching
config = FraiseQLConfig(
db_url="postgresql://...",
apq_storage_backend="memory",
apq_cache_responses=True, # Enable response caching
apq_backend_config={
"response_ttl": 300, # 5 minutes
}
)
Storage Backend Options¶
1. Memory Backend (Default)¶
Best for: Development, small deployments, single-instance apps
Pros: - Fastest performance (<0.1ms lookup) - Zero external dependencies - Simple configuration
Cons: - Lost on restart - Not shared across instances - Memory consumption grows with queries
Recommended: Development and single-server production
2. PostgreSQL Backend¶
Best for: Production, multi-instance deployments, persistence
config = FraiseQLConfig(
apq_storage_backend="postgresql",
apq_backend_config={
"db_url": "postgresql://...",
"table_name": "apq_cache",
"response_ttl": 300, # 5 minutes
}
)
Pros: - Shared across instances - Survives restarts - Leverages existing PostgreSQL infrastructure - Automatic cleanup via TTL
Cons: - Slightly slower than memory (~1-2ms) - Requires database connection - Additional database load
Recommended: Production with multiple app instances
Monitoring & Metrics¶
Dashboard Access¶
Access the interactive monitoring dashboard:
Features: - Real-time hit rate visualization - Top queries analysis - Health status monitoring - Performance trends
Key Metrics to Monitor¶
1. Query Cache Hit Rate¶
Target: >70% (ideally >90%)
What it means: - >90%: Excellent - queries are being reused effectively - 70-90%: Good - normal for varied query patterns - 50-70%: Warning - high query diversity or cache warming needed - <50%: Critical - investigate query patterns or cache configuration
2. Response Cache Hit Rate¶
Target: >50% (when enabled)
What it means: - >80%: Excellent - significant performance gains - 50-80%: Good - response caching is beneficial - 30-50%: Marginal - consider disabling if overhead isn't worth it - <30%: Poor - disable response caching
3. Top Queries¶
Monitor the top queries endpoint:
Look for: - High miss rate on frequent queries (cache warming opportunity) - Queries with long parse times (optimization candidates) - Unexpected query patterns (potential issues)
Prometheus Integration¶
Add to your Prometheus configuration:
# prometheus.yml
scrape_configs:
- job_name: 'fraiseql-apq'
metrics_path: '/admin/apq/metrics'
scrape_interval: 15s
static_configs:
- targets: ['localhost:8000']
Available metrics:
- apq_query_cache_hit_rate: Query cache effectiveness
- apq_response_cache_hit_rate: Response cache effectiveness
- apq_requests_total: Total APQ requests
- apq_storage_bytes_total: Cache memory usage
- apq_health_status: System health status
Optimization Strategies¶
1. Improve Query Cache Hit Rate¶
Strategy: Cache Warming¶
Pre-populate the cache with common queries:
from fraiseql.storage.apq_store import store_persisted_query, compute_query_hash
# Get top queries from analytics
top_queries = [
"query GetUsers { users { id name email } }",
"query GetPosts { posts { id title content } }",
# ... more queries
]
# Pre-warm the cache
for query in top_queries:
hash_value = compute_query_hash(query)
store_persisted_query(hash_value, query)
Strategy: Client-Side APQ¶
Configure your GraphQL client to use APQ:
Apollo Client:
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";
const link = createPersistedQueryLink({ sha256 });
urql:
import { Client, cacheExchange, fetchExchange } from "urql";
import { persistedExchange } from "@urql/exchange-persisted";
const client = new Client({
exchanges: [persistedExchange({ generateHash: sha256 }), cacheExchange, fetchExchange],
});
2. Optimize Response Cache Hit Rate¶
Strategy: Tenant Isolation¶
For multi-tenant applications:
from fraiseql.middleware.apq_caching import handle_apq_request_with_cache
# Add tenant context
context = {"tenant_id": request.headers.get("X-Tenant-ID")}
cached_response = handle_apq_request_with_cache(
request=graphql_request,
backend=backend,
config=config,
context=context, # Tenant-specific caching
)
Strategy: TTL Tuning¶
Adjust response TTL based on data freshness requirements:
# Aggressive caching (5-15 minutes)
apq_backend_config={"response_ttl": 900} # 15 minutes
# Moderate caching (1-5 minutes)
apq_backend_config={"response_ttl": 300} # 5 minutes
# Short-term caching (30-60 seconds)
apq_backend_config={"response_ttl": 60} # 1 minute
Strategy: Selective Caching¶
Cache only specific query types:
from fraiseql.middleware.apq_caching import is_cacheable_response
def custom_is_cacheable(response: dict, query_string: str) -> bool:
"""Custom caching logic."""
# Only cache read-only queries
if "mutation" in query_string.lower():
return False
# Don't cache queries with specific directives
if "@nocache" in query_string:
return False
# Use default logic
return is_cacheable_response(response)
3. Storage Optimization¶
Monitor Cache Size¶
from fraiseql.storage.apq_store import get_storage_stats
stats = get_storage_stats()
print(f"Stored queries: {stats['stored_queries']}")
print(f"Total size: {stats['total_size_bytes'] / 1024:.1f} KB")
Implement Eviction (PostgreSQL/Redis)¶
PostgreSQL backend automatically cleans up expired entries. For memory backend, implement periodic cleanup:
import asyncio
from fraiseql.storage.apq_store import clear_storage
async def periodic_cleanup():
"""Clear cache every 24 hours."""
while True:
await asyncio.sleep(86400) # 24 hours
clear_storage()
print("APQ cache cleared")
# Run in background
asyncio.create_task(periodic_cleanup())
Troubleshooting¶
Problem: Low Query Cache Hit Rate (<70%)¶
Diagnosis:
Common Causes:
- Client not configured for APQ
- Solution: Configure GraphQL client to send
persistedQueryextension -
Verify: Check network requests for
extensions.persistedQuery.sha256Hash -
High query diversity
- Solution: This is expected for APIs with many unique queries
-
Target: Optimize the most frequent queries instead of all queries
-
Cache cleared frequently
- Solution: Use PostgreSQL or Redis backend instead of memory
-
Verify: Check
apq_stored_queries_totalmetric over time -
Development environment
- Solution: Low hit rates are normal during development
- Action: Focus on production metrics
Problem: Response Cache Not Working¶
Diagnosis:
Common Causes:
-
Response caching disabled
-
Queries with errors
- Responses with errors are never cached
-
Solution: Fix query errors or validation issues
-
User-specific queries
- Different users get different responses
-
Solution: Implement tenant isolation with context
-
Cache expired
- TTL too short for query patterns
- Solution: Increase
response_ttlin config
Problem: High Memory Usage¶
Diagnosis:
Solutions:
-
Switch to PostgreSQL backend:
-
Reduce response TTL:
-
Implement cache size limits:
Problem: Stale Data Being Served¶
Diagnosis: Response cache serving outdated data after mutations
Solutions:
-
Disable response caching:
-
Reduce TTL for volatile data:
-
Implement cache invalidation:
-
Use materialized views instead:
- FraiseQL already uses
tv_{entity}materialized views - These provide data-level caching at PostgreSQL layer
- More appropriate for frequently changing data
Production Best Practices¶
1. Configuration Checklist¶
✅ Always Enable:
- [ ] Query caching (apq_storage_backend configured)
- [ ] Metrics tracking (automatic)
- [ ] Health monitoring endpoint
- [ ] Dashboard access for operations team
✅ Consider Enabling: - [ ] Response caching (if read-heavy workload) - [ ] PostgreSQL/Redis backend (if multi-instance) - [ ] Prometheus integration (if using monitoring)
✅ Never Do: - [ ] Enable response caching for user-specific data without tenant isolation - [ ] Use memory backend in multi-instance deployments - [ ] Ignore health warnings (hit rate <50%)
2. Monitoring Setup¶
Set up alerts for:
-
Critical Alert: Hit Rate <50%
-
Warning Alert: Hit Rate <70%
-
Storage Alert: High Memory Usage
3. Performance Testing¶
Before enabling in production:
-
Baseline without APQ:
-
Test with query cache only:
-
Test with full APQ:
-
Compare metrics:
- Response time percentiles (p50, p95, p99)
- Throughput (requests/second)
- Memory usage
- CPU usage
4. Rollout Strategy¶
Phase 1: Query Cache Only 1. Enable memory backend in production 2. Monitor for 1 week 3. Verify hit rate >70% 4. No rollback needed (pure performance gain)
Phase 2: PostgreSQL Backend (if multi-instance) 1. Deploy PostgreSQL backend to canary 2. Monitor for 48 hours 3. Verify no increased latency 4. Roll out to production
Phase 3: Response Caching (if applicable)
1. Enable for read-only, public queries only
2. Start with short TTL (60s)
3. Monitor for stale data issues
4. Gradually increase TTL if no issues
5. Rollback plan: Set apq_cache_responses=False
5. Maintenance¶
Daily: - Check dashboard for warnings - Monitor hit rates - Review top queries
Weekly: - Analyze hit rate trends - Review storage usage - Check for query pattern changes
Monthly: - Review and optimize top queries - Audit cache effectiveness - Update TTL configuration if needed
Quarterly: - Performance benchmark comparison - Review backend choice (memory vs PostgreSQL vs Redis) - Consider cache warming strategies
Advanced Topics¶
Custom Cache Backends¶
Implement custom storage backend:
from fraiseql.storage.backends.base import APQStorageBackend
class CustomBackend(APQStorageBackend):
def get_persisted_query(self, hash_value: str) -> str | None:
# Your implementation
pass
def store_persisted_query(self, hash_value: str, query: str) -> None:
# Your implementation
pass
def get_cached_response(self, hash_value: str, context=None) -> dict | None:
# Your implementation
pass
def store_cached_response(self, hash_value: str, response: dict, context=None) -> None:
# Your implementation
pass
Integration with CDN¶
For public APIs, combine with CDN caching:
from fastapi import Response
@app.post("/graphql")
async def graphql_endpoint(request: GraphQLRequest, response: Response):
# Add cache headers for CDN
if is_public_query(request):
response.headers["Cache-Control"] = "public, max-age=300"
# APQ handles query and response caching
return await execute_graphql(request)
Multi-Tier Caching Strategy¶
Combine FraiseQL caching layers:
┌────────────────────────────────────────────────────┐
│ CDN Layer (Cloudflare, Fastly) │
│ • Full response caching │
│ • 5-15 minute TTL │
│ • Public queries only │
└─────────────────┬──────────────────────────────────┘
│ CDN miss
↓
┌────────────────────────────────────────────────────┐
│ APQ Response Cache │
│ • FraiseQL in-process or Redis │
│ • 1-5 minute TTL │
│ • All cacheable queries │
└─────────────────┬──────────────────────────────────┘
│ Response cache miss
↓
┌────────────────────────────────────────────────────┐
│ APQ Query Cache │
│ • Eliminates parsing overhead │
│ • Permanent (no TTL) │
│ • All queries │
└─────────────────┬──────────────────────────────────┘
│ Query cache miss
↓
┌────────────────────────────────────────────────────┐
│ PostgreSQL Materialized Views (tv_{entity}) │
│ • Data-level caching │
│ • Refresh strategy configured per entity │
│ • All queries │
└─────────────────┬──────────────────────────────────┘
│ Materialized view miss
↓
┌────────────────────────────────────────────────────┐
│ PostgreSQL Base Tables │
│ • Source of truth │
│ • Full query execution │
└────────────────────────────────────────────────────┘
Further Reading¶
Last Updated: 2025-10-23 | FraiseQL v1.6.1