El patrón DataLoader: eliminar consultas N+1 sin un ORM
N+1 no es un problema del ORM: implementa el patrón de agrupación DataLoader en TypeScript para reducir consultas redundantes en cualquier fuente.

El problema de consultas N+1 se le achaca a los ORM. Arregla tu carga diferida, añade eager: true, usa un JOIN. Pero N+1 no es un problema de ORM — es un problema estructural que aparece siempre que se obtienen datos dentro de un bucle. Resolvers de GraphQL, endpoints REST que componen varios servicios, React Server Components que hacen llamadas a la base de datos por fila: el patrón está en todas partes, y el ORM es solo el mensajero.
La solución no siempre es un JOIN. A veces los datos viven en servicios diferentes. A veces llamas a una API de terceros. El patrón DataLoader — originalmente de la infraestructura GraphQL de Facebook — resuelve esto en la capa de abstracción correcta, sin colapsar los límites de tus resolvers.
Cómo se ve realmente N+1
Este es un ejemplo realista: un resolver de GraphQL que devuelve una lista de posts de blog, cada uno con un autor adjunto.
// ❌ N+1 — authorResolver fires once per post, 20 posts = 21 queries
const postsResolver = async () => {
return db.query<Post[]>("SELECT * FROM posts ORDER BY created_at DESC LIMIT 20");
};
const authorResolver = async (post: Post): Promise<User> => {
return db.queryOne<User>("SELECT * FROM users WHERE id = $1", [post.authorId]);
};Cada resolver parece completamente razonable de forma aislada. El problema solo emerge en tiempo de ejecución, bajo carga. Veinte posts generan veinte consultas de autor además de la consulta inicial de la lista.
La solución ingenua funciona cuando los datos viven en la misma base de datos:
// ✅ For same-DB data: batch in the parent, build a lookup map
const postsWithAuthorsResolver = async () => {
const posts = await db.query<Post[]>(
"SELECT * FROM posts ORDER BY created_at DESC LIMIT 20"
);
const authorIds = [...new Set(posts.map((p) => p.authorId))];
const authors = await db.query<User[]>(
"SELECT * FROM users WHERE id = ANY($1)",
[authorIds]
);
const byId = new Map(authors.map((a) => [a.id, a]));
return posts.map((p) => ({ ...p, author: byId.get(p.authorId) }));
};Pero esto rompe la separación de resolvers que hace que GraphQL sea componible. Y se desmorona por completo cuando los datos del autor provienen de un servicio de usuarios separado.
La primitiva de agrupación
La idea central es aplazar las obtenciones individuales hasta el final del tick actual del bucle de eventos, y luego enviarlas todas como un solo lote. Esto es exactamente para lo que fue diseñado queueMicrotask.
type BatchFn<K, V> = (keys: readonly K[]) => Promise<Map<K, V>>;
class DataLoader<K, V> {
private readonly batchFn: BatchFn<K, V>;
private queue: Array<{
key: K;
resolve: (value: V) => void;
reject: (error: Error) => void;
}> = [];
private scheduled = false;
private cache = new Map<K, Promise<V>>();
constructor(batchFn: BatchFn<K, V>) {
this.batchFn = batchFn;
}
load(key: K): Promise<V> {
// Return cached promise — same key in one request never fires twice
const cached = this.cache.get(key);
if (cached) return cached;
const promise = new Promise<V>((resolve, reject) => {
this.queue.push({ key, resolve, reject });
});
this.cache.set(key, promise);
if (!this.scheduled) {
this.scheduled = true;
queueMicrotask(() => this.flush());
}
return promise;
}
private async flush(): Promise<void> {
const batch = this.queue.splice(0);
this.scheduled = false;
const keys = batch.map((item) => item.key);
try {
const results = await this.batchFn(keys);
for (const { key, resolve, reject } of batch) {
const value = results.get(key);
if (value !== undefined) {
resolve(value);
} else {
reject(new Error(`DataLoader: no result for key "${String(key)}"`));
}
}
} catch (err) {
for (const { reject } of batch) {
reject(err instanceof Error ? err : new Error(String(err)));
}
}
}
}load(key) se llama N veces a través de diferentes resolvers. Todas las llamadas dentro del mismo punto de control de microtarea se encolan. flush() se ejecuta exactamente una vez, envía una sola solicitud agrupada, y luego distribuye los resultados a las promesas individuales en espera.
Conectándolo a una fuente de datos real
La función de lote es donde reside la estrategia. Aquí hay una respaldada por PostgreSQL:
const batchUsers: BatchFn<string, User> = async (userIds) => {
const rows = await db.query<User[]>(
"SELECT * FROM users WHERE id = ANY($1)",
[userIds]
);
return new Map(rows.map((u) => [u.id, u]));
};
const userLoader = new DataLoader(batchUsers);
// Each resolver calls load() — batching is transparent
const authorResolver = async (post: Post): Promise<User> => {
return userLoader.load(post.authorId);
};Para una llamada a microservicio, la función de lote se ve idéntica: solo cambia la consulta SQL por una solicitud HTTP:
const batchUsersFromService: BatchFn<string, User> = async (userIds) => {
const response = await fetch(`${USER_SERVICE_URL}/users/batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: [...userIds] }),
});
if (!response.ok) {
throw new Error(`User service responded with ${response.status}`);
}
const users: User[] = await response.json();
return new Map(users.map((u) => [u.id, u]));
};El código del resolver no cambia. La estrategia de agrupación está completamente encapsulada en la función de lote del loader.
Ámbito por solicitud
Hay un peligro sutil en la implementación anterior: el mapa cache vive en la instancia del loader. Si creas un loader a nivel de módulo, los datos en caché de una solicitud se filtran a la siguiente.
Nunca compartas una instancia de DataLoader entre solicitudes. La caché por solicitud es una característica, pero solo si el loader se recrea para cada ciclo de vida de solicitud.
El patrón estándar es AsyncLocalStorage para delimitar los loaders a una solicitud:
import { AsyncLocalStorage } from "node:async_hooks";
interface RequestLoaders {
user: DataLoader<string, User>;
post: DataLoader<string, Post>;
}
const loaderStorage = new AsyncLocalStorage<RequestLoaders>();
// Express / Fastify middleware — fresh loaders per request
export function attachLoaders(
req: Request,
res: Response,
next: NextFunction
): void {
loaderStorage.run(
{
user: new DataLoader(batchUsers),
post: new DataLoader(batchPosts),
},
next
);
}
// Anywhere downstream in the call stack
export function getLoaders(): RequestLoaders {
const store = loaderStorage.getStore();
if (!store) throw new Error("getLoaders() called outside request context");
return store;
}Loaders nuevos por solicitud, sin contaminación cruzada, deduplicación automática de caché dentro de la vida útil de una sola solicitud.
Cuando la agrupación no es la herramienta adecuada
DataLoader reduce N+1 a 1, pero a veces incluso una sola consulta agrupada es más de lo que necesitas — o menos de lo que necesitas. Saber cuándo usarlo importa.
| Patrón | Ideal para |
|---|---|
| DataLoader (caché por solicitud) | Resolver relaciones en respuestas GraphQL/REST |
| Redis con TTL | Datos entre solicitudes que cambian con poca frecuencia |
SQL JOIN | Datos coubicados con patrones de acceso predecibles y uniformes |
| Vista materializada | Agregaciones intensivas en lectura actualizadas según un cronograma |
| Caché LRU en proceso | Datos de referencia frecuentes (feature flags, configuración, enums) |
No recurras a DataLoader cuando un JOIN sea más simple: merece su complejidad cuando los datos abarcan límites de servicio o cuando el aislamiento del resolver importa más que la eficiencia bruta de la consulta. El patrón también asume que tu función de lote puede aceptar conjuntos de claves arbitrarios; si la API downstream solo admite obtener registros individuales, estás resolviendo el problema equivocado.
Conclusiones clave
- N+1 es estructural, no específico de ORM — aparece cada vez que se obtienen datos dentro de un bucle, independientemente de la capa de datos
- Aplaza, luego descarga —
queueMicrotaskrecopila todas las llamadasload()dentro de un tick en un solo lote; no se requiere coordinación manual - La función de lote es el adaptador — la misma implementación de
DataLoaderfunciona con SQL, HTTP, Redis o cualquier fuente asíncrona - Delimita los loaders a la solicitud — un loader a nivel de módulo servirá datos en caché obsoletos a solicitudes posteriores; usa
AsyncLocalStoragepara crear instancias nuevas - La deduplicación de caché es gratuita — llamar
loader.load(id)dos veces con la misma clave devuelve la misma promesa, evitando solicitudes redundantes en vuelo


