¿Qué es el problema N+1 en GraphQL y cómo lo resuelve DataLoader (batching + caching)?
Una sola consulta GraphQL puede disparar en silencio cientos de llamadas a la base de datos cuando los resolvers anidados corren por separado — DataLoader las agrupa y las cachea.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué es el problema N+1 en GraphQL y cómo lo resuelve DataLoader (batching + caching)?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Una consulta que parece inocente
Consideremos una consulta GraphQL que trae una lista de posts de un blog y, para cada uno, el nombre de su autor:
query {posts {idtitleauthor {name}}}
Nada en esta consulta parece peligroso. Se lee como una sola petición, y desde la perspectiva del cliente lo es. Pero en el servidor puede convertirse en decenas o cientos de idas y vueltas individuales a la base de datos, una por cada post de la lista. Esa brecha entre "parece una sola consulta" y "se ejecuta como N+1 consultas" es el error de rendimiento más común en las APIs GraphQL en producción.
#Por qué el ejecutor hace esto por diseño
GraphQL resuelve una consulta campo por campo, y cada campo tiene su propia función resolver. Cuando un campo devuelve una lista, los resolvers hijos de los sub-campos de esa lista se invocan una vez por elemento, de forma independiente, sin ninguna conciencia incorporada de sus hermanos. Un esquema ingenuo para la consulta anterior luce así:
type Author {id: ID!name: String!}type Post {id: ID!title: String!author: Author!}type Query {posts: [Post!]!}
Y una implementación de resolver ingenua:
const resolvers = {Query: {posts: () => db.query("SELECT id, title, author_id FROM posts"),},Post: {// Esto se ejecuta una vez POR cada post devuelto arriba — ese es el N+1.author: (post) => db.query("SELECT id, name FROM authors WHERE id = $1",[post.author_id]),},};
Si posts devuelve 50 filas, el resolver Post.author dispara 50 sentencias SELECT separadas contra la tabla authors — una consulta para traer la lista (el "1"), más N consultas para resolver el campo anidado de cada elemento (el "N"). Si se anida un nivel más (comentarios de cada post, el autor de cada comentario), el multiplicador se agrava.
#La solución: batching y caché con DataLoader
DataLoader es una utilidad pequeña (construida originalmente en Facebook junto con GraphQL.js) que se ubica entre los resolvers y la capa de acceso a datos. Hace dos cosas:
- Batching (agrupación) — dentro de un mismo tick del event loop, recolecta cada llamada a
.load(key)hecha en cualquier parte de la petición y se las entrega a una única función de batch definida por el usuario como un solo arreglo de claves, en lugar de llamar a la capa de datos una vez por clave. - Caching (caché) — durante la vida de una petición, llamar a
.load(7)dos veces devuelve la misma promesa en curso o cacheada en lugar de disparar una segunda consulta, lo que además hace que las llamadas por elemento del ejecutor se deduplican de forma natural.
Una función de batch tiene un solo contrato: dado un arreglo de claves, devolver una promesa de un arreglo de valores de la misma longitud, en el mismo orden.
const DataLoader = require("dataloader");function createAuthorLoader() {return new DataLoader(async (authorIds) => {// authorIds podría ser [7, 3, 7, 12] — DataLoader ya deduplicó// internamente antes de llamar a esta función.const rows = await db.query("SELECT id, name FROM authors WHERE id = ANY($1)",[authorIds]);const byId = new Map(rows.map((row) => [row.id, row]));// El orden y la longitud deben coincidir exactamente con las claves de entrada.return authorIds.map((id) => byId.get(id) ?? null);});}
Se conecta un loader nuevo en el contexto de cada petición, y el resolver apunta a él en lugar de a la capa de datos cruda:
const server = new ApolloServer({typeDefs,resolvers,context: () => ({authorLoader: createAuthorLoader(), // un loader nuevo por petición}),});const resolvers = {Query: {posts: () => db.query("SELECT id, title, author_id FROM posts"),},Post: {author: (post, _args, context) => context.authorLoader.load(post.author_id),},};
Nada cambió en cómo el ejecutor llama al resolver author — sigue disparándose una vez por post. Lo que cambió es que cada llamada ahora registra una clave en el loader en lugar de golpear la base de datos directamente. DataLoader espera a que termine el tick actual, recolecta las claves acumuladas (deduplicadas: [7, 3, 12] en lugar de [7, 3, 7, 12]), y llama a la función de batch exactamente una vez. Cincuenta posts escritos por un puñado de autores distintos ahora cuestan dos consultas en total, no cincuenta y una.
#Por qué esto debe vivir por petición
#Dónde aparece la misma idea en otros lugares
El patrón N+1 no es exclusivo de GraphQL — es lo que ocurre cada vez que un bucle dispara una llamada de I/O por iteración en lugar de una sola llamada para todo el lote. Los ORMs chocan con el mismo muro con asociaciones de carga perezosa (la gema bullet de Rails y select_related/prefetch_related de Django existen exactamente por esta razón), y las APIs REST también lo sufren cuando un endpoint de listado se abre en abanico hacia N llamadas de detalle. GraphQL simplemente hace el problema más probable por diseño, porque su modelo de resolución campo por campo invita activamente a escribir resolvers como si cada uno fuera independiente.
| Enfoque | Consultas para 50 posts, 10 autores distintos | Alcance del caché |
|---|---|---|
| Resolver ingenuo | 51 (1 + 50) | ninguno |
| DataLoader | 2 (1 + 1 en batch) | por petición |
🧠 Check your understanding
0/3 · 0/3 answered1. ¿Por qué el resolver ingenuo Post.author causa un problema N+1?
2. ¿Qué debe garantizar una función de batch de DataLoader sobre su valor de retorno?
3. ¿Por qué debería crearse una instancia nueva de DataLoader por petición en lugar de reutilizarla entre peticiones?