Skip to main content

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

  1. Custom DataLoader Class: Built a Deno-compatible DataLoader that batches requests using queueMicrotask
  2. Request-Scoped Loaders: Each GraphQL request gets fresh DataLoader instances to prevent cross-request data leaks
  3. Specific Loaders Created:
    • organizationLoader: Batches organization lookups by ID
    • userLoader: Batches user profile lookups
    • subscriptionItemLoader: Batches subscription items by subscription ID
    • invoiceLoader: 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

  1. Dramatic Performance Improvement: Reduced database queries by up to 90% for complex queries
  2. Automatic Batching: No need to manually optimize each resolver
  3. Request-Level Caching: Same data requested multiple times in one request is cached
  4. Maintainable: Clear separation between data fetching and business logic

Negative

  1. Added Complexity: Developers need to understand DataLoader pattern
  2. Memory Usage: Caches data for request duration (minimal impact)
  3. 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

  1. Manual Batching: Write custom batch queries for each use case

    • Rejected: High maintenance burden, error-prone
  2. Database Views Only: Rely entirely on pre-joined views

    • Rejected: Less flexible, doesn't solve all N+1 cases
  3. GraphQL Federation: Split into microservices with federation

    • Rejected: Over-engineering for current scale

References