Consultas individuales entre una API y su base de datos frente a una petición agrupada

El problema N+1 en las API: por qué tu backend hace demasiadas consultas

Una API puede funcionar perfectamente durante meses y empezar a responder lentamente cuando aumenta el número de usuarios. El código no ha cambiado, los tests siguen pasando y la base de datos no parece estar saturada. Sin embargo, una petición que antes tardaba poco ahora necesita varios segundos.

Una de las causas más habituales de este comportamiento es el problema N+1.

Se trata de un error de acceso a datos que aparece especialmente en aplicaciones que utilizan ORM como TypeORM, Hibernate, Entity Framework o Sequelize. No es un fallo exclusivo de estas herramientas, pero su abstracción puede hacer que resulte más difícil detectar cuántas consultas SQL está ejecutando realmente la aplicación.

En este artículo veremos cómo aparece, cómo identificarlo y qué alternativas existen para solucionarlo sin convertir cada consulta en un JOIN gigantesco.

¿Qué es el problema N+1?

El problema N+1 ocurre cuando una aplicación ejecuta una consulta inicial para obtener una colección y después realiza una consulta adicional por cada elemento de esa colección.

Imaginemos una aplicación de gestión de proyectos que necesita mostrar una lista de proyectos junto con sus tareas.

La primera consulta obtiene los proyectos:

SELECT id, name FROM projects ORDER BY id LIMIT 100;

Después, el backend recorre los resultados y obtiene las tareas de cada proyecto:

SELECT _ FROM tasks WHERE project_id = 1; SELECT _ FROM tasks WHERE project_id = 2; SELECT * FROM tasks WHERE project_id = 3; -- ... hasta el proyecto 100

El resultado es una consulta inicial más cien consultas adicionales.

Es decir:

1 consulta para obtener proyectos + N consultas para obtener sus tareas = N + 1 consultas

Con diez proyectos puede pasar desapercibido. Con cien, mil o varios usuarios realizando peticiones simultáneas, el problema empieza a ser significativo.

Un ejemplo realista con NestJS y TypeORM

Supongamos que tenemos dos entidades relacionadas. Los fragmentos siguientes utilizan la sintaxis de TypeORM 0.3 y omiten los imports de las entidades y la inyección de repositorios en el servicio NestJS. Declaramos los nombres de tablas y de la clave foránea para que coincidan con los ejemplos SQL.

@Entity('projects') export class Project { @PrimaryGeneratedColumn() id: number; @Column() name: string; @OneToMany(() => Task, (task) => task.project) tasks: Task[]; }
@Entity('tasks') export class Task { @PrimaryGeneratedColumn() id: number; @Column() title: string; @Column({ name: 'project_id' }) projectId: number; @ManyToOne(() => Project, (project) => project.tasks) @JoinColumn({ name: 'project_id' }) project: Project; }

La propiedad projectId expone la misma columna que utiliza la relación. Esto nos permitirá agrupar tareas sin tener que cargar el objeto project.

Queremos construir un endpoint que devuelva los proyectos con sus tareas.

Una implementación aparentemente razonable podría ser:

async findAll() { const projects = await this.projectRepository.find(); return Promise.all( projects.map(async (project) => { const tasks = await this.taskRepository.find({ where: { project: { id: project.id }, }, }); return { ...project, tasks, }; }), ); }

El código es legible y devuelve exactamente lo que esperamos.

Pero si find() devuelve 100 proyectos, ejecutaremos 101 consultas.

Promise.all() no elimina el problema. Únicamente permite que varias consultas se ejecuten de forma concurrente, dentro de los límites del pool de conexiones y de la base de datos.

De hecho, aumentar la concurrencia puede incrementar la presión sobre PostgreSQL sin reducir el número total de consultas.

¿Por qué puede ser tan lento?

Cada consulta tiene un coste.

Aunque PostgreSQL pueda resolver una consulta individual rápidamente, el backend debe enviarla, esperar la respuesta, procesar los resultados y gestionar los recursos necesarios.

El tiempo total no depende únicamente del trabajo que realiza el motor SQL.

También intervienen:

  • la latencia entre la aplicación y la base de datos;
  • el número de viajes de ida y vuelta;
  • la planificación y ejecución de cada consulta;
  • el pool de conexiones;
  • la cantidad de datos transferidos;
  • la transformación de resultados en entidades.

Por ejemplo, si una petición necesita realizar cien consultas secuenciales, una pequeña latencia por consulta puede acumularse. Si se ejecutan concurrentemente, el tiempo puede reducirse, pero el trabajo total y la presión sobre el sistema siguen existiendo.

Por eso no basta con mirar si cada consulta individual es rápida.

También debemos observar cuántas consultas ejecuta una petición completa.

Solución 1: cargar las relaciones con un JOIN

La primera alternativa consiste en obtener los proyectos y sus tareas mediante una consulta conjunta.

En TypeORM podemos utilizar QueryBuilder:

async findAll() { return this.projectRepository .createQueryBuilder('project') .leftJoinAndSelect('project.tasks', 'task') .getMany(); }

El ORM genera una consulta SQL equivalente, de forma simplificada, a:

SELECT project.id, project.name, task.id, task.title, task.project_id FROM projects project LEFT JOIN tasks task ON task.project_id = project.id;

En lugar de realizar una consulta por cada proyecto, obtenemos los datos relacionados en una única operación SQL.

TypeORM se encarga después de reconstruir las entidades y sus relaciones.

¿Por qué LEFT JOIN?

Utilizamos LEFT JOIN porque queremos obtener también los proyectos que todavía no tienen tareas.

Si utilizásemos un INNER JOIN, los proyectos sin tareas quedarían fuera del resultado.

Esta distinción es importante: optimizar una consulta no debe cambiar accidentalmente el comportamiento funcional del endpoint.

Solución 2: consultas por lotes

Un JOIN no siempre es la mejor solución.

Imaginemos que necesitamos obtener cien proyectos, pero cada uno puede tener miles de tareas. Una consulta conjunta podría devolver una cantidad enorme de filas y repetir los datos del proyecto muchas veces.

En ese caso podemos utilizar una estrategia de carga por lotes.

Primero obtenemos los proyectos:

const projects = await this.projectRepository.find({ order: { id: 'ASC' }, take: 100, }); if (projects.length === 0) { return []; }

El orden hace que el límite sea determinista. Si no hay proyectos, devolvemos una lista vacía sin consultar tareas.

Después extraemos sus identificadores:

const projectIds = projects.map((project) => project.id);

Y finalmente obtenemos todas las tareas necesarias en una única consulta, utilizando In y la columna projectId que hemos declarado en la entidad:

import { In } from 'typeorm'; // Dentro del mismo método del servicio: const tasks = await this.taskRepository.find({ where: { projectId: In(projectIds), }, });

La consulta SQL será conceptualmente similar a:

SELECT * FROM tasks WHERE project_id IN (1, 2, 3, 4, 5);

Ahora tenemos dos consultas en lugar de N+1.

Podemos agrupar las tareas por proyecto:

const tasksByProject = new Map<number, Task[]>(); for (const task of tasks) { const projectId = task.projectId; const current = tasksByProject.get(projectId) ?? []; current.push(task); tasksByProject.set(projectId, current); }

Y construir la respuesta:

return projects.map((project) => ({ ...project, tasks: tasksByProject.get(project.id) ?? [], }));

En este ejemplo, task.projectId está disponible sin cargar la relación. Acceder a task.project.id no sería correcto si project no se ha cargado.

La idea importante es que el número de consultas deja de crecer linealmente con el número de proyectos. Para listas de identificadores muy grandes conviene dividir la carga en lotes acotados; en ese caso habrá una consulta adicional por lote.

Los lotes evitan repetir los datos del proyecto, pero siguen cargando todas las tareas seleccionadas. Si son demasiadas, necesitaremos paginar las tareas o reducir los campos de la respuesta.

¿JOIN o consultas por lotes?

Comparación para 100 proyectos: N+1 ejecuta 101 consultas, JOIN una consulta y la carga por lotes dos consultas

Recuento ilustrativo para los ejemplos, sin relaciones adicionales ni consultas extra de paginación. Menos consultas no implica por sí solo un menor tiempo de respuesta.

No existe una respuesta universal.

EstrategiaVentaja principalRiesgo
JOINReduce viajes a la base de datosPuede multiplicar filas y transferir demasiados datos
Consultas por lotesControla mejor la carga de relacionesRequiere agrupar resultados y gestionar lotes
Carga individualSencilla para casos aisladosProduce N+1 cuando se utiliza dentro de colecciones

Si necesitamos un proyecto concreto con sus tareas, una carga individual puede ser perfectamente adecuada.

Si necesitamos cien proyectos con sus tareas, debemos plantearnos otra estrategia.

Y si cada proyecto tiene decenas de miles de tareas, probablemente el problema no sea únicamente cómo cargar la relación, sino si realmente necesitamos devolver todas esas tareas en una misma respuesta.

El error de cargar todas las relaciones por defecto

Una reacción habitual al descubrir N+1 es configurar todas las relaciones como eager.

Por ejemplo:

@OneToMany(() => Task, (task) => task.project, { eager: true, }) tasks: Task[];

Esto puede evitar consultas adicionales en determinados flujos, pero introduce otro problema: cargar datos que no necesitamos.

Quizá un endpoint solamente necesita devolver:

{ "id": 12, "name": "Proyecto web" }

Si cargamos automáticamente todas las tareas, estaremos consumiendo memoria, tiempo de consulta y ancho de banda sin aportar nada al cliente.

La solución no consiste en cargar siempre todas las relaciones.

Consiste en definir qué datos necesita cada caso de uso.

Diseñar DTO de respuesta ayuda a evitar el problema

Una buena práctica es separar las entidades de base de datos de los DTO que devuelve la API.

Por ejemplo, un listado puede necesitar:

export class ProjectListDto { id: number; name: string; taskCount: number; }

Mientras que el detalle puede necesitar:

export class ProjectDetailDto { id: number; name: string; tasks: TaskDto[]; }

Son necesidades diferentes y, por tanto, pueden utilizar consultas diferentes.

Para el listado no necesitamos cargar todas las tareas solamente para contarlas.

Podemos realizar una consulta agregada:

SELECT p.id, p.name, COUNT(t.id) AS task_count FROM projects p LEFT JOIN tasks t ON t.project_id = p.id GROUP BY p.id, p.name;

Esto permite devolver el número de tareas sin transferir cada tarea al backend.

El diseño de la API y el diseño de las consultas están directamente relacionados.

Cómo detectar N+1 antes de que llegue a producción

El primer paso es activar el registro de consultas durante el desarrollo.

En TypeORM podemos configurar el logging:

TypeOrmModule.forRoot({ type: 'postgres', // ...resto de configuración logging: ['query', 'error'], });

No es recomendable dejar un logging exhaustivo de todas las consultas en producción sin valorar su volumen, coste y posible exposición de datos sensibles.

Durante el desarrollo, sin embargo, permite observar qué ocurre cuando llamamos a un endpoint.

Si vemos algo parecido a esto:

SELECT ... FROM projects SELECT ... FROM tasks WHERE project_id = 1 SELECT ... FROM tasks WHERE project_id = 2 SELECT ... FROM tasks WHERE project_id = 3 SELECT ... FROM tasks WHERE project_id = 4

tenemos una señal clara de que debemos investigar.

Otra técnica útil consiste en crear tests de integración que verifiquen el número de consultas ejecutadas por determinados casos de uso.

No todos los endpoints necesitan un límite estricto, pero puede ser especialmente útil para listados críticos.

EXPLAIN ANALYZE: comprobar qué está haciendo PostgreSQL

Reducir el número de consultas es importante, pero no garantiza que la consulta resultante sea eficiente.

Una consulta única también puede ser lenta.

PostgreSQL proporciona EXPLAIN ANALYZE para estudiar cómo ejecuta una consulta:

EXPLAIN (ANALYZE, BUFFERS) SELECT p.id, p.name, t.id, t.title FROM projects p LEFT JOIN tasks t ON t.project_id = p.id;

El resultado permite analizar aspectos como:

  • el plan de ejecución;
  • el tiempo real empleado;
  • el número de filas procesadas;
  • los accesos a buffers;
  • los tipos de JOIN utilizados;
  • las diferencias entre filas estimadas y reales.

Es importante recordar que ANALYZE ejecuta realmente la consulta. Por tanto, hay que utilizarlo con precaución sobre operaciones que modifican datos.

En PostgreSQL 18, EXPLAIN ANALYZE muestra la información de buffers por defecto. La opción explícita BUFFERS del ejemplo también permite solicitarla en versiones anteriores. Puedes consultar este cambio en las notas de PostgreSQL 18.

¿Y los índices?

Los índices pueden mejorar considerablemente determinadas consultas, pero no solucionan por sí solos el problema N+1.

Si ejecutamos cien consultas, añadir un índice puede hacer que cada una sea más rápida.

Seguiremos ejecutando cien consultas.

En el ejemplo anterior, un índice sobre la clave utilizada para buscar tareas puede ser útil:

CREATE INDEX idx_tasks_project_id ON tasks (project_id);

Pero la decisión debe basarse en el patrón real de consultas y en el plan de ejecución.

Los índices tienen costes de almacenamiento y mantenimiento, especialmente durante las escrituras. No conviene crearlos indiscriminadamente.

PostgreSQL 18 es más rápido, pero no arregla una mala estrategia de acceso

PostgreSQL 18, publicado el 25 de septiembre de 2025, introdujo un nuevo subsistema de entrada y salida asíncrona, mejoras en el planificador y capacidades adicionales de observabilidad, descritos en sus notas de la versión.

Son avances relevantes para el rendimiento de la base de datos.

Sin embargo, una mejora del motor no elimina los errores de diseño de la aplicación.

Si un endpoint realiza 501 consultas para devolver 500 registros, actualizar PostgreSQL no transforma automáticamente ese patrón en una consulta eficiente.

El rendimiento de una aplicación depende de varias capas:

Cliente ↓ API ↓ Lógica de negocio ↓ ORM / acceso a datos ↓ PostgreSQL ↓ Almacenamiento

Optimizar solamente una de ellas puede no resolver el cuello de botella real.

Una metodología práctica para optimizar un endpoint

Cuando una API empieza a responder lentamente, conviene evitar las optimizaciones basadas únicamente en intuiciones.

Un procedimiento razonable sería:

  1. Medir el tiempo total del endpoint.
  2. Registrar cuántas consultas SQL ejecuta.
  3. Identificar consultas repetidas o innecesarias.
  4. Comprobar qué datos necesita realmente el cliente.
  5. Sustituir N+1 por JOIN, lotes o consultas agregadas cuando corresponda.
  6. Analizar las consultas costosas con EXPLAIN ANALYZE.
  7. Revisar índices y volumen de datos.
  8. Volver a medir con un conjunto de datos representativo.

La última parte es fundamental.

Una optimización no está demostrada porque el código parezca mejor. Está demostrada cuando las mediciones muestran una mejora sin alterar el comportamiento esperado.

Conclusión

El problema N+1 es uno de los ejemplos más claros de cómo una aplicación puede tener código correcto y, al mismo tiempo, una estrategia de acceso a datos deficiente.

Los ORM facilitan enormemente el desarrollo, pero no eliminan la necesidad de comprender SQL.

Saber cuándo utilizar un JOIN, cuándo cargar relaciones por lotes, cuándo realizar una consulta agregada y cómo analizar un plan de ejecución sigue siendo una habilidad esencial para cualquier desarrollador backend.

La lección no es que debamos abandonar TypeORM ni escribir todo el SQL manualmente.

Es que debemos dejar de tratar la base de datos como una caja negra.

Porque una API rápida no depende únicamente de tener un servidor potente o una versión reciente de PostgreSQL.

Depende, sobre todo, de pedirle a la base de datos exactamente lo que necesitamos, de la forma adecuada.

Fuentes y documentación