Inicio / Artículos / 20 patrones de Node.js que evitan interrupciones en los servidores en producción

20 patrones de Node.js que evitan interrupciones en los servidores en producción

Aprenda 20 patrones prácticos de Node.js, desde el manejo de errores hasta el apagado ordenado y la gestión de conexiones en pool, que evitan caídas antes de que sea necesario reiniciar.

4280 palabras

El tiempo de inactividad del servidor casi nunca comienza con un evento catastrófico.

Por lo general, el desencadenante es algo sutil y sin importancia.

Una conexión a la base de datos nunca vuelve al grupo de conexiones disponibles.

El uso de memoria aumenta gradualmente hasta que el proceso alcanza su límite máximo.

Una única ruta que antes respondía en 200 milisegundos comienza a tardar cinco segundos.

Una implementación interrumpe conexiones que aún estaban activas.

Un trabajo asíncrono falla sin que nadie detecte el error.

Un aumento repentino de tráfico obliga a cada solicitud entrante a realizar otra llamada costosa a la base de datos.

Ninguno de estos factores parece alarmante al principio.

Luego, los tiempos de respuesta comienzan a empeorar.

A continuación, las solicitudes empiezan a caducar.

Después, el proceso se detiene por completo.

Y es entonces cuando surgen las preguntas:

“¿Por qué está caído el servidor?”

Lo que hace esto tan frustrante es que la mayoría de estas situaciones podrían haberse evitado.

En muchos sistemas en producción, un conjunto recurrente de soluciones para Node.js sigue demostrándose verdaderamente efectivo, precisamente porque abordan la causa raíz en lugar de simplemente hacer que el servidor vuelva a funcionar después de caer.

Este artículo explica las más importantes.

1. Deje de permitir que una solicitud defectuosa detenga todo el proceso

Entre los hábitos más riesgosos en un códigobase de Node.js está permitir que una excepción lanzada o el rechazo no manejado de una promesa llegue hasta el propio proceso.

Tomemos este ejemplo:

app.get("/users/:id", async (req, res) => {
  const user = await User.findByPk(req.params.id);

  res.json(user);
});

A primera vista parece estar bien.

Pero, ¿qué sucede si la conexión a la base de datos se interrumpe en medio de una solicitud?

¿O el ORM genera un error?

¿O un valor de entrada inesperado rompe la lógica dentro del manejador?

Sin un manejo consistente de errores, el proceso puede llegar a un estado defectuoso, y dependiendo del error específico y de cómo esté configurado el entorno de ejecución, es posible que se cierre por completo.

Un patrón más resistente consiste en redirigir los errores de forma explícita en lugar de permitir que se escapen:

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await User.findByPk(req.params.id);

    res.json(user);
  } catch (error) {
    next(error);
  }
});

A partir de ahí, dirigir todo hacia un manejador centralizado:

app.use((err, req, res, next) => {
  console.error(err);

  res.status(500).json({
    error: "Internal server error"
  });
});

Esto no se trata de ocultar problemas.

Se trata de decidir exactamente dónde terminan los errores.

En lugar de que cada ruta improvise su propio método para lidiar con los fallos, toda la aplicación sigue un camino consistente y predecible.

2. No considere la reinicialización del proceso como la solución

Cuando un proceso de Node.js falla, la solución instintiva suele ser simplemente:

npm start

O, para equipos que utilizan PM2:

pm2 restart app

Esto permite volver a poner el servicio en línea lo suficientemente rápido.

Lo que no hace es abordar la causa real que provocó su fallo en un principio.

Si tu aplicación se cae cada seis horas debido a una fuga de memoria, reiniciarla según ese horario no constituye un sistema estable, sino solo una solución temporal.

Herramientas como PM2, Docker, Kubernetes o un entorno de ejecución en plataforma gestionada pueden reiniciar automáticamente un proceso caído, lo cual es realmente útil.

Con PM2, por ejemplo:

pm2 start app.js --name api
pm2 save

Pero un reinicio automático debe considerarse como control de daños, no como una solución al problema subyacente.

Aún necesitas respuestas a preguntas como:

Why did the process crash?
What request triggered it?
How much memory was being used?
What error occurred?
Was there an unhandled rejection?
Was the database unavailable?

Un reinicio restaura el servicio para tus usuarios.

Solo una buena capacidad de observabilidad te permite saber por qué se perdió el servicio en primer lugar.

Ambos elementos son necesarios.

3. Corrige las fugas de memoria antes de que causen caídas por falta de memoria

Node.js depende de la recolección de basura, pero eso no garantiza que la memoria se limpie correctamente por sí sola.

Es totalmente posible que su código mantenga referencias a objetos que ya no se necesitan en realidad.

Aquí hay una ilustración sencilla:

const cache = [];


app.get("/users", async (req, res) => {
  const users = await getUsers();


  cache.push(users);

  res.json(users);
});

Cada solicitud recibida agrega más datos al cache.

Dado que la aplicación sigue manteniendo una referencia a esas entradas, el recolector de basura no puede recuperarlas.

Con el tiempo, esto se ve así:

Memory usage
100 MB
200 MB
400 MB
800 MB
1.2 GB
2 GB
💥

Las cifras exactas variarán según su configuración y los límites establecidos, pero lo importante es el patrón subyacente.

Tenga cuidado con cosas como:

  • Arreglos globales que siguen creciendo
  • Cachés sin límite de tamaño
  • Objetos grandes que permanecen en memoria
  • Escuchadores de eventos que nunca se desvinculan
  • Timer que nunca se cancelan
  • Flujos abiertos
  • Datos grandes retenidos dentro de cierres
  • Colas sin límite máximo
  • Si su aplicación almacena datos en memoria, ese caché necesita una política explícita.

    Por ejemplo:

    Maximum entries: 10,000
    TTL: 10 minutes
    Eviction: LRU
    

    Un caché sin límites en realidad no funciona como tal.

    Es un problema de memoria que está a punto de ocurrir.

    4. Establecer tiempos de espera en las solicitudes externas

    Llamar a otro servicio sin establecer un tiempo de espera es una de las formas más rápidas de dejar su API colgada.

    Imagínese esta llamada:

    const response = await fetch("https://external-service.com/api");
    

    ¿Qué pasa si ese servicio externo de repente se vuelve muy lento para responder?

    Su propia solicitud podría terminar esperando mucho más tiempo del necesario.

    Ahora imagine que 1,000 usuarios concurrentes acceden al mismo endpoint.

    De repente tendrá cientos o miles de solicitudes atascadas, esperando a una dependencia que no responde.

    Ese retraso acumulado puede acabar sobrecargando también tu propio servidor.

    Siempre incluye un manejo explícito de tiempos de espera.

    Por ejemplo, utilizando AbortController:

    const controller = new AbortController();
    
    const timeout = setTimeout(() => {
      controller.abort();
    }, 5000);
    
    try {
      const response = await fetch(
        "https://external-service.com/api",
        {
          signal: controller.signal
        }
      );
    
      return await response.json();
    } finally {
      clearTimeout(timeout);
    }
    

    Ahora tu aplicación no permanecerá esperando indefinidamente.

    La regla general es sencilla:

    Nunca dejes que una dependencia externa dicte cuánto tiempo está dispuesto a esperar tu servidor, sin límite alguno.

    Esto es igualmente importante ya sea que hables con una pasarela de pagos, un servicio de envío de correos, un endpoint HTTP genérico, Redis, tu propia capa de base de datos, una API de terceros o otro microservicio interno en tu infraestructura.

    5. Solucionar el agotamiento de conexiones a la base de datos

    Una API de Node.js puede parecer completamente funcional por sí sola, mientras que el verdadero cuello de botella se encuentra en la capa de conexiones a la base de datos.

    Imagínese que su aplicación recibe las siguientes situaciones:

    500 concurrent requests
    

    y cada una de esas solicitudes intenta establecer su propia conexión a la base de datos.

    En algún momento, la base de datos alcanza el límite de conexiones configurado.

    A partir de ahí, las nuevas solicitudes comienzan a fallar completamente.

    La API podría responder con errores como:

    Too many connections
    Connection refused
    Connection timeout
    

    Un pool de conexiones existe precisamente para evitar que cada solicitud entrante cree una conexión completamente nueva por sí sola.

    Visualmente, se ve algo así:

    ┌── Connection 1
                  ├── Connection 2
    API ── Pool ──┼── Connection 3 ── Database
                  ├── Connection 4
                  └── Connection 5
    

    El pool actúa como un controlador, limitando la cantidad de conexiones que pueden estar abiertas a la base de datos en un momento dado.

    Pero dimensionar ese pool correctamente es tan importante como tenerlo.

    Configure el pool de forma deliberada.

    Siñálvelo demasiado pequeño:

    Requests
       ↓
    Waiting for connections
       ↓
    High latency
    

    Siñálvelo demasiado grande:

    Application
       ↓
    Too many connections
       ↓
    Database overloaded
    

    La cantidad adecuada depende de cuánta carga puede manejar realmente su base de datos, del grado de concurrencia de su aplicación, de cómo son sus consultas y de cómo está implementada su infraestructura.

    Haga lo que haga, no responda a los errores de conexión aumentando indefinidamente el tamaño máximo del pool de conexiones.

    6. Eliminar las consultas N+1

    Otra forma frecuente en la que los equipos dañan accidentalmente su base de datos es mediante el patrón de consultas N+1.

    Imagínese un endpoint que devuelve una lista de publicaciones:

    const posts = await Post.findAll();
    
    
    for (const post of posts) {
      post.author = await User.findByPk(post.authorId);
    }
    

    Con 1,000 publicaciones en la tabla, esto se convierte en:

    1 query → retrieve posts
    
    1,000 queries → retrieve authors
    
    Total: 1,001 queries
    

    Con diez publicaciones, el endpoint parece responder al instante y nadie nota nada anormal.

    Luego el conjunto de datos crece.

    Y ese mismo endpoint se vuelve extremadamente lento.

    Con el tiempo, puede agotar tanta capacidad de la base de datos que también afecte a endpoints no relacionados.

    Algunas soluciones comunes:

    JOINs

    SELECT
      posts.id,
      posts.title,
      users.name
    FROM posts
    JOIN users
      ON users.id = posts.author_id;
    

    Carga anticipada de ORM

    La mayoría de los ORMs ofrecen una forma de obtener los registros relacionados de antemano en una sola operación controlada, en lugar de realizar una consulta por fila.

    Consultas por lotes

    En lugar de enviar solicitudes una tras otra, como:

    Get user 1
    Get user 2
    Get user 3
    ...
    

    obtén a todos los usuarios necesarios en una sola consulta combinada.

    Durante las pruebas de carga, vale la pena hacer una pregunta diagnóstica sencilla:

    ¿Cuántas consultas separadas a la base de datos realiza este endpoint cuando devuelve 10,000 registros?

    Si la respuesta honesta es:

    "10,001."

    Eso es una señal clara de que algo necesita ser corregido.

    7. Agregar paginación antes de que la base de datos se vuelva enorme

    Este fragmento es una bomba de tiempo:

    const users = await User.findAll();
    
    res.json(users);
    

    Se comporta de manera excelente en el entorno de desarrollo, donde la tabla contiene:

    37 users
    

    Seis meses después, esa misma tabla ahora contiene:

    2,400,000 users
    

    Ese único endpoint sin cambios ahora puede:

    • Obtener millones de filas de la base de datos
    • Consumir recursos de la base de datos
    • Agotar la memoria del proceso Node.js
    • Serializar una gran carga JSON
    • Agotar el ancho de banda
    • Aumentar enormemente los tiempos de respuesta
    • Riesgo de alcanzar un tiempo de espera límite

    La solución es la paginación.

    Por ejemplo, aceptar una solicitud con este formato:

    GET /users?page=1&limit=50
    

    y tratarla de esta manera:

    const limit = Math.min(
      Number(req.query.limit) || 50,
      100
    );
    
    const page = Number(req.query.page) || 1;
    
    const offset = (page - 1) * limit;
    

    Igualmente importante es establecer un límite a lo que los clientes pueden solicitar.

    No permita que un cliente envíe:

    ?limit=100000000
    

    y obligue a su API a intentar ejecutar una consulta de ese tamaño.

    8. Deje de ejecutar operaciones independientes de forma secuencial

    Supongamos que un endpoint necesita recopilar tres datos:

    User
    Settings
    Notifications
    

    Una implementación ingenua podría verse así:

    const user = await getUser();
    const settings = await getSettings();
    const notifications = await getNotifications();
    

    Si cada llamada tarda 200 ms, esperarlas una tras otra suma aproximadamente:

    200 + 200 + 200 = 600 ms
    

    Cuando estas llamadas no dependen unas de otras, ejecútelas de forma concurrente:

    const [
      user,
      settings,
      notifications
    ] = await Promise.all([
      getUser(),
      getSettings(),
      getNotifications()
    ]);
    

    Ahora las tres solicitudes se realizan al mismo tiempo.

    El tiempo total disminuye hasta acercarse al de la llamada más lenta:

    max(200, 200, 200) ≈ 200 ms
    

    Esto no se trata solo de reducir milisegundos en la respuesta.

    A gran escala, una latencia adicional por solicitud significa que las solicitudes ocupan los recursos del servidor más tiempo del necesario.

    Las solicitudes que terminan más rápido liberan los recursos también con mayor rapidez.

    No obstante, no recurra automáticamente a Promise.all en todos los casos.

    Si una llamada realmente necesita el resultado de otra, como en:

    const user = await getUser();
    const orders = await getOrders(user.id);
    

    entonces esas llamadas deben realizarse de forma secuencial por naturaleza.

    9. Agregar un apagado elegante

    Una de las formas más simples de generar tiempos de inactividad innecesarios durante un despliegue es detener el proceso abruptamente.

    Considere esta secuencia:

    User → POST /checkout
                  ↓
           Processing payment
                  ↓
             Deployment starts
                  ↓
              Process killed
    

    En ese escenario, el usuario podría ver simplemente:

    Connection reset
    

    aunque la operación subyacente ya podría haber avanzado parcialmente.

    El apagado elegante resuelve esto al hacer que el servidor deje de aceptar nuevas conexiones mientras permite que las solicitudes en curso finalicen de forma natural.

    Una versión mínima se ve así:

    const server = app.listen(PORT);
    
    process.on("SIGTERM", () => {
      console.log("SIGTERM received");
    
    server.close(() => {
        console.log("HTTP server closed");
    
        process.exit(0);
      });
    });
    

    En un sistema de producción, apagar de forma elegante a menudo también implica cerrar:

    • Pools de conexiones a la base de datos
    • Conexiones a Redis
    • Consumidores de colas de mensajes
  • Procesos en segundo plano
  • Conexiones WebSocket abiertas
  • Cualquier otro recurso en uso
  • El objetivo general se resume en:

    Stop receiving new work
            ↓
    Finish current work
            ↓
    Close resources
            ↓
    Exit
    

    Un despliegue no debería sentirse como si alguien hubiera sacado el cable de alimentación de la pared.

    10. Utilizar verificaciones de estado y disponibilidad

    Es completamente posible que un proceso Node.js siga activo mientras la aplicación detrás de él haya dejado de funcionar efectivamente.

    Imagínese este estado:

    Node.js process → Running
    Database → DOWN
    API → Practically unusable
    

    Si la única señal de su balanceador de carga es si un puerto TCP responde, podría seguir dirigiendo tráfico a una instancia que en realidad no puede ofrecer nada útil.

    Un endpoint de disponibilidad le proporciona una señal real para verificar:

    app.get("/ready", async (req, res) => {
      try {
        await db.query("SELECT 1");
    
       res.status(200).json({
          status: "ready"
        });
      } catch (error) {
        res.status(503).json({
          status: "not_ready"
        });
      }
    });
    

    Esto permite que su infraestructura distinga entre dos condiciones muy diferentes:

    Process is alive
    

    versus:

    Process is ready to serve traffic
    

    Esa diferencia es muy importante cuando se despliega nuevo código o se recupera de una falla.

    11. Proteger el servidor de picos de tráfico

    Supongamos que su servicio puede manejar cómodamente:

    100 requests/second
    

    Luego su producto se comparte en algún lugar con una gran audiencia.

    De repente se enfrenta a:

    2,000 requests/second
    

    Sin medidas de protección, ese aumento repentino puede abrumar por completo la API.

    Algunas formas comunes de absorber picos de tráfico:

    • Limitar la cantidad de solicitudes que un cliente puede realizar en un período determinado
    • Colocar un proxy inverso delante de los servidores de aplicaciones
    • Distribuir el tráfico entrante entre múltiples instancias
    • Cachear respuestas para no repetir trabajos
    • Servir contenido estático a través de un CDN
    • Posponer tareas en una cola en lugar de procesarlas directamente
  • Límite en la cantidad de conexiones a la base de datos simultáneas permitidas
  • Escalar automáticamente el número de instancias a medida que aumenta la demanda
  • Eliminar las solicitudes que tardan demasiado en completarse
  • No es necesario contar con cada elemento de esta lista.

    Pero cualquier sistema en producción debe tener una respuesta clara a la siguiente pregunta:

    ¿Qué sucede si el tráfico aumenta de repente diez veces?

    12. Sacar las tareas costosas fuera de la solicitud HTTP

    Tomemos un punto de extremo que crea un usuario y envía un correo de bienvenida en la misma solicitud:

    await createUser();
    await sendWelcomeEmail();
    res.json({ success: true });
    

    Si el servicio de correo tarda tres segundos en responder, eso significa que el usuario pasa tres segundos esperando.

    Más aún, un fallo en el paso del correo puede hacer que toda la solicitud falle, aunque la cuenta de usuario se haya creado con éxito.

    Un diseño basado en colas evita este acoplamiento:

    HTTP Request
         ↓
    Create User
         ↓
    Return response
         ↓
    Queue email job
         ↓
    Background Worker
         ↓
    Send Email
    

    La API ya no tiene que esperar a que se completen las tareas en segundo plano lentas.

    Este patrón es especialmente útil en situaciones como:

    • Enviar correos de confirmación o bienvenida
    • Generar informes bajo demanda
    • Redimensionar o transformar imágenes subidas
    • Codificar o transcodificar video
    • Enviar notificaciones push
    • Generar exportaciones masivas de datos
    • Tratar los payloads de webhook recibidos
    • Ejecutar cálculos numéricos o analíticos intensivos

    Como regla general, el manejador de solicitudes HTTP debe realizar solo la cantidad mínima de trabajo necesaria para generar una respuesta.

    13. Hacer que las tareas en segundo plano se intenten nuevamente de forma segura

    Al colocar el trabajo en una cola surge una nueva pregunta:

    ¿Qué sucede si el propio proceso de trabajo falla a mitad de camino?

    Tenga en cuenta este flujo:

    Worker receives payment notification
            ↓
    Starts processing
            ↓
    Crashes
    

    Es probable que el sistema de colas intente ejecutar la tarea automáticamente nuevamente.

    En general, eso es algo bueno.

    Pero si la tarea no es idempotente, intentar ejecutarla de nuevo puede provocar los mismos efectos secundarios una segunda vez.

    Por ejemplo:

    Send payment
    Send payment again
    

    o:

    Create order
    Create duplicate order
    

    Por esta razón, las tareas en segundo plano deben diseñarse con:

    • Intentos de reintentar
    • Retraso entre intentos
    • Gestión de mensajes no entregados
    • Idempotencia
    • Número máximo de intentos
    • Monitoreo

    Sin idempotencia, un mecanismo de reintentos puede convertir un único fallo en varios efectos secundarios no deseados.

    14. Deje de registrar información sensible

    Mientras se busca un error en producción, es tentador registrar todo lo que se pueda obtener.

    Un ejemplo común:

    console.log(req.body);
    

    Ese cuerpo de solicitud podría incluir fácilmente cosas como:

    Password
    Access token
    Credit-card information
    Personal information
    

    Una vez que eso ocurre, su sistema de registro ahora almacena datos sensibles.

    Un enfoque más seguro es registrar solo los campos que realmente necesita:

    logger.info({
      userId,
      requestId,
      endpoint: req.path
    }, "Login attempt");
    

    No caiga en la trampa de pensar:

    "Solo está en los registros."

    Los registros suelen persistir durante semanas, meses o incluso más tiempo, y a menudo se copian a lugares como:

    • Sistemas de registro en la nube
    • Plataformas de monitoreo
    • Sistemas SIEM
    • Almacenamiento de respaldo
    • Panels de control para desarrolladores

    Depurar un problema en producción no debería terminar creando un incidente de seguridad separado.

    15. Agregue IDs de solicitud antes de necesitarlos

    Cuando algo falla en producción, el problema a menudo afecta a varios sistemas:

    Client
      ↓
    Nginx
      ↓
    Node.js API
      ↓
    Redis
      ↓
    Database
      ↓
    Payment API
    

    Sin alguna forma de vincular estos eventos, reconstruir lo que sucedió puede ser un proceso lento y complicado.

    La solución es generar un ID de solicitud temprano:

    request_id = 7f4e9c21
    

    y luego adjuntarlo a cada entrada de registro o respuesta relevante:

    API log
    Database-related log
    Payment log
    Error response
    

    Por ejemplo:

    {
      "requestId": "7f4e9c21",
      "method": "POST",
      "path": "/orders",
      "status": 500
    }
    

    Con esto en su lugar, se puede rastrear una solicitud a medida que atraviesa todo el sistema.

    Así, cuando un cliente informa:

    "Mi pedido falló."

    puede simplemente solicitar su ID de solicitud en lugar de buscar entre millones de líneas de registro intentando coincidir las marcas de tiempo.

    16. Evitar que las solicitudes duplicadas creen datos duplicados

    Los problemas de red ocurren constantemente, y los clientes necesitan manejarlos.

    Imagínese esta secuencia:

    Client
      ↓
    POST /orders
      ↓
    Server creates order
      ↓
    Response lost
    

    El cliente no tiene forma de saber si la solicitud tuvo éxito realmente.

    Por lo tanto, naturalmente, vuelve a intentarlo:

    POST /orders
      ↓
    Server creates another order
    

    El resultado:

    Order #1001 → original request
    Order #1002 → retry
    

    Este es exactamente el escenario para el cual están diseñadas las claves de idempotencia.

    El cliente adjunta un encabezado como:

    Idempotency-Key: 5e7b2d91-...
    

    El servidor recuerda el resultado asociado a esa clave. Si aparece la misma clave nuevamente, simplemente devuelve el resultado original en lugar de crear un segundo recurso.

    Esto es especialmente importante para operaciones como:

    Payments
    Orders
    Subscriptions
    Account creation
    Resource provisioning
    

    Cualquier API de producción debe diseñarse bajo la premisa de que los clientes volverán a intentar las solicitudes, a veces más de una vez.

    17. Utilice transacciones cuando los datos deben mantenerse consistentes

    Piense en una secuencia como esta:

    Create order
    Decrease inventory
    Create payment record
    

    Ahora imagine que uno de estos pasos falla a mitad de camino:

    Create order → SUCCESS
    Decrease inventory → SUCCESS
    Create payment record → FAIL
    

    Su base de datos ahora contiene una operación incompleta.

    Cada vez que varias escrituras en la base de datos deben completarse como una unidad, envuélvalas en una transacción.

    La idea es más o menos la siguiente:

    await db.transaction(async (transaction) => {
      const order = await createOrder(transaction);
    
      await decreaseInventory(transaction);
    
      await createPaymentRecord(transaction);
    });
    

    Y si algún paso genera un error:

    Rollback
    

    todo se revierte, en lugar de dejar registros inconsistentes.

    Las transacciones no resolverán todos los problemas que se presenten en un sistema distribuido, pero dentro de una sola base de datos son una de las herramientas más fiables para mantener la consistencia de los datos.

    18. No permita que una consulta lenta arruine la base de datos

    Una sola consulta mal escrita puede consumir una cantidad desproporcionada de capacidad de la base de datos.

    Tome este ejemplo:

    SELECT *
    FROM orders
    WHERE customer_id = ?
    ORDER BY created_at DESC;
    

    Si customer_id no tiene índice y la tabla contiene millones de filas, esta consulta puede obligar a la base de datos a realizar mucho más trabajo del necesario.

    Use

    EXPLAIN
    

    para inspeccionar las consultas importantes.

    Preste atención a señales de advertencia como:

    Full table scan
    Huge number of rows examined
    Unexpected joins
    Poor index usage
    Expensive sorting
    

    Luego base su optimización en lo que realmente demuestran las pruebas.

    No confíe en conjeturas.

    Una consulta que devuelve resultados en 50 milisegundos localmente puede tardar varios segundos al ejecutarse con datos a escala de producción.

    19. Establecer límites de recursos

    Un servidor de producción necesita límites.

    Sin ellos, una sola solicitud maliciosa o mal formada, o un proceso desbocado, puede consumir todos los recursos a su disposición.

    Considere establecer límites en aspectos como:

    Request body size
    File upload size
    Database connections
    HTTP request duration
    Cache size
    Queue size
    Worker concurrency
    Pagination size
    Memory usage
    

    Por ejemplo:

    app.use(express.json({
      limit: "1mb"
    }));
    

    y:

    const limit = Math.min(
      Number(req.query.limit) || 50,
      100
    );
    

    No existe un número universal que se aplique en todos los casos.

    Pero el principio subyacente siempre es válido.

    Cada recurso debe tener un límite razonable.

    Dejar algo sin límites rara vez es una opción sensata en entornos de producción.

    20. Monitorea antes del incidente, no después

    No podrás evitar todos los cortes de servicio que ocurran.

    Lo que sí puedes controlar es la rapidez con la que los detectas y con qué celeridad te recuperas.

    Como mínimo, vigila:

    HTTP 5xx rate
    Request latency
    CPU
    Memory
    Database connections
    Database latency
    Event-loop health
    Queue depth
    External API failures
    Restart count
    

    Por ejemplo:

    09:00 → 0.2% errors
    09:10 → 0.3%
    09:20 → 0.5%
    09:30 → 4.8% 🚨
    

    Si nadie supervisa estas cifras, probablemente solo te enterarás de un problema una vez que los usuarios comiencen a quejarse.

    Las alertas eficaces cambian la situación de:

    "Los clientes nos dicen que la API está caída."

    a:

    “Nuestra tasa de errores 5xx superó el umbral hace cinco minutos.”

    Esa diferencia en el tiempo de respuesta marca una gran diferencia.

    El patrón detrás de la mayoría de estas soluciones

    Al analizar todas estas soluciones, surge un tema común.

    Los sistemas Node.js fiables no operan bajo la suposición de que todo saldrá bien.

    En lugar de eso, se diseñan asumiendo que:

    Requests will fail.
    Databases will become slow.
    Networks will timeout.
    Clients will retry.
    Dependencies will go offline.
    Traffic will spike.
    Deployments will restart processes.
    Developers will make mistakes.
    

    Y se implementan medidas de protección alrededor de cada una de esas suposiciones.

    La arquitectura resultante suele verse más o menos así:

                        ┌───────────────┐
                        │     Client    │
                        └───────┬───────┘
                                │
                                ▼
                        ┌───────────────┐
                        │ Reverse Proxy │
                        │ Rate Limiting │
                        └───────┬───────┘
                                │
                                ▼
                      ┌───────────────────┐
                      │    Node.js API    │
                      │                   │
                      │ Validation        │
                      │ Timeouts          │
                      │ Error Handling    │
                      │ Request IDs       │
                      └───────┬───────────┘
                              │
                 ┌────────────┼────────────┐
                 ▼            ▼            ▼
            ┌────────┐   ┌────────┐   ┌─────────┐
            │Database│   │ Redis  │   │ Queue   │
            └────────┘   └────────┘   └────┬────┘
                                          │
                                          ▼
                                   ┌──────────────┐
                                   │Worker / Jobs │
                                   └──────────────┘
    

    Cada capa tiene una función clara.

    Cada dependencia externa cuenta con una estrategia definida para casos de fallo.

    Cada operación costosa está limitada por algún umbral.

    Cada acción significativa puede ser observada y medida.

    Esa combinación es lo que confiere resiliencia al sistema.

    La lección más importante

    Los mayores avances en fiabilidad no siempre provienen de añadir más infraestructura.

    A veces la solución es cambiar una sola línea de código.

    Otras veces consiste en agregar un índice que falta.

    A veces se trata de incluir un límite de tiempo en las llamadas a una API externa.

    Otras veces se trata de convertir 1,001 consultas a la base de datos en dos.

    A veces se trata de encapsular un conjunto de escrituras en una transacción.

    Otras veces basta con asegurarse de que el proceso se cierre correctamente.

    Lo que diferencia una API frágil de una fiable suele reducirse a una serie de pequeñas decisiones técnicas.

    Ninguna de ellas por sí sola parece especialmente notable.

    Pero juntas son lo que impide que un día normal en producción se convierta en una emergencia.

    Así que la próxima vez que su API de Node.js funcione exactamente como se espera en el entorno de desarrollo, no se limite a preguntar:

    "¿Funciona?"

    Pregunte en su lugar:

    "¿Qué ocurre cuando no funciona?"

    Esa pregunta marca el verdadero punto de partida para la fiabilidad en producción.

    Y el mejor tipo de incidente por tiempo de inactividad es aquel que sus usuarios nunca notan, porque usted detectó y solucionó la falla antes de que se convirtiera en una interrupción del servicio.

    Lecturas relacionadas

  • Comprendiendo las claves de idempotencia en los endpoints POST de Node.js — Explica por qué las solicitudes POST fallan de manera impredecible al intentarse nuevamente y cómo las claves de idempotencia generadas por el cliente permiten a las APIs de Node.js manejar de forma segura las solicitudes duplicadas.
  • Refuerzo de la seguridad en Node.js: ataques DoS, inyección y defensa de contenedores — Aprenda técnicas prácticas para proteger los servicios de Node.js contra ataques DoS en el bucle de eventos, ReDoS, ataques de inyección y contenedores inseguros, utilizando limitación de tasas y Helmet.
  • Construyendo un manejo de errores de nivel producción en aplicaciones Node.js — Aprenda cómo clasificar los errores en Node.js, diseñar una jerarquía de errores personalizada, centralizar el manejo asincrónico de errores y proteger las trazas de ejecución para garantizar la resiliencia en entornos de producción.
  • Encontrando el verdadero cuello de botella en un endpoint Node.js lento — Aprenda un método sistemático para rastrear la latencia del backend a lo largo de la ruta de la solicitud, desde el código Node.js hasta las consultas a la base de datos, utilizando mediciones de tiempo y comandos EXPLAIN ANALYZE.