GraphQL Cascade Best Practices¶
This guide provides recommendations for effectively using GraphQL Cascade in your FraiseQL applications. Cascade enables automatic cache updates and side effect tracking, but proper usage is key to maximizing benefits while avoiding pitfalls.
Quick Decision Matrix¶
| Mutation Type | CASCADE? | Reason |
|---|---|---|
| Create post with author stats update | ✅ YES | Multiple entities affected, cache needs update |
| Update user preference (UI only) | ❌ NO | Single entity, no side effects |
| Bulk data import (100+ records) | ❌ NO | Large payload, server-side operation |
| Mobile app background sync | ❌ NO | Bandwidth concerns, not user-facing |
| Order creation (inventory update) | ✅ YES | Side effects (inventory) need cache update |
| Delete comment (update counts) | ✅ YES | Related entities (post, user) affected |
| Simple status toggle | ❌ NO | Single field update, no dependencies |
| Create invoice (update account balance) | ✅ YES | Financial side effects critical for UI |
Rule of Thumb: - Use CASCADE when side effects affect entities the client cares about - Skip CASCADE for simple CRUD or server-side operations
When to Use Cascade¶
✅ Request CASCADE When:¶
You Need Cache Updates
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
... on CreatePostSuccess {
post { id title }
cascade {
updated { __typename id entity }
invalidations { queryName }
}
}
}
}
You're Using Apollo Client or Similar CASCADE works seamlessly with Apollo Client's automatic cache updates.
You Have Complex Mutations Mutations that affect multiple entities benefit from CASCADE for consistency.
❌ Don't Request CASCADE When:¶
Simple Display-Only Mutations
mutation UpdateUserPreference($input: PreferenceInput!) {
updatePreference(input: $input) {
... on UpdatePreferenceSuccess {
message
# No cascade needed - just showing success message
}
}
}
Server-Side Only Operations Background jobs, webhooks, or API-to-API calls typically don't need CASCADE.
Mobile Clients with Limited Bandwidth Mobile clients on slow connections should avoid CASCADE unless absolutely necessary.
Partial CASCADE Selections¶
Request only the CASCADE fields you need:
# Only need to know affected count
cascade {
metadata { affectedCount }
}
# Only need invalidations for cache clearing
cascade {
invalidations { queryName strategy }
}
# Only need updated entities (not deletes or invalidations)
cascade {
updated {
__typename
id
entity
}
}
This reduces payload size while still getting needed side effect information.
✅ Good Candidates for Cascade¶
Multi-Entity Mutations
-- Creating a post updates both post and user entities
CREATE OR REPLACE FUNCTION graphql.create_post(input jsonb)
RETURNS jsonb AS $$
BEGIN
-- Create post
INSERT INTO tb_post (title, content, author_id) VALUES (...)
RETURNING id INTO v_post_id;
-- Update author's post count
UPDATE tb_user SET post_count = post_count + 1 WHERE id = v_author_id;
-- Return cascade for both entities
RETURN jsonb_build_object(
'success', true,
'data', jsonb_build_object('id', v_post_id),
'_cascade', app.build_cascade(
updated => jsonb_build_array(
app.cascade_entity('Post', v_post_id, 'CREATED', 'v_post'),
app.cascade_entity('User', v_author_id, 'UPDATED', 'v_user')
)
)
);
END;
$$ LANGUAGE plpgsql;
List Invalidation Requirements
-- New post requires invalidating post lists
'_cascade', app.build_cascade(
updated => jsonb_build_array(
app.cascade_entity('Post', v_post_id, 'CREATED', 'v_post')
),
invalidations => jsonb_build_array(
app.cascade_invalidation('posts', 'INVALIDATE', 'PREFIX'),
app.cascade_invalidation('userPosts', 'INVALIDATE', 'EXACT')
)
)
Complex Business Logic - Order placement updating inventory, user balance, and order history - User following updating follower counts and feed timelines - Comment creation updating post stats and notification counts
❌ When to Skip Cascade¶
Single Entity Updates
# Simple preference update - no cascade needed
@mutation # Not enable_cascade=True
class UpdateUserPreferences:
input: UpdatePreferencesInput
success: UpdatePreferencesSuccess
error: UpdatePreferencesError
Frequent, Independent Updates - Real-time cursor position updates - Typing indicators - Presence status changes
Large, Infrequent Operations - Bulk imports/exports - Database migrations - Administrative operations
Mutations Without CASCADE¶
For simple mutations with no side effects, skip CASCADE entirely to reduce payload size and improve performance.
GraphQL (No CASCADE Requested)¶
mutation UpdateUserPreference($input: PreferenceInput!) {
updatePreference(input: $input) {
... on UpdatePreferenceSuccess {
message # Just show success message
# NO cascade field requested
}
}
}
Response (minimal payload):
SQL Function (No CASCADE Tracking)¶
CREATE OR REPLACE FUNCTION fn_update_preference(
p_user_id UUID,
p_key TEXT,
p_value TEXT
) RETURNS JSONB AS $$
BEGIN
UPDATE tb_user_preferences
SET value = p_value, updated_at = NOW()
WHERE user_id = p_user_id AND key = p_key;
-- Simple success response (no _cascade object)
RETURN jsonb_build_object(
'success', true,
'message', 'Preference updated'
);
-- Note: No app.build_cascade() call
END;
$$ LANGUAGE plpgsql;
Python Mutation (CASCADE Disabled)¶
from fraiseql import mutation
@mutation # Note: enable_cascade=False by default
class UpdateUserPreference:
"""Simple preference update - no side effects"""
input: UpdatePreferenceInput
success: UpdatePreferenceSuccess
error: UpdatePreferenceError
Benefits: - ✅ Smaller payload: ~50-80% reduction vs CASCADE response - ✅ Faster response: No CASCADE tracking overhead - ✅ Lower bandwidth: Critical for mobile clients - ✅ Simpler debugging: Less data to inspect
Use this pattern for: - User preferences/settings - Simple status toggles - Single-field updates - Background operations
Designing Cascade Data¶
Entity Selection Principles¶
Include All Affected Entities
-- GOOD: Include both post and author
updated => jsonb_build_array(
app.cascade_entity('Post', v_post_id, 'CREATED', 'v_post'),
app.cascade_entity('User', v_author_id, 'UPDATED', 'v_user')
)
-- BAD: Missing author update
updated => jsonb_build_array(
app.cascade_entity('Post', v_post_id, 'CREATED', 'v_post')
)
Use Appropriate Operations
- CREATED: New entities added to the system
- UPDATED: Existing entities modified
- DELETED: Entities removed (use deleted array)
Keep Entity Data Complete
-- GOOD: Complete entity data
CREATE VIEW v_post AS
SELECT id, jsonb_build_object(
'id', id,
'title', title,
'content', content,
'author_id', author_id,
'created_at', created_at,
'updated_at', updated_at,
'like_count', like_count
) as data FROM tb_post;
-- BAD: Incomplete entity data
jsonb_build_object(
'id', id,
'title', title
-- Missing other fields clients expect
)
Invalidation Strategies¶
Query-Specific Invalidations
-- Invalidate specific query patterns
invalidations => jsonb_build_array(
-- Invalidate all post-related queries
app.cascade_invalidation('posts', 'INVALIDATE', 'PREFIX'),
-- Invalidate user-specific queries
app.cascade_invalidation('userPosts', 'INVALIDATE', 'EXACT')
)
Scope Options
- PREFIX: Invalidate queries starting with the name (e.g., posts, postsConnection)
- EXACT: Invalidate only exact query name matches
- SUFFIX: Invalidate queries ending with the name (less common)
Strategic Invalidation
-- For new posts: invalidate list queries but not individual post queries
invalidations => jsonb_build_array(
app.cascade_invalidation('posts', 'INVALIDATE', 'PREFIX'),
app.cascade_invalidation('feed', 'INVALIDATE', 'PREFIX')
)
-- For profile updates: invalidate user-specific queries
invalidations => jsonb_build_array(
app.cascade_invalidation('userProfile', 'INVALIDATE', 'EXACT'),
app.cascade_invalidation('currentUser', 'INVALIDATE', 'EXACT')
)
Performance Optimization¶
Payload Size Management¶
Keep Cascade Payloads Reasonable
-- GOOD: Essential entities only
-- Post creation affects: Post + Author (2 entities)
-- AVOID: Over-inclusive cascades
-- Don't include every related entity in the system
Monitor Payload Sizes
# Track cascade payload sizes in production
cascade_size = Histogram('fraiseql_cascade_payload_bytes', 'Cascade payload size')
Large Cascade Thresholds - Small: < 1KB (most mutations) - Medium: 1-10KB (complex business logic) - Large: > 10KB (review necessity)
Client Cache Efficiency¶
Prefer Entity Updates Over Invalidations
-- GOOD: Update specific entities
updated => jsonb_build_array(
app.cascade_entity('User', v_user_id, 'UPDATED', 'v_user')
)
-- LESS EFFICIENT: Invalidate and refetch
invalidations => jsonb_build_array(
app.cascade_invalidation('userProfile', 'INVALIDATE', 'EXACT')
)
Batch Related Updates
-- GOOD: Single cascade with multiple updates
updated => jsonb_build_array(
app.cascade_entity('Post', v_post_id, 'CREATED', 'v_post'),
app.cascade_entity('User', v_author_id, 'UPDATED', 'v_user'),
app.cascade_entity('Feed', v_feed_id, 'UPDATED', 'v_feed')
)
Error Handling¶
Cascade on Error Responses¶
Include Cascade on Partial Success
-- If some operations succeeded but validation failed
IF validation_error THEN
RETURN jsonb_build_object(
'success', false,
'error', jsonb_build_object('code', 'VALIDATION_ERROR', ...),
'_cascade', v_partial_cascade -- Still include successful updates
);
END IF;
No Cascade on Complete Failure
-- If nothing was actually changed
IF complete_failure THEN
RETURN jsonb_build_object(
'success', false,
'error', jsonb_build_object('code', 'PERMISSION_DENIED', ...)
-- No _cascade field
);
END IF;
Client Error Handling¶
Graceful Cascade Processing
const result = await client.mutate({ mutation: CREATE_POST, variables });
if (result.data?.createPost.cascade) {
try {
await applyCascadeToCache(result.data.createPost.cascade);
} catch (error) {
// Log error but don't fail the mutation
console.warn('Cascade application failed:', error);
// Optionally invalidate entire cache as fallback
client.cache.reset();
}
}
Validate Cascade Structure
function applyCascadeToCache(cascade: CascadeData) {
// Validate structure before processing
if (!cascade.updated && !cascade.deleted && !cascade.invalidations) {
throw new Error('Invalid cascade structure');
}
// Process updates...
}
Database Design¶
Entity View Patterns¶
Consistent View Naming
-- Use v_ prefix for cascade views
CREATE VIEW v_user AS SELECT id, jsonb_build_object(...) as data FROM tb_user;
CREATE VIEW v_post AS SELECT id, jsonb_build_object(...) as data FROM tb_post;
CREATE VIEW v_comment AS SELECT id, jsonb_build_object(...) as data FROM tb_comment;
Complete Entity Data
-- Include all fields clients typically need
CREATE VIEW v_post AS
SELECT id, jsonb_build_object(
'id', id,
'title', title,
'content', content,
'author', jsonb_build_object(
'id', author_id,
'name', (SELECT name FROM tb_user WHERE id = author_id)
),
'created_at', created_at,
'updated_at', updated_at,
'like_count', like_count,
'comment_count', comment_count
) as data FROM tb_post;
Performance Considerations
-- Add indexes for cascade view performance
CREATE INDEX idx_post_author_id ON tb_post(author_id);
CREATE INDEX idx_user_id ON tb_user(id);
-- Ensure views are fast to query
EXPLAIN ANALYZE SELECT data FROM v_post WHERE id = 'some-uuid';
Helper Function Usage¶
Standard Helper Functions
-- Use consistent helper functions across your application
CREATE OR REPLACE FUNCTION app.cascade_entity(text, uuid, text, text) RETURNS jsonb;
CREATE OR REPLACE FUNCTION app.cascade_invalidation(text, text, text) RETURNS jsonb;
CREATE OR REPLACE FUNCTION app.build_cascade(jsonb, jsonb, jsonb, jsonb) RETURNS jsonb;
Custom Helpers for Your Domain
-- Domain-specific cascade builders
CREATE OR REPLACE FUNCTION app.cascade_post_creation(uuid, uuid) RETURNS jsonb;
CREATE OR REPLACE FUNCTION app.cascade_user_update(uuid) RETURNS jsonb;
Client Integration Patterns¶
Apollo Client Best Practices¶
Type-Safe Cascade Handling
interface CascadeUpdate {
__typename: string;
id: string;
operation: 'CREATED' | 'UPDATED' | 'DELETED';
entity: any;
}
interface CascadeData {
updated: CascadeUpdate[];
deleted: { __typename: string; id: string }[];
invalidations: { queryName: string; strategy: string; scope: string }[];
metadata: { timestamp: string; affectedCount: number };
}
function applyCascade(cache: ApolloCache, cascade: CascadeData) {
// Apply updates
cascade.updated.forEach(update => {
const id = cache.identify({ __typename: update.__typename, id: update.id });
cache.writeFragment({
id,
fragment: gql`fragment _ on ${update.__typename} { id }`,
data: update.entity
});
});
// Apply deletions
cascade.deleted.forEach(deletion => {
const id = cache.identify(deletion);
cache.evict({ id });
});
// Apply invalidations
cascade.invalidations.forEach(invalidation => {
if (invalidation.strategy === 'INVALIDATE') {
// Implement invalidation logic based on scope
}
});
}
Optimistic Updates with Cascade
const [createPost] = useMutation(CREATE_POST, {
optimisticResponse: {
createPost: {
id: 'temp-id',
message: 'Post created',
cascade: {
// Include expected cascade data for optimistic updates
}
}
},
update: (cache, result) => {
if (result.data?.createPost.cascade) {
applyCascade(cache, result.data.createPost.cascade);
}
}
});
Error Recovery¶
Fallback Strategies
function applyCascadeWithFallback(cache: ApolloCache, cascade: CascadeData) {
try {
applyCascade(cache, cascade);
} catch (error) {
console.warn('Cascade application failed, falling back to cache reset');
// Fallback: reset cache to force refetch
cache.reset();
}
}
Partial Failure Handling
function applyCascadeRobust(cache: ApolloCache, cascade: CascadeData) {
let successCount = 0;
let failureCount = 0;
// Apply updates individually
cascade.updated.forEach(update => {
try {
const id = cache.identify(update);
cache.writeFragment({
id,
fragment: gql`fragment _ on ${update.__typename} { id }`,
data: update.entity
});
successCount++;
} catch (error) {
console.warn(`Failed to update ${update.__typename}:${update.id}`, error);
failureCount++;
}
});
// Log results
if (failureCount > 0) {
console.warn(`Cascade partially failed: ${successCount} successes, ${failureCount} failures`);
}
}
Monitoring and Observability¶
Key Metrics to Track¶
Performance Metrics
# Cascade processing time
cascade_processing_duration = Histogram(
'fraiseql_cascade_processing_duration_seconds',
'Time spent processing cascade data'
)
# Payload sizes
cascade_payload_bytes = Histogram(
'fraiseql_cascade_payload_bytes',
'Size of cascade payloads in bytes'
)
# Entity counts
cascade_entities_total = Counter(
'fraiseql_cascade_entities_total',
'Total entities processed via cascade',
['operation'] # CREATED, UPDATED, DELETED
)
Effectiveness Metrics
# Cache hit improvements
cache_hit_rate = Gauge(
'fraiseql_cache_hit_rate',
'Client cache hit rate percentage'
)
# Network request reduction
network_requests_reduced_total = Counter(
'fraiseql_network_requests_reduced_total',
'Network requests eliminated by cascade'
)
Error Metrics
# Cascade processing errors
cascade_errors_total = Counter(
'fraiseql_cascade_errors_total',
'Total cascade processing errors',
['error_type']
)
Alerting Rules¶
Performance Alerts
groups:
- name: cascade_performance
rules:
- alert: HighCascadeProcessingTime
expr: histogram_quantile(0.95, rate(fraiseql_cascade_processing_duration_seconds_bucket[5m])) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "Cascade processing time is too high"
- alert: LargeCascadePayloads
expr: histogram_quantile(0.95, fraiseql_cascade_payload_bytes) > 50000
for: 5m
labels:
severity: warning
annotations:
summary: "Cascade payloads are getting large"
Error Alerts
- alert: CascadeProcessingErrors
expr: rate(fraiseql_cascade_errors_total[5m]) > 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "High rate of cascade processing errors"
Testing Strategies¶
Unit Tests¶
Test Cascade Data Structure
def test_cascade_data_structure():
cascade = generate_cascade_data()
assert 'updated' in cascade
assert 'deleted' in cascade
assert 'invalidations' in cascade
assert 'metadata' in cascade
for update in cascade['updated']:
assert '__typename' in update
assert 'id' in update
assert 'operation' in update
assert 'entity' in update
Test Helper Functions
-- Test cascade_entity function
SELECT app.cascade_entity('Post', '123e4567-e89b-12d3-a456-426614174000', 'CREATED', 'v_post');
-- Expected: Valid cascade entity JSONB
Integration Tests¶
End-to-End Cascade Flow
async def test_cascade_end_to_end():
# Create test data
# Execute mutation
# Verify cascade in response
# Verify client cache state
# Verify UI updates without additional queries
Error Scenarios
async def test_cascade_with_partial_failure():
# Test cascade when some updates succeed but others fail
# Verify partial cascade application
# Verify error handling and logging
Client Tests¶
Apollo Cache Updates
it('applies cascade updates to cache', () => {
const mockCache = new MockApolloCache();
const cascade = createMockCascade();
applyCascade(mockCache, cascade);
expect(mockCache.writeFragment).toHaveBeenCalledTimes(cascade.updated.length);
expect(mockCache.evict).toHaveBeenCalledTimes(cascade.invalidations.length);
});
Migration and Rollback¶
Gradual Adoption¶
Start with Low-Risk Mutations 1. Begin with read-heavy mutations (create operations) 2. Add cascade to update operations 3. Finally tackle delete operations
Feature Flags
# Use environment variables for gradual rollout
ENABLE_CASCADE = os.getenv('ENABLE_CASCADE', 'false').lower() == 'true'
@mutation(enable_cascade=ENABLE_CASCADE)
class CreatePost:
# ...
Rollback Strategies¶
Immediate Rollback
1. Remove enable_cascade=True from mutations
2. Clients gracefully ignore cascade field
3. Monitor for performance improvements
Partial Rollback 1. Keep cascade enabled but reduce scope 2. Remove complex cascades, keep simple ones 3. Adjust invalidation strategies
Full Rollback 1. Remove all cascade-related code 2. Drop helper functions (optional) 3. Revert to traditional cache management
Common Pitfalls¶
Over-Cascading¶
Problem: Including too many entities in cascade Solution: Include only directly affected entities Impact: Large payloads, complex client logic
Under-Cascading¶
Problem: Missing important entity updates Solution: Audit all side effects of mutations Impact: Inconsistent cache state, unnecessary refetches
Inconsistent Entity Data¶
Problem: Cascade entity data doesn't match GraphQL schema Solution: Keep views in sync with GraphQL types Impact: Client errors, cache corruption
Ignoring Performance¶
Problem: Not monitoring cascade impact Solution: Track metrics and optimize based on data Impact: Performance degradation, increased costs
Advanced Patterns¶
Conditional Cascade¶
Based on Client Capabilities
-- Include cascade only for clients that support it
IF input->>'client_supports_cascade' = 'true' THEN
RETURN jsonb_build_object(
'success', true,
'data', ...,
'_cascade', v_cascade
);
ELSE
RETURN jsonb_build_object(
'success', true,
'data', ...
);
END IF;
Selective Cascade
-- Include different cascade data based on operation type
CASE input->>'operation_type'
WHEN 'full' THEN
-- Include all related entities
v_cascade := app.build_full_cascade(v_post_id, v_author_id);
WHEN 'minimal' THEN
-- Include only essential updates
v_cascade := app.build_minimal_cascade(v_post_id);
END CASE;
Cascade Composition¶
Reusable Cascade Components
CREATE OR REPLACE FUNCTION app.cascade_user_stats(uuid) RETURNS jsonb;
CREATE OR REPLACE FUNCTION app.cascade_post_lists(uuid) RETURNS jsonb;
CREATE OR REPLACE FUNCTION app.cascade_notifications(uuid, uuid) RETURNS jsonb;
-- Compose complex cascades
v_cascade := app.build_cascade(
updated => jsonb_build_array(
app.cascade_entity('Post', v_post_id, 'CREATED', 'v_post'),
app.cascade_user_stats(v_author_id)
),
invalidations => jsonb_build_array(
app.cascade_post_lists(v_author_id),
app.cascade_notifications(v_post_id, v_author_id)
)
);