Source:
ocean/docs/adr/0028-graphql-dataloader-implementation.md| ✏️ Edit on GitHub
0028. GraphQL DataLoader Implementation
Date: 2025-08-14
Status
Accepted
Context
Our GraphQL resolvers were experiencing N+1 query problems, particularly when fetching related data like organization members and their profiles. For example, fetching 10 organizations with their members would result in 1 query for organizations + 10 queries for members + potentially 10×N queries for member profiles.
This pattern leads to:
- Poor performance as data scales
- Increased database load
- Higher latency for end users
- Potential rate limiting issues
Decision
We implemented DataLoader pattern in our GraphQL resolvers to batch and cache database queries within a single request context.
Implementation Details
- Custom DataLoader Class: Built a Deno-compatible DataLoader that batches requests using
queueMicrotask - Request-Scoped Loaders: Each GraphQL request gets fresh DataLoader instances to prevent cross-request data leaks
- Specific Loaders Created:
organizationLoader: Batches organization lookups by IDuserLoader: Batches user profile lookupssubscriptionItemLoader: Batches subscription items by subscription IDinvoiceLoader: Batches invoices by organization ID
Example Usage
// Before: N+1 queries
const members = await supabase
.from('organization_members')
.select('*, profiles(*)')
.eq('organization_id', parent.id)
// After: 2 queries total
const members = await supabase
.from('organization_members')
.select('user_id, role, joined_at')
.eq('organization_id', parent.id)
const profiles = await context.loaders.user.loadMany(members.map((m) => m.user_id))
Consequences
Positive
- Dramatic Performance Improvement: Reduced database queries by up to 90% for complex queries
- Automatic Batching: No need to manually optimize each resolver
- Request-Level Caching: Same data requested multiple times in one request is cached
- Maintainable: Clear separation between data fetching and business logic
Negative
- Added Complexity: Developers need to understand DataLoader pattern
- Memory Usage: Caches data for request duration (minimal impact)
- Debugging: Batched queries can be harder to trace
Metrics
- Organization query with 20 members: 41 queries → 3 queries
- Billing overview page load: 500ms → 150ms
- Database CPU usage: Reduced by 40% during peak
Alternatives Considered
-
Manual Batching: Write custom batch queries for each use case
- Rejected: High maintenance burden, error-prone
-
Database Views Only: Rely entirely on pre-joined views
- Rejected: Less flexible, doesn't solve all N+1 cases
-
GraphQL Federation: Split into microservices with federation
- Rejected: Over-engineering for current scale