Rebajas de verano: hasta un 80% de descuento Aprovechar oferta

Optimización del rendimiento de Node.js: 12 formas de acelerar las apps

Optimización del rendimiento de Node.js: 12 formas de acelerar las apps

Optimizar el rendimiento de Node.js consiste en identificar y solucionar los aspectos de tu app que la ralentizan para que responda más rápido, gestione más tráfico, utilice los recursos de forma más eficiente y se mantenga estable bajo carga.

Lo mejor es medir primero y solucionar después los problemas que indiquen los datos. Sin mediciones, no sabrás si el problema es un bucle de eventos bloqueado, middleware que consume demasiados recursos, respuestas de API demasiado grandes, consultas repetidas a la base de datos, índices que faltan, fugas de memoria o tareas que consumen mucha CPU en el hilo principal.

Herramientas como los perfiladores, las herramientas de pruebas de carga y el software de APM permiten detectar los problemas reales de tu app.

Una vez que tengas una medición de referencia, céntrate en las soluciones que correspondan a los cuellos de botella detectados:

  1. Usa código asíncrono para mantener libre el bucle de eventos
  2. Optimiza el middleware de Express y reduce el tamaño de las respuestas de la API
  3. Almacena en caché las consultas repetidas y los cálculos que consumen muchos recursos
  4. Acelera las consultas a la base de datos con índices y connection pooling
  5. Usa streams en lugar de cargar archivos grandes en la memoria
  6. Saca las tareas que consumen mucha CPU del hilo principal
  7. Escala entre los núcleos de la CPU mediante clustering
  8. Ajusta la configuración de memoria y detecta las fugas
  9. Elimina las dependencias innecesarias
  10. Sirve archivos estáticos a través de una CDN con compresión
  11. Elige el entorno de hosting adecuado
  12. Supervisa continuamente el rendimiento en producción

La mayoría de las apps no necesitan todas las optimizaciones a la vez. Soluciona primero los problemas que detectes al analizar el rendimiento, vuelve a medir y pasa al siguiente cuello de botella.

Cómo medir el rendimiento de Node.js antes de optimizarlo

Medir el rendimiento de Node.js implica analizar el comportamiento de tu app bajo una carga realista, hacer un seguimiento de las métricas clave y comparar los resultados antes y después de cada cambio.

Empieza con el perfilador integrado de Node.js y Chrome DevTools. Estas herramientas permiten comprobar si el código que consume mucha CPU, la presión de memoria o los bloqueos del bucle de eventos están ralentizando la app.

Ejecuta el perfilador integrado con:

node --prof app.js

node --prof-process isolate-*.log > processed.txt

Si prefieres un enfoque visual, inicia tu app con:

node --inspect app.js

A continuación, abre chrome://inspect en Chrome.

Si necesitas un diagnóstico más claro, usa Clinic.js. Es un conjunto de herramientas gratuitas para detectar problemas de rendimiento en Node.js:

  • Clinic Doctor muestra si el problema está relacionado con la CPU, la E/S, la memoria o el bucle de eventos
  • Clinic Flame crea gráficos de llama para que puedas ver qué funciones tardan más en ejecutarse
  • Clinic Bubbleprof te ayuda a detectar cadenas asíncronas lentas

Una vez que hayas analizado el rendimiento, sabrás qué rutas, funciones u operaciones son lentas. Para comprobar cómo se comportan bajo una carga elevada, haz una prueba de carga con una herramienta como Autocannon. Esta herramienta envía un gran volumen de solicitudes a tu app y muestra los tiempos de respuesta, el número de solicitudes y los errores.

Para obtener resultados útiles de las pruebas de carga:

  • Ejecuta las pruebas en hardware similar al de producción
  • Deja que la app se caliente antes de hacer las mediciones
  • Cambia solo una cosa cada vez
  • Controla los límites de la base de datos y las API externas para evitar que distorsionen los resultados

Una vez que tu app esté en producción, sigue haciendo mediciones. Las herramientas de APM como Datadog, New Relic o AppSignal miden la latencia de las rutas, los tiempos de las consultas a la base de datos, los errores y el uso de recursos con tráfico real. Ayudan a detectar problemas en producción, pero no sustituyen a un análisis más detallado cuando necesitas inspeccionar cuellos de botella a nivel de código.

Si tienes problemas de memoria, crea snapshots del heap con Chrome DevTools o v8.writeHeapSnapshot(). Los snapshots completos pueden consumir muchos recursos con tráfico real, así que créalos en un entorno de staging o en momentos de poco tráfico.

Haz un seguimiento de estas métricas antes y después de cada optimización:

Métrica

Qué mide

Qué ayuda a detectar

Tiempo de respuesta

Duración de la solicitud desde el principio hasta el final

Rutas lentas, middleware que consume demasiados recursos

Solicitudes por segundo

Solicitudes gestionadas por segundo

Límites de capacidad

Uso de la CPU

Tiempo de procesador que consume la app

Código que consume mucha CPU, bucles bloqueantes

Uso de memoria (RSS)

Memoria total que ocupa el proceso, incluidos los búferes nativos

Fugas de memoria, cachés grandes, asignaciones nativas

Retraso del bucle de eventos

Retraso entre la ejecución programada y la ejecución real de las funciones de callback

Código síncrono bloqueante

Tiempo de consulta de la base de datos

Tiempo de respuesta de las consultas

Índices que faltan, consultas lentas

Tasa de errores

Porcentaje de solicitudes fallidas

Tiempos de espera, rutas de código inestables

¿Cuáles son las métricas de rendimiento más importantes de Node.js?

Las métricas más importantes son la latencia p95 y p99, el crecimiento de la memoria, el uso de la CPU, el retraso del bucle de eventos, la duración de las consultas a la base de datos, la tasa de aciertos de la caché y la tasa de errores.

El tiempo medio de respuesta es útil, pero puede ocultar problemas. Una ruta puede tener un tiempo medio de 200 ms mientras que las solicitudes del percentil 99 tardan tres segundos. Los valores p95 y p99 reflejan los retrasos que experimentan los usuarios con las solicitudes más lentas durante los picos de tráfico, algo que los valores medios pueden ocultar.

Si la latencia p95 o p99 es alta, las demás métricas ayudan a identificar las posibles causas de las solicitudes lentas:

  • Si la memoria sigue aumentando, puede haber una fuga
  • Los picos de uso de la CPU apuntan a cálculos intensivos en el hilo principal
  • El retraso del bucle de eventos indica que hay código bloqueante
  • La duración de las consultas a la base de datos y la tasa de aciertos de la caché permiten detectar operaciones costosas que se ejecutan repetidamente
  • Si la tasa de errores aumenta a medida que crece el tráfico, es probable que a la app se le estén agotando los recursos

Revisa estas métricas con regularidad. Cuando cambie alguna, úsala para decidir qué optimización aplicar a continuación. Empieza por las correcciones a nivel de código y, después, pasa a la infraestructura y la monitorización según sea necesario.

1. Usa código asíncrono para evitar bloquear el bucle de eventos

El código asíncrono mantiene el bucle de eventos disponible mientras tu app espera a que terminen operaciones con archivos, consultas a bases de datos, peticiones de red o llamadas a API externas.

Node.js ejecuta JavaScript en un único hilo principal. Cuando ese hilo está ocupado con operaciones síncronas, las demás solicitudes quedan en espera. El código bloqueante dentro de los controladores de solicitudes ralentiza toda la app.

Los bloqueos suelen deberse a llamadas síncronas o a cálculos intensivos dentro de los controladores de solicitudes. Si el retraso del bucle de eventos es elevado, comprueba primero lo siguiente:

  • Lecturas síncronas de archivos
  • Bucles que requieren muchos recursos
  • Procesamiento de grandes cantidades de datos JSON
  • Cifrado síncrono
  • Procesamiento que consume mucha CPU dentro de los controladores de solicitudes

Compara la lectura bloqueante de un archivo con una versión asíncrona:

// Bloqueante: las demás peticiones quedan en espera

const data = fs.readFileSync('/path/to/large-file.txt', 'utf8');

res.send(data);

// No bloqueante: el bucle de eventos sigue disponible

const data = await fs.promises.readFile('/path/to/large-file.txt', 'utf8');

res.send(data);

La versión asíncrona permite que Node gestione la lectura del archivo fuera del hilo principal de JavaScript. Mientras se lee el archivo, el bucle de eventos puede seguir gestionando otras peticiones.

Usa async/await y las API de Node.js basadas en promesas, como fs.promises, de forma predeterminada.

Los controladores de bases de datos modernos como pg, mysql2 y Mongoose funcionan de forma similar: gestionan la E/S de forma asíncrona, por lo que esperar una respuesta de la base de datos no suele bloquear el bucle de eventos. Sin embargo, los conjuntos de resultados grandes y el procesamiento intensivo de las respuestas sí pueden provocar bloqueos, así que limita el tamaño de las consultas y los datos devueltos.

El código asíncrono evita los bloqueos mientras se espera a que termine una operación de E/S, pero no soluciona las tareas que dependen de la CPU. Si una función dedica 500 ms a realizar cálculos, seguirá bloqueando el hilo principal aunque uses await. Los worker threads solucionan este problema ejecutando los cálculos en otro hilo.

Si tu app usa llamadas síncronas dentro de los controladores de solicitudes, sustituirlas por versiones asíncronas puede ser una de las formas más rápidas de mejorar el rendimiento.

2. Optimiza el middleware de Express y las respuestas de la API

Optimizar la configuración de Express y los datos que devuelve la API reduce el tiempo de respuesta al eliminar procesos que el servidor no debería ejecutar en cada solicitud.

El middleware aplicado con app.use() se ejecuta en todas las solicitudes que coincidan. Si la autenticación, el registro, el análisis del cuerpo de las solicitudes, CORS, la limitación de solicitudes y la compresión se aplican globalmente, algunas rutas pueden ejecutar procesos que no necesitan. Siempre que sea posible, aplica el middleware solo a las rutas que lo necesiten:

// En lugar de aplicar la autenticación a todas las rutas

app.use(authMiddleware);

// Aplícala solo donde sea necesaria

app.get('/dashboard', authMiddleware, dashboardHandler);

app.get('/public-page', publicHandler);

Ahora, las rutas públicas omiten por completo el proceso de autenticación.

A continuación, revisa qué datos acepta y devuelve la API. Establece un límite para el tamaño de los datos JSON entrantes:

app.use(express.json({ limit: '100kb' }));

Ajusta el límite según los datos que acepte realmente la API. En las respuestas, devuelve solo los campos que necesite el cliente. Enviar registros completos de la base de datos cuando el frontend solo utiliza tres campos consume tiempo de procesamiento y ancho de banda innecesariamente.

Pagina las respuestas grandes para evitar que los endpoints devuelvan miles de registros sin ningún límite.

Si sirves archivos estáticos a través de Express, pásalos a una CDN o a un proxy inverso como Nginx. Si la CDN o el proxy inverso ya se encargan de la compresión, no uses compression() en Express. Comprime los archivos una sola vez y en una sola capa.

Aunque hayas optimizado todo esto, el tiempo de respuesta depende de toda la cadena de la solicitud: middleware, validación, consultas a la base de datos, API externas, formateo de datos y transferencia de red. Analiza las rutas lentas para averiguar qué parte provoca más retraso.

¿Cómo puedes reducir el tiempo de respuesta de la API de Node.js?

Puedes reducir el tiempo de respuesta de la API solucionando el punto más lento de cada ruta. Las medidas con mayor impacto son:

  • Elimina el middleware innecesario de la ruta
  • Devuelve solo los campos necesarios
  • Pagina los resultados extensos
  • Almacena en caché las respuestas repetidas
  • Optimiza las consultas a la base de datos
  • Configura tiempos de espera para las peticiones externas lentas

Si una ruta espera cinco segundos la respuesta de una API externa, establece un tiempo de espera y devuelve una respuesta alternativa cuando sea posible. Así, los usuarios reciben una respuesta controlada en lugar de tener que esperar a que falle la solicitud.

3. Almacena en caché los datos solicitados con más frecuencia

La caché reduce las consultas repetidas a la base de datos, las llamadas a API externas y los cálculos que consumen muchos recursos. Si una consulta tarda 50 ms y devuelve el mismo resultado muchas veces, puedes ejecutarla una sola vez y atender las solicitudes posteriores desde la caché.

Para valores pequeños y de corta duración en un solo servidor, suele bastar con una caché sencilla en memoria. Limítala con un TTL o un tamaño máximo para evitar que acabe causando problemas de memoria.

Usa Redis o Memcached cuando ejecutes varios procesos, necesites compartir la caché entre servidores o quieras guardar los datos de sesión fuera del proceso de la app.

Tipo de almacenamiento en caché

Ideal para

Ventaja principal

Ten cuidado con

En memoria

Valores pequeños y temporales

Lecturas rápidas, sin conexión a Internet

No se comparte entre procesos

Redis/Memcached

Datos compartidos, sesiones

Funciona en todas las instancias

Añade un salto de red

CDN

Archivos estáticos, recursos públicos

Menor carga en el servidor de origen y menor latencia

Unos encabezados incorrectos pueden hacer que se sirvan archivos obsoletos

Encabezados HTTP

Visitas repetidas, recursos estáticos

Controlan cómo se reutilizan las respuestas

Unos encabezados mal configurados pueden almacenar en caché más contenido del necesario

Sea cual sea el tipo que uses, la caché necesita dos ajustes: el TTL y la invalidación.

El TTL (time to live) determina cuánto tiempo se mantiene válido un valor almacenado en caché. Un TTL corto mantiene los datos actualizados, pero implica repetir operaciones con más frecuencia. Un TTL largo mejora la velocidad, pero aumenta el riesgo de servir datos obsoletos. La invalidación determina cómo se eliminan o actualizan los valores almacenados en caché cuando cambia la fuente.

Controla la tasa de aciertos de la caché. Si el 90% de las solicitudes se atienden desde la caché, la base de datos solo tendrá que gestionar el 10% de ese trabajo. Si la tasa de aciertos sigue siendo baja, puede que el TTL sea demasiado corto, que las claves de caché sean demasiado específicas o que los datos cambien con demasiada frecuencia para aprovechar bien la caché.

4. Optimiza las consultas a la base de datos y la gestión de las conexiones

Las consultas a la base de datos suelen ser una de las principales causas de las respuestas lentas de una API, ya que la app tiene que esperar a que la base de datos responda antes de poder devolver una respuesta.

Empieza por las soluciones con mayor impacto:

  • Selecciona solo las columnas que necesites
  • Evita SELECT *
  • Añade índices a las columnas que se usan con frecuencia para filtrar, ordenar o hacer joins
  • Evita las consultas N+1
  • Pagina los conjuntos de resultados grandes
  • Usa connection pooling
  • Revisa los planes de ejecución de las consultas lentas

Compara una consulta sin optimizar con otra más eficiente:

// Lenta: recupera todas las columnas y todas las filas

const users = await db.query('SELECT * FROM users');

// Más rápida: recupera solo los campos y las filas necesarios

const users = await db.query(

'SELECT id, name, email FROM users ORDER BY created_at DESC LIMIT 20'

);

En una tabla con, por ejemplo, 100.000 filas y 30 columnas, la primera consulta hace que la app recupere y envíe muchos más datos de los necesarios. La segunda devuelve 20 filas con tres columnas. Con un índice adecuado en created_at, la base de datos puede recuperar los registros más recientes sin tener que escanear toda la tabla.

La consulta es solo una parte del proceso. También importa cómo se conecta la app a la base de datos. Sin connection pooling, la app abre una conexión nueva para cada solicitud, lo que añade retraso y puede saturar la base de datos. Un pool mantiene las conexiones disponibles y las reutiliza.

La mayoría de los controladores de Node.js, incluidos pg y mysql2, admiten connection pooling. Ajusta el tamaño del pool en función del tráfico, la capacidad de la base de datos y el número de instancias de la app. Si hay muy pocas conexiones, las solicitudes se acumulan en la cola. Si hay demasiadas, la base de datos puede sobrecargarse.

¿Cómo mejoran los índices el rendimiento de las apps de Node.js?

Los índices permiten que la base de datos encuentre más rápido las filas que coinciden con una consulta, lo que reduce el tiempo de consulta y agiliza las respuestas de la API.

Si la ruta de inicio de sesión busca usuarios por email, por ejemplo, un índice en email ayuda a la base de datos a encontrar la fila correspondiente sin tener que revisar todos los registros:

SELECT id, name, email
FROM users
WHERE email = $1;

Los valores de email suelen ser únicos o casi únicos, por lo que este tipo de índice suele ser útil.

Para las consultas paginadas que filtran y ordenan datos, un índice compuesto como (active, created_at) puede ser útil, dependiendo de la base de datos y de cómo estén distribuidos los datos.

No indexes todas las columnas. Los índices aceleran las lecturas, pero aumentan el tiempo de escritura y ocupan espacio de almacenamiento, ya que la base de datos debe actualizar el índice cada vez que cambian las filas.

5. Usa streams para archivos grandes y respuestas de gran tamaño

Los streams procesan los datos por partes en lugar de cargar archivos completos en la memoria. Por ejemplo, si cargas de la forma habitual un archivo CSV de 500 MB, ocupará 500 MB de RAM. En cambio, al procesarlo mediante streams, solo se utiliza un pequeño búfer en cada momento.

Evita cargar archivos grandes de esta forma:

app.get('/download', async (req, res) => {

const data = await fs.promises.readFile('large-file.csv');

res.send(data);

});

En su lugar, usa un stream:

const fs = require('node:fs');

const { pipeline } = require('node:stream/promises');

app.get('/download', async (req, res, next) => {

try {

await pipeline(

fs.createReadStream('large-file.csv'),

res

);

} catch (err) {

next(err);

}

});

La versión con streams empieza a enviar datos de inmediato y consume menos memoria, ya que Node procesa el archivo por partes.

Los streams también gestionan las diferencias de velocidad entre el emisor y el receptor mediante un mecanismo conocido como backpressure. Si la conexión del usuario es lenta, Node pausa la lectura hasta que el stream de respuesta pueda seguir procesando los datos. Usa pipeline() en producción, ya que gestiona mejor que .pipe() los errores de los streams y la liberación de recursos.

En producción, comprueba también que el archivo exista, configura los encabezados adecuados y gestiona las desconexiones de los clientes.

6. Reduce las tareas que consumen mucha CPU en el hilo principal

Las tareas que consumen mucha CPU bloquean el hilo principal e impiden que el bucle de eventos gestione otras solicitudes. El código asíncrono no soluciona este problema porque el motor de JavaScript sigue ejecutando los cálculos en un solo hilo.

Entre las tareas que suelen consumir mucha CPU se encuentran:

  • Procesamiento de imágenes
  • Generación de archivos PDF
  • Cifrado
  • Compresión
  • Transformación de grandes cantidades de datos JSON
  • Generación de informes
  • Cálculos intensivos

Saca las tareas que consumen muchos recursos del flujo principal de la solicitud en función de cuándo y cómo se necesite el resultado:

  • Usa worker threads cuando el resultado se necesite durante la misma solicitud
  • Usa colas de tareas en segundo plano cuando el procesamiento pueda completarse más tarde, por ejemplo, para generar informes o procesar emails
  • Recurre a servicios externos cuando la carga de trabajo sea especializada o requiera muchos recursos, como el procesamiento de vídeo o el aprendizaje automático

Para las tareas en segundo plano, usa una cola como BullMQ con Redis para que la API pueda responder rápidamente mientras otro worker procesa la tarea.

Para tareas web que consumen mucha CPU, como generar archivos PDF, cambiar el tamaño de imágenes o transformar datos, los worker threads y las colas de tareas en segundo plano suelen permitirte seguir utilizando Node.js.

Para cargas de trabajo relacionadas con la ciencia de datos, el aprendizaje automático o la computación numérica, puede merecer la pena comparar Node.js con Python antes de decidir dónde ejecutar esa parte del sistema.

¿Cuándo conviene usar worker threads en Node.js?

Usa worker threads cuando tu app necesite ejecutar código JavaScript que consuma mucha CPU sin bloquear el bucle de eventos principal y el resultado se necesite durante la misma solicitud.

Los worker threads y el clustering resuelven problemas distintos. Los worker threads permiten ejecutar tareas que consumen mucha CPU dentro de un mismo proceso sin bloquear el hilo principal. El clustering ejecuta varios procesos de Node.js para gestionar más solicitudes simultáneas entre los distintos núcleos de la CPU.

Imagina que tu app genera facturas en PDF. El hilo principal puede seguir disponible mientras un worker se encarga de generarlas. En este ejemplo se crea un único worker para que resulte más sencillo entender el proceso. En producción, usa un pool de workers para las tareas recurrentes:

const { Worker } = require('node:worker_threads');

function generatePDF(data) {

return new Promise((resolve, reject) => {

const worker = new Worker('./pdf-worker.js', {

workerData: data,

});

worker.on('message', resolve);

worker.on('error', reject);

worker.on('exit', (code) => {

if (code !== 0) {

reject(new Error(`Worker stopped with exit code ${code}`));

}

});

});

}

Crear un worker para cada tarea pequeña puede consumir más recursos que la propia tarea. Para operaciones breves, de unos pocos milisegundos o menos, mantenlas en el hilo principal.

7. Escala Node.js mediante clustering y balanceo de carga

El clustering ejecuta varios procesos worker de Node.js en distintos núcleos de la CPU para que la app pueda gestionar más solicitudes simultáneas.

La mejora dependerá de la carga de trabajo. Las apps con muchas operaciones de E/S, como los servidores de API que esperan respuestas de bases de datos, suelen beneficiarse más que las apps con un uso intensivo de la CPU, ya que en estas últimas cada worker sigue compitiendo por los recursos de procesamiento.

En cualquier caso, casi nunca se consigue cuadruplicar el rendimiento con cuatro núcleos, ya que los recursos compartidos, como la base de datos y la red, además de la planificación de procesos del sistema operativo, siguen imponiendo límites.

El módulo cluster de Node.js crea varios procesos de la app que comparten el mismo puerto. En la mayoría de las plataformas, el proceso principal de Node distribuye las conexiones entre los workers mediante un sistema round-robin. PM2 simplifica este proceso al añadir gestión de procesos, reinicios automáticos, monitorización y recargas sin tiempo de inactividad.

Más allá de un único servidor, un proxy inverso como Nginx o un balanceador de carga puede distribuir el tráfico entre varios servidores o contenedores. Docker y Kubernetes permiten gestionar este tipo de despliegues a mayor escala.

El clustering mejora la capacidad de la app para procesar solicitudes, pero no soluciona las consultas lentas, las fugas de memoria, las tareas que bloquean la CPU dentro de cada proceso ni la ausencia de caché. Cuatro copias de una app lenta seguirán siendo lentas, aunque puedan atender a más usuarios a la vez.

Warning

Advertencia Si los datos de sesión se almacenan únicamente en la memoria del proceso, el clustering hará que las sesiones dejen de funcionar correctamente. Cada worker tiene su propia memoria, por lo que la siguiente solicitud de un usuario podría llegar a otro worker y hacer que pierda la sesión. Almacena las sesiones en Redis o en otro sistema de almacenamiento compartido.

¿Cuál es la diferencia entre clustering y balanceo de carga?

El clustering ejecuta varios procesos de Node.js en un mismo servidor para aprovechar más núcleos de la CPU. El balanceo de carga distribuye el tráfico entre procesos, servidores o contenedores.

Las apps en producción suelen usar ambas técnicas. El clustering permite aprovechar los núcleos de la CPU de cada servidor, mientras que el balanceo de carga distribuye el tráfico entre varios servidores para mejorar la disponibilidad y la capacidad.

8. Optimiza la memoria y la recolección de basura de Node.js

Ajustar la memoria puede reducir las pausas de la recolección de basura, evitar fallos y mantener la app estable con un tráfico sostenido. La mayoría de las apps no necesitan ajustes manuales de memoria a menos que surjan problemas relacionados con su uso.

V8, el motor de JavaScript que utiliza Node.js, gestiona la memoria automáticamente. Su heap incluye dos áreas principales:

  • New Space: almacena objetos de corta duración y se libera con frecuencia
  • Old Space: almacena objetos que sobreviven a varios ciclos de recolección de basura y se libera con menos frecuencia

Un uso elevado de memoria no siempre indica una fuga. Puede deberse al crecimiento normal bajo carga, a cachés grandes, a búferes nativos o a la fragmentación. Una fuga de memoria se produce cuando el uso de memoria sigue aumentando porque la app conserva referencias a objetos que ya no necesita.

Puedes ajustar los límites de memoria de V8 con:

node --max-old-space-size=4096 app.js

Este comando aumenta el límite de Old Space. Úsalo cuando la app realmente necesite más espacio en el heap, no para ocultar una fuga de memoria. El valor predeterminado depende de la versión de Node.js y de la memoria disponible en el sistema. En los contenedores, Node.js ajusta el límite en función de la memoria disponible para el contenedor.

El flag --max-semi-space-size influye en New Space. Un valor mayor reduce la frecuencia con la que los objetos de corta duración pasan a Old Space, lo que puede reducir las ejecuciones más lentas de la recolección de basura. Sin embargo, modificarlo sin analizar antes el rendimiento puede empeorarlo. Haz pruebas con tu carga de trabajo real antes de cambiarlo.

¿Cómo puedes detectar fugas de memoria en Node.js?

Las fugas de memoria se producen cuando la app conserva referencias a objetos que ya no necesita.

Entre las causas más habituales se encuentran:

  • Arrays o mapas globales que no dejan de crecer
  • Cachés en memoria sin TTL ni límites de tamaño
  • Event listeners que nunca se eliminan
  • Objetos grandes almacenados en closures
  • Temporizadores que nunca se eliminan

Usa snapshots del heap para comparar el uso de memoria a lo largo del tiempo:

const v8 = require('node:v8');

v8.writeHeapSnapshot();

Haz un snapshot, ejecuta la app bajo carga y haz otro después. Los objetos cuyo número sigue aumentando entre ambos snapshots pueden ser indicios de una fuga de memoria.

Supervisa también la memoria en producción. Si aumenta durante horas o días y no disminuye después de la recolección de basura, investiga la causa. Las pruebas de carga con Autocannon pueden hacer que las fugas aparezcan más rápido en un entorno de staging.

9. Reduce la sobrecarga de las dependencias y el código

Eliminar dependencias innecesarias y operaciones repetidas puede hacer que la app se inicie más rápido, consuma menos memoria y reduzca los riesgos de seguridad. Muchas de estas mejoras son pequeñas tareas de limpieza, no requieren reescribir el código.

Comprueba primero package.json. Elimina los paquetes que la app ya no utilice y busca paquetes grandes que solo se usen para tareas sencillas. Si utilizas Lodash únicamente para _.get(), el encadenamiento opcional puede ser suficiente:

const city = user?.address?.city;

Si utilizas Moment.js únicamente para aplicar un formato básico a las fechas, Intl.DateTimeFormat o una biblioteca más pequeña como date-fns pueden cubrir tus necesidades.

Las dependencias no son la única fuente de operaciones innecesarias. Tu propio código también puede repetir operaciones que consumen muchos recursos:

  • Crear un nuevo cliente HTTP para cada solicitud saliente
  • Leer y analizar el mismo archivo de configuración en cada solicitud
  • Compilar una expresión regular dentro de un bucle
// Ineficiente: vuelve a leer la configuración en cada solicitud

app.get('/settings', async (req, res) => {
  const config = JSON.parse(await fs.promises.readFile('config.json', 'utf8'));

  res.json({ theme: config.theme });
});

// Mejor: lee la configuración una vez al iniciar la app

const config = JSON.parse(fs.readFileSync('config.json', 'utf8'));

app.get('/settings', (req, res) => {
  res.json({ theme: config.theme });
});

Las lecturas síncronas no suponen un problema durante el inicio de la app porque todavía no se están gestionando solicitudes. El problema aparece cuando se ejecutan dentro de los controladores de solicitudes, donde pueden bloquear otras solicitudes.

Mantén actualizadas las dependencias que utilices. Las versiones más recientes suelen incluir mejoras de rendimiento y parches de seguridad.

10. Usa una CDN y compresión para los archivos estáticos

Una CDN sirve archivos estáticos desde ubicaciones más cercanas a los usuarios, lo que reduce la latencia y la carga del servidor de origen. Úsala para JavaScript, CSS, imágenes, fuentes y descargas estáticas. Tu app de Node.js no debería dedicar recursos de la CPU a servir estos archivos cuando una CDN o un proxy inverso pueden encargarse de ellos.

Además de la ubicación, el tamaño de los archivos también influye en la velocidad de carga. La compresión reduce su tamaño antes de que lleguen al navegador. Gzip y Brotli son las principales opciones.

Brotli comprime mejor los archivos estáticos que Gzip cuando se comprimen previamente durante el proceso de compilación. Para las respuestas dinámicas que se comprimen sobre la marcha, Gzip suele ser más rápido porque la codificación con Brotli consume más CPU.

Comprime archivos basados en texto como HTML, CSS, JavaScript, JSON y SVG. Evita comprimir archivos que ya utilizan formatos comprimidos, como JPEG, PNG, MP4 y ZIP, ya que el proceso consume CPU sin reducir demasiado su tamaño.

Para las imágenes, usa formatos modernos como WebP o AVIF siempre que sea posible y ajusta sus dimensiones al tamaño en el que realmente se muestran. Si subes una imagen de 4000 × 3000 pero la muestras a 400 × 300, el navegador seguirá descargando el archivo completo a menos que sirvas una versión más pequeña. Redimensionar las imágenes durante la compilación o mediante una CDN permite ahorrar ancho de banda.

Cuando los archivos estén comprimidos y tengan el tamaño adecuado, configura encabezados de caché de larga duración para evitar que los visitantes recurrentes tengan que descargarlos de nuevo:

Cache-Control: public, max-age=31536000, immutable

Usa nombres de archivo con versionado para invalidar la caché, como app.a3f2b1.js. Así, los navegadores pueden mantener los archivos en caché durante mucho tiempo y seguir recibiendo las versiones actualizadas cuando cambie el nombre del archivo.

11. Elige el entorno de hosting adecuado para optimizar el rendimiento de Node.js

El hosting influye en el rendimiento porque la CPU, la RAM, la velocidad del almacenamiento, el ancho de banda, el acceso a una CDN y la región del servidor determinan cómo funciona la app optimizada en producción.

Optimizar el código hace que la app sea más rápida. Elegir el hosting adecuado permite que ese rendimiento llegue a los usuarios. Ambos aspectos son necesarios.

La decisión principal es cuánta infraestructura quieres gestionar.

FactorHosting administrado de Node.jsVPS
Control del servidorLa plataforma se encarga de la mayor parte de la configuraciónAcceso root completo
DespliegueMediante Git o archivos, con menor complejidadSSH y configuración manual
MantenimientoLo gestiona el proveedorTú te encargas de las actualizaciones y los parches
FlexibilidadFunciona dentro de los límites de la plataformaControl total sobre el entorno de ejecución y el servidor
Ideal paraApps estándar y despliegues más rápidosStacks personalizados, Docker, PM2 y Nginx

El hosting para Node.js de Hostinger es una opción administrada. Permite desplegar proyectos mediante GitHub e incluye CDN, SSL y protección DDoS, para que puedas centrarte en publicar el código en lugar de mantener el servidor. El hosting para Node.js está disponible en los planes Business y Cloud.

El hosting VPS es más adecuado cuando necesitas acceso root, flujos de trabajo con Docker, PM2, Nginx, versiones específicas de Node.js o ajustes de rendimiento de bajo nivel. A cambio, tendrás que encargarte de las actualizaciones, los parches y la seguridad.

Puedes desplegar una app de Node.js con cualquiera de las dos opciones. Elige según las necesidades de la app, no solo por el precio. La región del servidor, los límites de CPU, la memoria, el escalado y la monitorización influyen en la experiencia de los usuarios.

12. Supervisa el rendimiento de Node.js en producción

La monitorización en producción permite detectar problemas que pueden pasar desapercibidos durante las pruebas de desarrollo, como solicitudes lentas con tráfico real, fugas de memoria, picos de errores y consultas a la base de datos que se ralentizan a medida que aumenta el volumen de datos.

Las herramientas de APM como Datadog, New Relic o AppSignal permiten monitorizar el rendimiento de las rutas con tráfico real. Por sí solas no detectan todos los problemas, así que combínalas con registros estructurados, trazas, monitorización de errores y comprobaciones de disponibilidad.

Para configuraciones más sencillas, los registros estructurados con Pino o Winston, junto con la monitorización de disponibilidad, cubren las necesidades básicas.

Configura alertas cuando:

  • Aumente la latencia p95 o p99
  • La memoria aumente de forma continua
  • El uso de la CPU se mantenga elevado
  • Aumente la tasa de errores
  • Aumente el tiempo de las consultas a la base de datos
  • Disminuya la tasa de aciertos de la caché

Empieza con umbrales conservadores y ajústalos a medida que conozcas los patrones habituales de la app. Si las alertas generan demasiado ruido, acabarán ignorándose. Las alertas bien definidas permiten detectar los problemas a tiempo.

Cuando las métricas indiquen que algo está ralentizando la app, aplica la solución correspondiente y vuelve a medir.

Lista de comprobación para optimizar el rendimiento de Node.js

Usa esta lista después de analizar el rendimiento de tu app. Empieza por los puntos relacionados con los cuellos de botella que hayas detectado y revisa el resto a medida que cambien el tráfico, las funcionalidades y la infraestructura:

  • Mide el rendimiento de referencia antes de modificar el código
  • Analiza las rutas lentas para detectar qué las está ralentizando
  • Comprueba la latencia p95 y p99, no solo los valores medios
  • Sustituye las operaciones síncronas dentro de los controladores de solicitudes
  • Saca el middleware específico de cada ruta de la configuración global
  • Limita el tamaño de los datos JSON entrantes
  • Devuelve solo los campos que necesita cada endpoint
  • Pagina las respuestas con listas grandes
  • Comprime las respuestas basadas en texto en una sola capa
  • Almacena en caché las consultas repetidas y los cálculos que consumen muchos recursos
  • Controla la tasa de aciertos de la caché
  • Añade índices para los filtros, joins y ordenaciones frecuentes
  • Evita las consultas N+1
  • Usa connection pooling para la base de datos
  • Usa streams para cargas, descargas y exportaciones de gran tamaño
  • Traslada las tareas que consumen mucha CPU a worker threads o colas de tareas en segundo plano
  • Escala entre los núcleos de la CPU mediante clustering
  • Almacena las sesiones en un sistema compartido cuando uses clustering
  • Comprueba el crecimiento de la memoria antes de ajustar los flags del heap
  • Elimina las dependencias que no utilices
  • Sirve los archivos estáticos mediante una CDN o un proxy inverso
  • Supervisa la latencia, los errores, la CPU, la memoria, los tiempos de las consultas a la base de datos y el rendimiento de la caché

Qué hacer después de optimizar Node.js

La optimización del rendimiento de Node.js no termina después de una sola ronda de mejoras. Las nuevas funcionalidades, el aumento del tráfico, las actualizaciones de las dependencias y el crecimiento de la base de datos pueden generar nuevos problemas de rendimiento con el tiempo.

Una vez que hayas solucionado los principales problemas de rendimiento, revisa el resto de la configuración de producción: gestión de errores, seguridad, gestión de dependencias, configuración del entorno, registros, pruebas y flujos de trabajo de despliegue.

Puede que estos aspectos no siempre afecten directamente a la velocidad, pero desempeñan un papel importante en la fiabilidad de la app en producción. Una app rápida también necesita registros claros, una configuración segura, dependencias estables y procesos de despliegue predecibles para seguir funcionando correctamente a medida que aumentan el tráfico y la complejidad.

Seguir las buenas prácticas de desarrollo con Node.js ayuda a que la app siga siendo fácil de mantener, segura y preparada para producción a medida que crece. Sigue midiendo, soluciona primero el cuello de botella que más afecte al rendimiento y repite el proceso.

Todo el contenido de los tutoriales en este sitio web está sujeto a los rigurosos estándares y valores editoriales de Hostinger.

Author
El autor

Faradilla Ayunindya

Faradilla, más conocida como Ninda, cuenta con 10 años de experiencia como lingüista y 5 años como especialista en marketing de contenidos en Hostinger. Le gusta mantenerse al día con las tendencias tecnológicas y ayudar a las personas a resolver sus problemas. En su tiempo libre, Ninda disfruta aprender sobre idiomas y ciencias de la vida, así como ver videos de animales. Puedes conocer mejor a Ninda en LinkedIn.

Lo que dicen nuestros clientes

Comentarios

0 responses

Write a respond

Por favor, rellena los campos obligatorios.Por favor, acepta la casilla de verificación Privacidad.Llena los campos requeridos y acepta la casilla de verificación de privacidad, por favor.

Thank you! Your comment has been successfully submitted. It will be approved within the next 24 hours.