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:
Después, el backend recorre los resultados y obtiene las tareas de cada proyecto:
El resultado es una consulta inicial más cien consultas adicionales.
Es decir:
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.
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:
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:
El ORM genera una consulta SQL equivalente, de forma simplificada, a:
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:
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:
Y finalmente obtenemos todas las tareas necesarias en una única consulta, utilizando In y la columna projectId que hemos declarado en la entidad:
La consulta SQL será conceptualmente similar a:
Ahora tenemos dos consultas en lugar de N+1.
Podemos agrupar las tareas por proyecto:
Y construir la respuesta:
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?
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.
| Estrategia | Ventaja principal | Riesgo |
|---|---|---|
| JOIN | Reduce viajes a la base de datos | Puede multiplicar filas y transferir demasiados datos |
| Consultas por lotes | Controla mejor la carga de relaciones | Requiere agrupar resultados y gestionar lotes |
| Carga individual | Sencilla para casos aislados | Produce 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:
Esto puede evitar consultas adicionales en determinados flujos, pero introduce otro problema: cargar datos que no necesitamos.
Quizá un endpoint solamente necesita devolver:
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:
Mientras que el detalle puede necesitar:
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:
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:
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:
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:
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:
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:
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:
- Medir el tiempo total del endpoint.
- Registrar cuántas consultas SQL ejecuta.
- Identificar consultas repetidas o innecesarias.
- Comprobar qué datos necesita realmente el cliente.
- Sustituir N+1 por JOIN, lotes o consultas agregadas cuando corresponda.
- Analizar las consultas costosas con EXPLAIN ANALYZE.
- Revisar índices y volumen de datos.
- 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.