StackPractices
intermediate Por Mathias Paulenko

Patron DataLoader en GraphQL

Consolida llamadas de carga individuales en llamadas batch con cache por peticion para prevenir queries N+1 y fetches redundantes.

Descripcion general

DataLoader es una utilidad generica que consolida llamadas individuales de load() en una sola peticion batch. Fue construido en Facebook para resolver el problema N+1 en servidores GraphQL. Dentro de un tick del event loop, todas las llamadas a load(key) se recolectan, se deduplican, y se pasan a una funcion batch que obtiene todas las claves a la vez. Los resultados se cachean por la vida util de la instancia del loader.

El patron no es especifico de GraphQL — funciona en cualquier lugar donde necesites batchear lookups async individuales. Pero brilla en GraphQL donde los resolvers anidados piden datos relacionados de forma independiente.

Cuando Usar

  • For alternatives, see GraphQL Batched Resolver Pattern.

  • Resolvers GraphQL que obtienen entidades relacionadas por item padre

  • Cualquier patron de lookup async donde llamadas individuales pueden batchearse (base de datos, REST, gRPC)

  • Prevenir fetches duplicados cuando la misma clave se pide multiples veces en una operacion

  • Capas de gateway o BFF que agregan datos de multiples servicios backend

Cuando No Usar

  • Lookups de un solo item sin oportunidad de batching (N=1)
  • Acceso a datos sincrono (DataLoader es solo async)
  • Caches de larga duracion (DataLoader cachea por peticion, no entre peticiones)
  • Subscripciones en tiempo real donde el batching anade latencia

Solucion

1. Configuracion Basica de DataLoader

import DataLoader from 'dataloader';

type User = { id: string; name: string; email: string };

async function batchUsers(ids: readonly string[]): Promise<User[]> {
  // Una sola query para todos los IDs pedidos
  const rows = await db.query('SELECT * FROM users WHERE id = ANY($1)', [ids]);
  const map = new Map(rows.map((r) => [r.id, r]));

  // Debe retornar en el mismo orden que los ids de entrada
  return ids.map((id) => map.get(id) ?? new Error(`User ${id} not found`));
}

const userLoader = new DataLoader(batchUsers);

2. Usar en Resolvers GraphQL

const resolvers = {
  Query: {
    posts: (_parent, _args, { db }) => db.post.findMany({ take: 20 }),
  },
  Post: {
    author: (post, _args, { loaders }) => loaders.userLoader.load(post.authorId),
    comments: (post, _args, { loaders }) =>
      loaders.commentLoader.loadMany(post.commentIds),
  },
  Comment: {
    author: (comment, _args, { loaders }) => loaders.userLoader.load(comment.authorId),
  },
};

Cuando una query pide 20 posts con sus autores y comentarios con sus autores, DataLoader batchea:

  • Una llamada a batchUsers con todos los IDs de autor unicos de posts y comentarios
  • Una llamada a batchComments con todos los arrays de IDs de comentarios

3. Contexto por Peticion

Crear loaders frescos para cada operacion GraphQL:

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

function createLoaders(db) {
  return {
    userLoader: new DataLoader((ids) => batchUsers(ids, db)),
    commentLoader: new DataLoader((ids) => batchComments(ids, db)),
    productLoader: new DataLoader((ids) => batchProducts(ids, db)),
  };
}

const server = new ApolloServer({ typeDefs, resolvers });

startStandaloneServer(server, {
  context: async ({ req }) => {
    const db = getDb();
    return {
      db,
      loaders: createLoaders(db),
      user: parseAuth(req.headers.authorization),
    };
  },
  listen: { port: 4000 },
});

4. Caching y Deduplicacion

// Si dos posts tienen el mismo authorId, el usuario se obtiene una vez
const posts = [
  { id: 1, authorId: 'user-10' },
  { id: 2, authorId: 'user-10' }, // mismo usuario
  { id: 3, authorId: 'user-20' },
];

// Cada resolver llama loaders.userLoader.load(post.authorId)
// DataLoader recolecta: ['user-10', 'user-10', 'user-20']
// Deduplica a: ['user-10', 'user-20']
// Una llamada batch, retorna resultado cacheado para el segundo 'user-10'

5. Primear el Cache

Cuando ya tienes datos, primea el loader para evitar fetches redundantes:

const resolvers = {
  Query: {
    user: async (_parent, { id }, { db, loaders }) => {
      const user = await db.user.findById(id);

      // Primear el cache — resolvers anidados que llamen load(id) obtienen esto al instante
      loaders.userLoader.prime(id, user);

      return user;
    },
  },
};

6. Limpiar Cache Despues de Mutaciones

const resolvers = {
  Mutation: {
    updateUser: async (_parent, { id, input }, { db, loaders }) => {
      const user = await db.user.update(id, input);

      // Limpiar entrada stale del cache — el proximo load() re-fetcheara
      loaders.userLoader.clear(id);

      return user;
    },
  },
};

Explicacion

  • Batching: Todas las llamadas load() dentro de un tick del event loop se recolectan.
  • Deduplicacion: Claves duplicadas en el mismo batch se piden una vez.
  • Cache por peticion: El cache es un Map en la instancia del loader.
  • Contrato de orden: La funcion batch debe retornar un array de la misma longitud que la entrada, en el mismo orden.
  • Manejo de errores: Retornar un objeto Error en un indice especifico para rechazar solo ese caller, no todo el batch

Variantes

DataLoader con Prisma

const userLoader = new DataLoader(async (ids: readonly string[]) => {
  const users = await prisma.user.findMany({
    where: { id: { in: [...ids] } },
  });
  const map = new Map(users.map((u) => [u.id, u]));
  return ids.map((id) => map.get(id));
});

DataLoader con Pipeline de Redis

const sessionLoader = new DataLoader(async (sessionIds: readonly string[]) => {
  const pipeline = redis.pipeline();
  sessionIds.forEach((id) => pipeline.get(`session:${id}`));
  const results = await pipeline.exec();
  return results.map(([err, value]) => {
    if (err) return new Error(err.message);
    return value ? JSON.parse(value) : null;
  });
});

Cache Key Function Personalizado

Para claves compuestas o no-string:

const permissionLoader = new DataLoader(
  async (keys: readonly { userId: string; resource: string }[]) => {
    const permissions = await db.permission.findMany({
      where: { OR: keys.map((k) => ({ userId: k.userId, resource: k.resource })) },
    });
    const map = new Map(
      permissions.map((p) => [`${p.userId}:${p.resource}`, p])
    );
    return keys.map((k) => map.get(`${k.userId}:${k.resource}`));
  },
  {
    cacheKeyFn: (key) => `${key.userId}:${key.resource}`,
  }
);

Tamano Maximo de Batch

Limitar el tamano del batch para restricciones de queries de base de datos:

const userLoader = new DataLoader(batchUsers, {
  maxBatchSize: 50,  // Max 50 IDs por query SELECT ... IN (...)
  batchScheduleFn: (callback) => setTimeout(callback, 10), // ventana de 10ms
});

Deshabilitar Cache para Datos Mutables

const stockPriceLoader = new DataLoader(batchStockPrices, {
  cache: false, // Los precios de acciones cambian constantemente — no cachear
});

Mejores Practicas

  • Crear una nueva instancia de DataLoader por peticion — nunca compartir entre peticiones
  • Retornar resultados en el orden exacto de las claves de entrada en funciones batch
  • Usar prime() cuando los datos ya estan disponibles desde un resolver padre
  • Llamar clear(id) despues de mutaciones que modifican entidades cacheadas
  • Configurar maxBatchSize para mantener clausulas IN dentro de los limites de la base de datos
  • Retornar objetos Error por clave en lugar de lanzar en funciones batch
  • Usar cacheKeyFn para claves no-string o compuestas
  • Nombrar loaders segun la entidad que cargan (userLoader, no dataLoader)

Errores Comunes

  • Compartir un DataLoader entre peticiones: El cache entre peticiones filtra datos entre usuarios y causa lecturas stale
  • Lanzar en funcion batch: Rechaza todo el batch.
  • Orden de resultados incorrecto: DataLoader empareja por posicion.
  • No limpiar despues de mutaciones: Datos cacheados stale se retornan para entidades actualizadas
  • Usar DataLoader para caching de larga duracion: El cache de DataLoader es por peticion.
  • Falta cacheKeyFn para claves objeto: { userId: '1' } y { userId: '1' } son referencias de objeto diferentes — cache miss cada vez

Troubleshooting

  • Query returns null unexpectedly: verify resolvers, data loaders, and authorization.
  • N+1 query performance issue: Inspect resolver execution traces.
  • Introspection disabled in production breaks tools: enable it only in development, or use schema artifacts in CI.
  • Mutation input rejected: confirm input validation, custom scalars, and whether variables are passed as the right type.
  • Subscription stops receiving events: check the pub/sub backend, event filtering, and that the resolver is emitting events.

Puntos Clave

  • Aplica patron dataloader en graphql cuando necesites una solución práctica para tu caso de uso.
  • Monitorea el rendimiento después de implementar; mide latencia, errores y uso de recursos antes y después.
  • Revisa la sección de Troubleshooting ante errores comunes; la mayoría tienen causa raíz documentada con solución.
  • Mantén dependencias actualizadas y ejecuta tests en CI para prevenir regresiones en producción.

Errores Comunes en Producción

  • Aplicar el patrón donde no se necesita abstracción, agregando complejidad accidental.
  • Dejar que el patrón se filtre en módulos no relacionados y confundir los límites de responsabilidad.
  • Sobre-ingeniería en la primera implementación en lugar de comenzar simple y medir el dolor.
  • Saltar los tests de contrato, de modo que las refactorizaciones rompan consumidores en silencio.
  • Ignorar modos de fallo que el patrón no cubre.
  • Usar el patrón como opción por defecto en lugar de elegir la herramienta adecuada para la escala actual.
  • Olvidar documentar cuándo dejar de usar el patrón y qué lo reemplaza.
  • Carecer de observabilidad sobre rendimiento y propagación de errores del patrón.

Preguntas frecuentes

¿Es este patrón adecuado para proyectos pequeños?

Para proyectos pequeños con pocos componentes, este patrón puede añadir complejidad innecesaria. Empieza simple e introduce el patrón cuando sientas el problema que resuelve.

¿Cómo se compara este patrón con alternativas?

Cada patrón hace diferentes trade-offs. Revisa la tabla de variantes arriba y considera tus restricciones específicas: tamaño del equipo, requisitos de rendimiento y planes de escalado.

¿Puedo aplicar este patrón parcialmente?

Sí. Muchos equipos adoptan patrones incrementalmente. Empieza con la idea central y añade sofisticación según sea necesario. El patrón es una guía, no un blueprint estricto.