How to Implement DataLoader Pattern in Go GraphQL

Web
Go lacks a native DataLoader, requiring manual implementation of request batching and caching to optimize database queries.

The N plus one trap

You are building a GraphQL API. A client asks for a list of ten users. Your resolver fetches them from the database. Then the schema asks for each user's recent posts. The naive approach fires ten separate database queries. The database driver opens ten connections. The response time multiplies. You hit the N plus one problem. The server slows down under load. You need a way to collect those scattered requests, combine them into one query, and hand the results back to the right caller. That is the DataLoader pattern.

How batching actually works

Think of a courier service. Ten neighbors on the same block order groceries. If the driver makes ten separate trips, traffic and fuel waste everything. Instead, the driver waits thirty seconds, collects all the addresses, makes one trip to the warehouse, picks up everything in one box, and delivers it back to the correct house. The DataLoader does exactly this for database calls. It sits between your GraphQL resolvers and your database. It collects keys over a short time window, executes a single batched query, caches the results for the current request, and returns the right value to each resolver.

Go does not ship with a DataLoader in the standard library. You build it with channels, goroutines, and a map. The pattern relies on three mechanics. Batching groups independent lookups into one operation. Caching prevents duplicate work within a single request cycle. Context awareness ensures the loader stops when the HTTP request cancels.

The minimal skeleton

Here is the core structure of a batch loader. It collects string keys, waits for a tick, runs a batch function, and returns a map of results.

package main

import (
    "context"
    "time"
)

// BatchLoader collects keys and returns a map of results.
type BatchLoader struct {
    batchFunc func(ctx context.Context, keys []string) (map[string]string, error)
    keys      []string
    results   map[string]string
    err       error
    done      chan struct{}
}

// NewBatchLoader creates a loader with a custom batch function.
func NewBatchLoader(fn func(context.Context, []string) (map[string]string, error)) *BatchLoader {
    return &BatchLoader{
        batchFunc: fn,
        done:      make(chan struct{}),
        results:   make(map[string]string),
    }
}

// Load queues a key and returns its result.
func (l *BatchLoader) Load(ctx context.Context, key string) (string, error) {
    l.keys = append(l.keys, key)
    select {
    case <-l.done:
        return l.results[key], l.err
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

The Load method appends the requested key to a slice and blocks until the batch finishes. The done channel signals completion. When the first key arrives, a background goroutine starts a timer. If more keys arrive before the timer fires, they join the same batch. When the timer expires or the context cancels, the goroutine calls batchFunc. The function executes one database query, builds a map keyed by the requested IDs, and closes the done channel. Every blocked Load call wakes up, looks up its key in the map, and returns.

Caching happens naturally because the map persists for the lifetime of the loader. If two resolvers request the same user ID during the same request cycle, the second call skips the database entirely. The map acts as a request-scoped cache. You discard the loader when the HTTP request finishes.

Keep the loader scoped to a single request. Do not share it across HTTP handlers. Request boundaries keep memory usage predictable and prevent stale data from leaking into new users.

Building a production-ready loader

Real GraphQL resolvers need error handling, context propagation, and a way to trigger the batch safely. Here is a complete loader that simulates a database call and handles the batching lifecycle.

package main

import (
    "context"
    "fmt"
    "sync"
    "time"
)

// UserLoader batches user lookups for a single request.
type UserLoader struct {
    mu      sync.Mutex
    keys    []string
    cache   map[string]string
    pending bool
    done    chan struct{}
    err     error
}

// NewUserLoader initializes the loader with an empty cache.
func NewUserLoader() *UserLoader {
    return &UserLoader{
        cache: make(map[string]string),
        done:  make(chan struct{}),
    }
}

The struct holds a mutex to protect concurrent access. The pending flag prevents multiple goroutines from starting the same batch. The done channel coordinates waiting resolvers.

// Load fetches a user, batching requests automatically.
func (l *UserLoader) Load(ctx context.Context, id string) (string, error) {
    l.mu.Lock()
    if val, ok := l.cache[id]; ok {
        l.mu.Unlock()
        return val, nil
    }
    l.keys = append(l.keys, id)
    if !l.pending {
        l.pending = true
        go l.runBatch(ctx)
    }
    l.mu.Unlock()

    select {
    case <-l.done:
        l.mu.Lock()
        val, ok := l.cache[id]
        l.mu.Unlock()
        if !ok {
            return "", fmt.Errorf("user %s not found", id)
        }
        return val, l.err
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

The Load method checks the cache first. If the key is missing, it appends to the slice and spawns the batch goroutine exactly once. The resolver then blocks on a select. It wakes up when the batch finishes or when the client cancels the request.

// runBatch executes the batch query and populates the cache.
func (l *UserLoader) runBatch(ctx context.Context) {
    timer := time.NewTimer(5 * time.Millisecond)
    defer timer.Stop()

    select {
    case <-timer.C:
        // proceed to query
    case <-ctx.Done():
        l.mu.Lock()
        l.err = ctx.Err()
        l.mu.Unlock()
        close(l.done)
        return
    }

    results := make(map[string]string)
    for _, id := range l.keys {
        results[id] = fmt.Sprintf("user_%s", id)
    }

    l.mu.Lock()
    for k, v := range results {
        l.cache[k] = v
    }
    l.keys = nil
    l.pending = false
    l.mu.Unlock()
    close(l.done)
}

The goroutine waits for a short window to collect more keys. It checks context cancellation before doing work. It simulates a database query, populates the cache, resets the slice, and signals completion. The mutex protects every read and write to shared state.

Go favors explicit error handling over silent failures. The if err != nil pattern is verbose by design. It forces you to acknowledge the unhappy path. When building a loader, return errors from Load rather than swallowing them. The community accepts the boilerplate because it makes failure modes visible. Also, keep receiver names short. (l *UserLoader) is standard. (this *UserLoader) or (self *UserLoader) breaks convention and makes code reviews slower.

Where things break

The pattern looks simple until you hit edge cases. Goroutine leaks are the most common failure. If you forget to close the done channel when the context cancels, every pending Load call blocks forever. The runtime will eventually panic with all goroutines are asleep - deadlock! if the main function waits on them. Always wire context cancellation to the batch trigger.

Race conditions happen when multiple resolvers call Load simultaneously. The sync.Mutex protects the key slice and the cache. If you skip the lock, the compiler will not catch the data race. You will see interleaved writes or panics like concurrent map writes during runtime. Run your tests with go test -race to catch this early.

Ordering matters. Your batch function receives keys in the order they arrived. The database returns rows in an arbitrary order. You must map results back to the original keys using a map, not a slice. If you return a slice and assume index alignment, you will hand User A's posts to User B. The compiler will not warn you. The bug only shows up in production when IDs collide.

Context propagation follows Go convention. The context.Context always goes as the first parameter. Functions that accept a context should check ctx.Err() before doing work and respect deadlines. If the HTTP client cancels the request, your loader must stop the timer and close the channel immediately. Trust the context. Run it through every long-lived call site.

When to reach for it

Use a DataLoader when your GraphQL schema has nested lists that trigger repeated lookups for the same resource. Use a direct database call when you only need one or two records and the overhead of batching outweighs the benefit. Use a request-scoped cache map when you are fetching the same data multiple times but do not need automatic batching. Use a dedicated caching library like ristretto or bigcache when you need to persist data across multiple HTTP requests. Use pre-fetching at the query planner level when you can predict all required IDs before resolving the tree.

Batch the requests. Cache the results. Cancel on context.

Where to go next