Skip to content
StackPractices
intermediate Por Mathias Paulenko

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 maxBatchSize para mantener clausulas IN dentro de los limites de la base de datos
  • Manejar errores por clave: retornar un objeto Error para 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 cacheKeyFn para 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.