Patron de Resolver Batch en GraphQL
Resuelve campos anidados de GraphQL en una sola peticion batch para eliminar consultas N+1 y reducir la carga de la base de datos.
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
Cuando una query GraphQL pide campos anidados a traves de una lista, cada item dispara una llamada separada a la base de datos. Consultar 50 posts y sus autores produce 50 sentencias SELECT extra — el clasico problema N+1. Los resolvers batch recolectan todas las claves de la lista, hacen una sola peticion batch, y distribuyen los resultados de vuelta a cada item.
El patron se combina naturalmente con DataLoader, que maneja batching, caching y deduplicacion por peticion.
Cuando Usar
-
For alternatives, see GraphQL DataLoader Pattern.
-
Resolvers que obtienen datos relacionados por item padre (posts → autor, ordenes → producto)
-
Listas de items donde cada item tiene una relacion anidada
-
Cualquier campo GraphQL que dispara una query de base de datos o llamada API por padre
-
Capas de gateway o stitching que delegan a servicios backend
Cuando No Usar
- Queries de un solo item donde N=1 (sin beneficio de batching)
- Campos resueltos desde datos ya cargados en el objeto padre (usar acceso directo a propiedad)
- Subscripciones en tiempo real donde las ventanas de batch anaden latencia inaceptable
Solucion
1. El Problema N+1
Sin batching, cada post dispara una busqueda de autor separada:
const resolvers = {
Post: {
author: (post, _args, { db }) => {
// Llamado una vez por post — 50 posts = 50 queries
return db.user.findById(post.authorId);
},
},
};
Consultar 50 posts genera 50 queries SQL:
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 1; -- duplicado
SELECT * FROM users WHERE id = 3;
-- ... 46 mas
2. DataLoader: Batch y Cache
DataLoader recolecta todos los valores de authorId dentro de un mismo tick del event loop, los deduplica, y envia una sola peticion batch.
import DataLoader from 'dataloader';
async function batchLoadUsers(userIds: readonly string[], { db }) {
// Una query: SELECT * FROM users WHERE id IN (1, 2, 3, ...)
const users = await db.user.findMany({ where: { id: { in: [...userIds] } } });
// DataLoader espera resultados en el mismo orden que las claves de entrada
const userMap = new Map(users.map((u) => [u.id, u]));
return userIds.map((id) => userMap.get(id) ?? new Error(`User ${id} not found`));
}
// Crear un nuevo DataLoader por peticion para evitar caching entre peticiones
function createLoaders(context) {
return {
userLoader: new DataLoader(
(ids) => batchLoadUsers(ids, context),
{ cacheKeyFn: (key) => key.toString() }
),
};
}
3. Usar el Loader en Resolvers
const resolvers = {
Post: {
author: (post, _args, { loaders }) => {
// DataLoader agrupa todas las llamadas dentro del mismo tick
return loaders.userLoader.load(post.authorId);
},
},
Query: {
posts: async (_parent, _args, { db }) => {
return db.post.findMany({ take: 50 });
},
},
};
Ahora 50 posts producen una sola query SQL:
SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5, ...);
4. Inicializacion de Loaders por Peticion
Crear loaders frescos para cada peticion para evitar filtrar datos cacheados entre usuarios:
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
const server = new ApolloServer({
typeDefs,
resolvers,
});
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
const db = getDatabase();
const loaders = createLoaders({ db });
return { db, loaders, user: req.headers.authorization };
},
listen: { port: 4000 },
});
5. Batching Entre Multiples Campos
DataLoader agrupa tambien entre diferentes campos de resolver. Si una query pide post.author y comment.author, ambos resuelven a traves del mismo userLoader:
const resolvers = {
Post: {
author: (post, _args, { loaders }) => loaders.userLoader.load(post.authorId),
},
Comment: {
author: (comment, _args, { loaders }) => loaders.userLoader.load(comment.authorId),
},
};
Una query como { posts { author { name } } comments { author { name } } } sigue produciendo una sola llamada batch para todos los IDs de autor.
Explicacion
- Ventana de batch: DataLoader recolecta todas las llamadas
.load()dentro de un tick del event loop de Node.js, luego dispara la funcion batch una vez - Deduplicacion: Si dos posts tienen el mismo
authorId, DataLoader pide ese usuario una vez y devuelve el resultado cacheado para ambos - Cache por peticion: El cache vive solo durante una operacion GraphQL, previniendo datos stale entre peticiones
- Preservacion de orden: La funcion batch debe retornar resultados en el mismo orden que las claves de entrada — DataLoader empareja resultados por posicion
Variantes
Funciones Batch Personalizadas para APIs REST
Batchear contra un endpoint REST que acepta multiples IDs:
const productLoader = new DataLoader(async (productIds: readonly string[]) => {
const response = await fetch(
`https://api.example.com/products?ids=${productIds.join(',')}`
);
const products = await response.json();
const productMap = new Map(products.map((p) => [p.id, p]));
return productIds.map((id) => productMap.get(id));
});
Batch con Tamano Maximo
Limitar el tamano del batch para evitar clausulas IN demasiado grandes:
const userLoader = new DataLoader(batchLoadUsers, {
maxBatchSize: 100,
cache: true,
});
Batch con Priming
Pre-poblar el cache cuando ya tienes los datos:
Query: {
user: async (_parent, { id }, { db, loaders }) => {
const user = await db.user.findById(id);
// Primear el cache para que los resolvers anidados no re-fetchen
loaders.userLoader.prime(id, user);
return user;
},
},
Batch con Cache Key Personalizado
Para claves compuestas (ej. tenant + user ID):
const loader = new DataLoader(batchLoad, {
cacheKeyFn: (key) => `${key.tenantId}:${key.userId}`,
});
Mejores Practicas
- Crear una instancia de DataLoader por peticion, nunca compartida entre peticiones
- Retornar siempre resultados en el mismo orden que las claves de entrada en funciones batch
- Usar
prime()cuando ya tienes datos para evitar fetches redundantes - Configurar
maxBatchSizepara mantener clausulasINdentro de los limites de la base de datos - Manejar errores por clave: retornar un objeto
Errorpara items fallidos, no lanzar una excepcion - Monitorear tamanos de batch en produccion para detectar patrones de query inesperados
Errores Comunes
- Compartir DataLoader entre peticiones: Causa fugas de datos entre usuarios y cache hits stale
- Retornar resultados desordenados: DataLoader empareja por posicion, resultados desalineados silenciosamente devuelven datos incorrectos
- Lanzar en funcion batch: Una clave fallida rechaza todo el batch. Retornar
new Error()por item en su lugar - No usar
cacheKeyFnpara claves no-string: Objetos como claves causan cache misses porque{}!=={}por referencia - Olvidar limpiar el cache en mutaciones: Despues de actualizar un usuario, llamar
loader.clear(id)para prevenir lecturas stale
FAQ
DataLoader funciona con subscripciones?
Si, pero la ventana de batch puede comportarse diferente. Para subscripciones, considera deshabilitar batching o usar un intervalo de batch mas corto.
En que se diferencia del patron DataLoader?
El patron de resolver batch es el concepto mas amplio — DataLoader es una implementacion. Tambien puedes batchear con logica personalizada, pipelines de Redis, o coleccion de campos info de GraphQL.js.
Puedo batchear mutaciones?
No directamente. Las mutaciones se ejecutan secuencialmente por diseno. Usa una sola mutacion con input de lista en lugar de multiples llamadas de mutacion.
Que hay del batching en federation?
El query planner de Apollo Federation automaticamente batchea la resolucion de campos entre servicios. Si usas federation, puede que no necesites DataLoader manual para campos entre servicios.
¿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.
Temas Avanzados
Escenario: Batched Resolver para N+1 en GraphQL
// DataLoader: batchear requests para evitar N+1
import DataLoader from "dataloader";
// Resolver sin batching: N+1 queries
// query { users { orders { id } } } -> 1 query users + N queries orders
// Resolver con DataLoader: 1 query batch
const orderLoader = new DataLoader<string, Order[]>(async (userIds) => {
// 1 query para todos los users
const res = await db.query(
"SELECT * FROM orders WHERE user_id = ANY($1)",
[userIds]
);
// Agrupar por user_id
const ordersByUser = new Map<string, Order[]>();
for (const order of res.rows) {
const list = ordersByUser.get(order.user_id) || [];
list.push(order);
ordersByUser.set(order.user_id, list);
}
// Retornar en el mismo orden que userIds
return userIds.map(id => ordersByUser.get(id) || []);
});
// GraphQL resolver
const resolvers = {
User: {
orders: (parent: User) => orderLoader.load(parent.id),
},
Query: {
users: async () => {
const res = await db.query("SELECT * FROM users LIMIT 100");
return res.rows;
},
},
};
// Antes: 100 users -> 100 queries de orders (N+1)
// Despues: 100 users -> 1 query de orders (batched)
// DataLoader agrupa todas las llamadas .load() en un tick
Lecciones:
- DataLoader agrupa llamadas .load() en un mismo tick de event loop
- 1 query batch en lugar de N queries individuales
- DataLoader cachea por request: no compartir entre requests
- El orden del resultado debe coincidir con el orden de keys
- En Apollo Server, crear DataLoader por request en context
- Usar para cualquier relacion 1:N o N:1 en GraphQL
### Como configuro DataLoader en Apollo Server?
Crea una instancia de DataLoader por request en el context function. No compartas DataLoader entre requests: la cache de un request puede contaminar otro. En el context: () => ({ orderLoader: new DataLoader(batchFn) }). El DataLoader se crea por request y se descarta al terminar. Para cache entre requests, usar Redis con cacheKeyFn. Para batching manual sin DataLoader: agrupar ids en un Set y hacer una sola query con IN clause. Recursos Relacionados
GraphQL DataLoader Pattern
Coalesce individual load requests into batched calls with per-request caching to prevent N+1 queries and redundant fetches.
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
RecipeCursor-based Pagination with GraphQL Relay Connections
Implement Relay-style cursor pagination in GraphQL with edges, nodes, and pageInfo for efficient forward and backward traversal
PatternGraphQL Connection Pagination Pattern
Implement Relay-style cursor-based pagination with edges, nodes, and pageInfo for stable GraphQL list queries.
PatternGraphQL Error Extension Pattern
Attach structured metadata to GraphQL errors using extension codes for predictable client-side error handling.