Patron DataLoader en GraphQL
Consolida llamadas de carga individuales en llamadas batch con cache por peticion para prevenir queries N+1 y fetches redundantes.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
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
batchUserscon todos los IDs de autor unicos de posts y comentarios - Una llamada a
batchCommentscon 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. DataLoader luego llama la funcion batch una vez con todas las claves - Deduplicacion: Claves duplicadas en el mismo batch se piden una vez. Todos los callers reciben la misma Promise
- Cache por peticion: El cache es un
Mapen la instancia del loader. Como se crea un loader nuevo por peticion, el cache esta aislado - Contrato de orden: La funcion batch debe retornar un array de la misma longitud que la entrada, en el mismo orden. DataLoader empareja resultados a callers por indice
- Manejo de errores: Retornar un objeto
Erroren 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
maxBatchSizepara mantener clausulasINdentro de los limites de la base de datos - Retornar objetos
Errorpor clave en lugar de lanzar en funciones batch - Usar
cacheKeyFnpara claves no-string o compuestas - Nombrar loaders segun la entidad que cargan (
userLoader, nodataLoader)
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. Retornar
new Error()en el indice fallido en su lugar - Orden de resultados incorrecto: DataLoader empareja por posicion. Resultados desalineados silenciosamente devuelven datos incorrectos
- 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. Usar Redis o cache a nivel aplicacion para caching entre peticiones
- Falta
cacheKeyFnpara claves objeto:{ userId: '1' }y{ userId: '1' }son referencias de objeto diferentes — cache miss cada vez
FAQ
DataLoader es solo para GraphQL?
No. DataLoader es una utilidad de batching de proposito general. Funciona donde sea que tengas lookups async individuales que pueden batchearse. GraphQL es el caso de uso mas comun porque los resolvers anidados piden datos relacionados de forma independiente.
Como decide DataLoader cuando batchear?
DataLoader recolecta todas las llamadas load() dentro del tick actual del event loop de Node.js. Cuando el tick termina, dispara la funcion batch con todas las claves recolectadas. Puedes personalizar el timing con batchScheduleFn.
Deberia usar DataLoader con federation?
El query planner de federation ya batchea la resolucion de campos entre servicios. Para resolvers internos del subgrafo (queries de base de datos dentro de un servicio), DataLoader sigue siendo util.
Que pasa si una clave no se encuentra?
Retorna null en ese indice para “no encontrado” como caso no-error. Retorna new Error('not found') en ese indice si quieres que el load() del caller rechaze. Elige una convencion y mantenla.
Puedo usar DataLoader con Python u otros lenguajes?
Si. aiodataloader para Python (asyncio), dataloader-go para Go, e implementaciones similares existen para otros lenguajes. El concepto de batching es independiente del lenguaje.
¿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.
Recursos Relacionados
GraphQL Batched Resolver Pattern
Resolve nested GraphQL fields in a single batched request to eliminate N+1 queries and reduce database load.
GuideComplete Guide to GraphQL Federation
Build unified GraphQL APIs across multiple services with Apollo Federation. Covers subgraphs, supergraph composition, entity resolution, and gateway deployment.
GuideComplete Guide to GraphQL Federation
Build unified GraphQL APIs across multiple services with Apollo Federation. Covers subgraphs, supergraph composition, entity resolution, and gateway deployment.
RecipeDetect and Fix N+1 Queries in GraphQL Resolvers
Identify N+1 query problems in GraphQL resolvers using logging, DataLoader, and query analysis tools before they hit production
RecipeBatch and Cache Database Queries with GraphQL DataLoader
Use DataLoader to coalesce individual load requests into batched database calls, solving the N+1 query problem in GraphQL resolvers
PatternGraphQL Connection Pagination Pattern
Implement Relay-style cursor-based pagination with edges, nodes, and pageInfo for stable GraphQL list queries.
PatternGraphQL Mutation Validation Pattern
Centralize input validation for GraphQL mutations using custom validators, schema directives, and structured error responses.