Inicio / Artículos

Por qué el código de backend que funciona localmente falla bajo la carga real de producción.

Un resumen práctico de las suposiciones relacionadas con el entorno, la base de datos, la seguridad y la fiabilidad que funcionan sin problemas en localhost pero causan interrupciones cuando llega el tráfico real a producción.

Editor: REACTAPP.TOP39 min de lectura8945 palabras

Fuentes: 10 Backend Mistakes That Work Perfectly on localhost but Break in Production

Funciona perfectamente en tu máquina. Pero, ¿resistirá cuando reciba tráfico real? Aquí hay 10 errores en el backend que pueden convertir silenciosamente una aplicación que funciona sin problemas en un desastre en producción.

A nivel local, todo parece estar bien. Las solicitudes llegan rápidamente. Las consultas devuelven los datos correctos. El inicio de sesión funciona. Docker se inicia sin problemas. Las pruebas pasan. Luego despliegas la aplicación, abres la URL en vivo y algo falla.

A veces la aplicación se niega a arrancar.

A veces los usuarios quedan bloqueados de sus cuentas.

A veces la base de datos comienza a funcionar muy lentamente sin motivo aparente.

A veces todo funciona bien durante unos minutos antes de que comiencen a acumularse errores de conexión.

Y a veces el servicio parece perfectamente normal en la superficie, mientras causa daños ocultos por debajo.

Lo que hace esto tan frustrante es que la mayoría de estos fallos no tienen nada que ver con algoritmos complicados.

Proviene de suposiciones pequeñas y no verificadas.

Suposiciones como:

  • "No tendremos tanto tráfico."
  • "La base de datos está en esta misma máquina."
  • "Esta variable de entorno siempre estará configurada."
  • "Una sola conexión a la base de datos es suficiente."
  • "Las solicitudes siempre se completarán rápidamente."
  • "Los archivos guardados en el disco seguirán allí más tarde."
  • "Nadie sobrecargará este endpoint."
  • "La API solo recibirá datos válidos."
  • "El contenedor nunca tendrá poca memoria."
  • "Un proceso en ejecución significa una aplicación sana."

Tu configuración local te protege de la mayoría de estos problemas.

La producción no ofrece tal protección.

Este artículo analiza 10 errores en el backend que suelen funcionar sin problemas en localhost pero causan daños reales una vez desplegados.

Ninguno de ellos es abstracto o hipotético.

Se trata de atajos que parecen perfectamente razonables mientras te concentras en desarrollar una función.

Y precisamente eso los hace riesgosos.

1. Tratar las variables de entorno como una configuración opcional

Comencemos con uno de los errores más frecuentes.

Imaginemos que estás desarrollando una API en tu ordenador portátil.

La configuración de conexión a la base de datos se ve así:

DATABASE_URL=postgresql://postgres:password@localhost:5432/myapp
JWT_SECRET=my-super-secret-key
PORT=5000

La aplicación arranca sin problemas.

Nada parece estar mal.

La envías a producción.

Y entonces, de repente:

Error: password authentication failed

O quizás:

JWT_SECRET is undefined

O:

ECONNREFUSED 127.0.0.1:5432

Tu primer impulso podría ser culpar a la plataforma de hosting.

Rara vez esa es la causa real.

Lo que realmente ocurre es que tu aplicación dependía silenciosamente de configuraciones que solo existían en tu máquina de desarrollo.

La trampa de localhost

A nivel local, podrías tener algo como esto:

DATABASE_URL=postgresql://localhost/myapp

Mientras que en producción está configurado así:

DATABASE_URL=postgresql://db.internal:5432/myapp

Estas son dos configuraciones completamente diferentes.

La situación empeora cuando los desarrolladores integran directamente la configuración en el código fuente:

const dbHost = "localhost";
const dbUser = "postgres";
const dbPassword = "password";

Esto funciona perfectamente.

Hasta que de repente deja de funcionar.

La producción no es tu ordenador portátil.

La base de datos podría estar en:

  • un servidor separado
  • un contenedor diferente
  • un proveedor de bases de datos gestionadas
  • una red interna privada
  • una zona de disponibilidad distinta
  • un proveedor de nube completamente diferente

Tu aplicación necesita una forma de descubrir dónde se encuentra realmente esa base de datos.

La configuración debe provenir del entorno

Un patrón más resistente se ve así:

const config = {
  databaseUrl: process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET,
  port: process.env.PORT || 5000
};

Pero aún hay un error más sutil oculto aquí.

Solo obtener valores de las variables de entorno no es suficiente por sí solo.

También es necesario verificar que realmente estén presentes.

Por ejemplo:

const requiredEnv = [
  "DATABASE_URL",  "JWT_SECRET"
];
for (const key of requiredEnv) {
  if (!process.env[key]) {
    throw new Error(`Missing required environment variable: ${key}`);
  }
}

Ahora la aplicación se niega a iniciarse si falta un parámetro necesario.

Ese tipo de fallo temprano en realidad es algo positivo.

Compárelo con un escenario en el que la aplicación arranca normalmente y solo falla en el momento en que el primer usuario intenta iniciar sesión.

Ver algo como:

Missing required environment variable: JWT_SECRET

durante el despliegue es mucho mejor que enterarse de ello a través de un ticket de soporte frustrado.

No proporcione valores predeterminados peligrosos en silencio

Este atajo es tentador:

const jwtSecret = process.env.JWT_SECRET || "secret";

Facilita el desarrollo local.

También abre la puerta a una vulnerabilidad de seguridad.

Supongamos que olvida establecer JWT_SECRET en su entorno de producción.

La aplicación no se caerá.

Simplemente recurrirá silenciosamente a:

secret

Ahora su sistema en producción está firmando tokens con un valor que cualquiera podría adivinar.

Una versión más segura sería esta:

if (!process.env.JWT_SECRET) {
  throw new Error("JWT_SECRET must be configured");
}

Los valores de fallback tienen sentido para configuraciones inofensivas.

Son un riesgo para cualquier cosa relacionada con la seguridad.

La validación de la configuración debe realizarse al iniciar

Un patrón que vale la pena adoptar es tratar su configuración casi como una dependencia obligatoria, una que debe cumplirse antes de que se ejecute cualquier otra cosa.

Por ejemplo:

function loadConfig() {
  if (!process.env.DATABASE_URL) {
    throw new Error("DATABASE_URL is required");
  }
  if (!process.env.JWT_SECRET) {
    throw new Error("JWT_SECRET is required");
  }
  return {
    databaseUrl: process.env.DATABASE_URL,
    jwtSecret: process.env.JWT_SECRET,
    port: Number(process.env.PORT || 5000)
  };
}
const config = loadConfig();

Esto le brinda a su aplicación un contrato claro con el que trabajar.

O bien la configuración es válida, o el proceso nunca comienza.

Ese compromiso realmente vale la pena.

2. Apuntar a localhost para servicios que se encuentran fuera de tu aplicación

Este error merece atención especial porque aparece constantemente una vez que el código abandona la máquina del desarrollador.

Supongamos que tu backend se comunica con PostgreSQL.

En tu portátil, la configuración es así:

Backend → localhost:5432 → PostgreSQL

Y funciona sin problemas.

Luego contenerizas el backend con Docker.

PostgreSQL ahora corre en un contenedor separado.

Podrías dejar la configuración sin cambios:

DATABASE_HOST=localhost

Y entonces ocurre lo siguiente:

connection refused

¿Qué está pasando?

localhost ya no se refiere a lo que creías que hacía.

Dentro de un contenedor:

localhost

se refiere a:

este contenedor específico.

No a:

el contenedor que ejecuta tu base de datos.

Malentender esta única distinción explica una gran parte de los problemas en la implementación de Docker.

La red de Docker requiere un modelo mental diferente

Imagínese tres contenedores separados:

backend
database
redis

Puede definirlos de esta manera:

services:
  backend:
    build: .
    depends_on:
      - database
      - redis
  database:
    image: postgres
  redis:
    image: redis

Desde el interior del contenedor backend, no se puede acceder directamente a:

localhost:5432

En su lugar, se dirige al otro contenedor por su nombre de servicio:

database:5432

La misma lógica se aplica a Redis:

redis:6379

La topología de red resultante se ve así:

backend
   |
   +---- database:5432
   |
   +---- redis:6379

Al principio esto parece extraño, ya que las configuraciones locales suelen ejecutar todo directamente en la máquina host. En entornos de producción existen verdaderas barreras de red entre los componentes.

Los servicios en la nube introducen la misma trampa

Localmente, su aplicación podría acceder a Redis en:

localhost:6379

Pero en producción, Redis se encuentra en un servicio gestionado en otro lugar completamente distinto.

La configuración adecuada allí podría ser:

REDIS_URL=redis://redis-production.example.com:6379

Su aplicación no debería necesitar conocer la diferencia.

Esa es la idea clave.

Escriba su código para consumir la configuración de esta manera:

createRedisClient({
  url: process.env.REDIS_URL
});

En lugar de codificar manualmente los detalles de conexión de esta forma:

createRedisClient({
  host: "localhost",
  port: 6379
});

Mantener la estructura de la configuración consistente en todos los entornos

Los valores cambiarán entre entornos.

La estructura no debería hacerlo.

Por ejemplo, en el entorno de desarrollo:

DATABASE_URL=postgresql://localhost:5432/myapp
REDIS_URL=redis://localhost:6379

Y en producción:

DATABASE_URL=postgresql://production-db:5432/myapp
REDIS_URL=redis://production-redis:6379

Misma base de código.

Valores diferentes según el entorno.

Esa consistencia es exactamente lo que se busca.

3. Abrir una nueva conexión a la base de datos en cada solicitud

Este error en particular puede permanecer oculto a simple vista durante mucho tiempo antes de causar problemas.

Escribes una ruta de esta manera:

app.get("/users", async (req, res) => {
  const client = new Client({
    connectionString: process.env.DATABASE_URL
  });
  await client.connect();
  const result = await client.query("SELECT * FROM users");
  await client.end();
  res.json(result.rows);
});

Funciona sin problemas.

La pruebas manualmente.

Las respuestas llegan rápidamente.

La lanzas al producción.

Luego llega el tráfico real.

Con el tiempo comienzas a ver errores como:

too many connections

o:

remaining connection slots are reserved

o notas que el uso de CPU de tu base de datos aumenta constantemente.

¿Cuál es la causa?

Cada solicitud entrante abre una conexión a la base de datos completamente nueva.

Con 100 solicitudes simultáneas, eso podría significar 100 conexiones separadas.

Con 1,000 solicitudes, la situación empeora aún más.

Crear conexiones es costoso

Establecer una conexión no es gratis. Dependiendo de tu base de datos y configuración, puede implicar:

  • configurar la conexión de red
  • autenticación
  • negociación de TLS
  • inicialización de la sesión
  • asignación de recursos
  • iniciación de un proceso o hilo en el lado de la base de datos
  • Repetir todo eso para cada solicitud desperdicia recursos. Por eso las aplicaciones suelen recurrir en su lugar a un pool de conexiones.

    Con PostgreSQL, esto se ve así:

    const { Pool } = require("pg");
    const pool = new Pool({
      connectionString: process.env.DATABASE_URL,
      max: 10
    });
    

    Y entonces tu ruta queda así:

    app.get("/users", async (req, res) => {
      const result = await pool.query(
        "SELECT id, name, email FROM users"
      );
      res.json(result.rows);
    });
    

    El pool mantiene un conjunto de conexiones reutilizables listas para usar.

    Visualmente, se ve así:

    ┌── connection 1
    Request ────────┼── connection 2
                    ├── connection 3
                    ├── connection 4
                    └── connection 5
    

    En lugar de esto:

    Request 1 → new connection
    Request 2 → new connection
    Request 3 → new connection
    Request 4 → new connection
    

    Los pools aún pueden causar problemas a gran escala

    Incluso con el uso de pools, sigue siendo posible una mala configuración.

    Imaginemos que ejecutas 10 instancias de backend, y cada una establece:

    pool.max = 20
    

    Eso suma un potencial problema de:

    10 × 20 = 200
    

    conexiones. Si su base de datos limita las conexiones a 100, ya está por encima del presupuesto, aunque la configuración del pool de cada instancia individual parezca razonable por sí sola.

    Este es exactamente el tipo de problema que nunca se manifiesta en la máquina de un solo desarrollador.

    A nivel local, podría observar:

    1 application × 10 connections = 10
    

    En producción:

    10 applications × 20 connections = 200
    

    Escalar horizontalmente su aplicación puede multiplicar silenciosamente la carga que ejerce sobre la base de datos.

    El tamaño del pool es un problema a nivel de sistema

    Al determinar cuán grande debe ser el pool, necesita tener en cuenta:

    database connection limit
            ↓
    number of application instances
            ↓
    pool size per instance
            ↓
    background workers
            ↓
    admin/monitoring connections
    

    Esto no es algo que se elija arbitrariamente.

    4. Devolver todas las columnas de la base de datos al cliente

    Este error en particular rara vez hace que su aplicación se caiga de inmediato.

    Es precisamente por eso que es arriesgado.

    Imagínese una tabla de usuarios con estos campos:

    id
    name
    email
    password_hash
    phone
    address
    created_at
    updated_at
    internal_notes
    

    Escribe una consulta como esta:

    SELECT * FROM users;
    

    Y luego pasas directamente las filas a la respuesta:

    res.json(result.rows);
    

    Funciona. El frontend recibe su objeto de usuario. Puedes seguir adelante.

    Pero en realidad has introducido varios problemas al mismo tiempo.

    Primero: exposición de datos

    La respuesta podría verse así:

    {
      "id": 12,
      "name": "John",
      "email": "john@example.com",
      "password_hash": "...",
      "internal_notes": "..."
    }
    

    Incluso si el campo de contraseña está hashado en lugar de en texto plano, no hay razón para que llegue nunca al cliente. Además, todos los campos destinados únicamente al uso interno que hayas olvidado también se envían junto con ella.

    Segundo: ancho de banda

    Supongamos que cada registro pesa 2 KB. Para mil registros, eso significa:

    2 KB × 1,000 = 2 MB
    

    Imagina ahora que la tabla se vuelve más grande con el tiempo. Podrías terminar sirviendo:

    20 fields
    

    cuando la interfaz de usuario realmente solo consume:

    id
    name
    avatar
    

    La base de datos no tiene idea de lo que realmente necesita tu frontend; esa decisión corresponde a la capa API.

    Así que en lugar de:

    SELECT * FROM users;
    

    deberías escribir:

    SELECT
      id,
      name,
      avatar_url
    FROM users;
    

    Ahora la carga contiene únicamente lo que necesita ese endpoint en particular.

    Esto se vuelve aún más importante con las uniones

    Considera una consulta como esta:

    SELECT *
    FROM users
    JOIN orders ON orders.user_id = users.id
    JOIN payments ON payments.order_id = orders.id;
    

    Esto puede recuperar una cantidad masiva de datos que nunca pretendiste enviar a ningún lugar. Es un atajo tentador mientras aún estás desarrollando las cosas, con el razonamiento algo así como:

    "Simplemente devolveré todo y dejaré que el frontend elija lo que necesita."

    Resiste ese impulso.

    La estructura de la respuesta de una API debe ser intencional. Por ejemplo:

    const users = result.rows.map(user => ({
      id: user.id,
      name: user.name,
      avatar: user.avatar_url
    }));
    

    O, aún mejor, transfiere esta responsabilidad a una capa DTO o de serialización dedicada.

    El contrato de la API es importante

    Una API de nivel producción debe poder responder a:

    ¿Qué promete devolver este endpoint?

    No a:

    ¿Qué columnas existen en la tabla subyacente?

    Esa distinción se vuelve aún más crucial a medida que crece la base de código.

    5. Ignorar los índices de la base de datos hasta que la producción se vuelve lenta

    Este podría ser uno de los ejemplos más claros de algo que parece perfectamente normal localmente pero que silenciosamente se convierte en un problema en producción.

    Tomemos una consulta como esta:

    SELECT * FROM users
    WHERE email = 'john@example.com';
    

    Mientras que tu base de datos de desarrollo tiene:

    500 users
    

    la consulta se devuelve al instante.

    La lanzas al producción. Pasan seis meses y la tabla ahora contiene:

    5 million users
    

    De repente, esa misma consulta puede volverse drásticamente más lenta, ya que la base de datos podría necesitar escanear una gran parte de la tabla para encontrar una coincidencia.

    La reacción típica es:

    "¡Pero esta consulta antes era rápida!"

    Claro, porque en ese entonces la tabla era muy pequeña.

    Los índices existen por esta razón

    Agregar algo como:

    CREATE INDEX idx_users_email
    ON users(email);
    

    permite a PostgreSQL localizar las filas coincidentes de manera mucho más eficiente que un escaneo completo de la tabla.

    No obstante, la conclusión aquí no es:

    Simplemente indexar todo.

    Eso es un error en sí mismo. Los índices no son gratuitos; ellos:

    • ocupan espacio en disco
    • ralentizan las escrituras
    • requieren mantenimiento continuo
    • no ayudan con todo tipo de consultas
    • no vale la pena agregarlos a todas las columnas

    La pregunta que se debe hacer en su lugar es:

    ¿Qué consultas ejecuta realmente esta aplicación en la práctica?

    Índice basado en patrones de acceso

    Si su aplicación ejecuta regularmente algo como:

    SELECT *
    FROM orders
    WHERE user_id = 123
    ORDER BY created_at DESC
    LIMIT 20;
    

    un índice compuesto de esta forma podría ser útil:

    CREATE INDEX idx_orders_user_created
    ON orders(user_id, created_at DESC);
    

    La elección adecuada del índice siempre depende de su motor de base de datos específico, de cómo están distribuidos los datos, del comportamiento del planificador de consultas y de su carga de trabajo real; esa es precisamente la razón por la cual indexar por conjeturas no funciona.

    Use EXPLAIN

    Una de las herramientas de diagnóstico más valiosas en PostgreSQL es:

    EXPLAIN ANALYZE
    SELECT *
    FROM orders
    WHERE user_id = 123
    ORDER BY created_at DESC
    LIMIT 20;
    

    Le muestra exactamente cómo ejecutó la base de datos una consulta dada, reemplazando suposiciones como:

    Esta consulta debería ejecutarse rápidamente

    por evidencia real:

    Esto es lo que realmente está haciendo la base de datos

    Ese cambio de mentalidad es de suma importancia.

    Los datos locales pueden ser engañosos

    He aquí una regla que vale la pena internalizar: el rendimiento medido con un pequeño conjunto de datos local le dice muy poco sobre cómo se comportará la misma consulta en producción.

    Una consulta que se completa en:

    5 ms
    

    con 1,000 filas puede comportarse de manera completamente diferente una vez que se trabaja con:

    10 million rows
    

    Para probar el rendimiento de manera significativa se necesitan volúmenes de datos que se asemejen a los que realmente se verán en producción.

    6. Creer que todo lo que se guarda en el disco permanecerá allí

    Este problema suele surgir con las subidas de archivos.

    En su propia máquina, podría escribir algo como:

    app.post("/upload", upload.single("file"), (req, res) => {
      console.log(req.file.path);
    });
    

    El archivo subido termina en algún lugar como:

    /uploads/photo.jpg
    

    Vuelve a cargar la aplicación.

    El archivo está exactamente donde lo dejó.

    Parece que no hay nada malo.

    Luego envía la aplicación a un contenedor o a alguna plataforma de hosting efímera.

    Se realiza un nuevo despliegue.

    De repente, el archivo ha desaparecido.

    Comienzan a llegar tickets de soporte:

    ¿Dónde han ido mis imágenes?

    ¿Por qué ocurre esto?

    Porque el sistema de archivos en tu máquina local se comporta de manera muy diferente al almacenamiento dentro de muchos entornos de producción.

    Un contenedor suele seguir un ciclo de vida similar al siguiente:

    created
    running
    destroyed
    recreated
    

    Una vez que ese contenedor se elimina, todo lo que esté escrito únicamente en su disco local desaparece junto con él.

    Muchas plataformas de hosting tratan los archivos escritos localmente como desechables por diseño.

    Deja de tratar tu servidor de aplicaciones como almacenamiento a largo plazo

    Para manejar las subidas en entornos de producción, el enfoque habitual es guardar los archivos en un almacenamiento de objetos dedicado en lugar del disco propio del servidor.

    Eso te da una arquitectura más o menos así:

    Client
      ↓
    Backend
      ↓
    Object Storage
      ↓
    Database stores file metadata
    

    Mientras tanto, tu base de datos solo necesita almacenar una referencia, algo como esto:

    id
    user_id
    filename
    storage_key
    content_type
    created_at
    

    Los bytes reales del archivo se encuentran en el servicio de almacenamiento de objetos, no en tu base de datos relacional.

    Dividir las responsabilidades de esta manera hace que sea mucho más sencillo comprender todo.

    La base de datos y la capa de almacenamiento resuelven problemas diferentes

    Las bases de datos son excelentes para manejar:

    • registros estructurados
    • relaciones entre registros
    • garantías transaccionales
    • búsquedas y filtrados

    El almacenamiento de objetos está diseñado para:

    • imágenes
    • vídeos
    • PDFs y otros documentos grandes
    • activos estáticos en general

    Tratar de hacer que un solo sistema haga ambas tareas generalmente tiene consecuencias negativas al final.

    Una trampa relacionada: incluir archivos subidos en tu base de código

    No es raro encontrar proyectos organizados de la siguiente manera:

    project/
      uploads/
      src/
      package.json
    

    donde los archivos subidos por el usuario se encuentran justo al lado de los propios archivos fuente de la aplicación.

    Ese enfoque falla rápidamente.

    Imagínese:

    100,000 uploaded images
    

    dentro de la carpeta de su proyecto.

    Los despliegues se vuelven extremadamente lentos y caóticos.

    El código de la aplicación y el contenido generado por el usuario siguen ciclos de vida completamente diferentes, por lo que deben estar en sistemas separados.

    7. Lanzar una API sin límites de uso

    Trabajar localmente crea un escenario engañoso.

    Es probable que usted sea el único cliente que accede a la API.

    Envía solicitudes:

    GET /api/users
    

    una vez.

    Luego otra vez.

    Tal vez unas cuantas veces más.

    Nada falla.

    En entornos de producción, las cosas no funcionan con tanta amabilidad.

    Un único usuario — por accidente o intencionadamente — puede enviar miles de solicitudes en un breve período de tiempo.

    Tomemos un endpoint como este:

    POST /api/login
    

    Si no se aplica ningún control de velocidad, un atacante podría someterlo a intentos repetidos de inicio de sesión:

    attempt 1
    attempt 2
    attempt 3
    ...
    attempt 10,000
    

    Incluso una verificación de contraseñas perfectamente segura no impide que el propio endpoint sea explotado a gran escala.

    El límite de frecuencia introduce un punto de control

    Conceptualmente, funciona de la siguiente manera:

    Client
       ↓
    Rate limiter
       ↓
    Application
    

    En lugar de permitir que las solicitudes fluyan sin límite, se puede establecer un tope para cierto tipo de endpoint en valores como:

    100 requests / minute
    

    Una vez que un usuario supera ese umbral, recibe lo siguiente:

    429 Too Many Requests
    

    Los valores adecuados varían según el caso de uso: un endpoint de búsqueda pública no necesita el mismo límite que algo como:

    POST /login
    

    o:

    POST /forgot-password
    

    No se trata solo de tráfico malicioso

    Los límites de tasa también protegen contra errores en el lado del cliente.

    Imagínese un fragmento de código frontend defectuoso como este:

    setInterval(() => {
      fetch("/api/notifications");
    }, 100);
    

    Esa única pestaña del navegador ahora está sometiendo su API sin cesar.

    Ahora imagine que ese error ocurre con 10,000 usuarios.

    De repente, su backend se enfrenta a un problema real.

    Un limitador de tasa le brinda una red de seguridad contra este tipo de comportamiento descontrolado.

    Los límites de tasa se vuelven más complicados al escalar horizontalmente

    Supongamos que su aplicación funciona detrás de tres instancias de backend separadas:

    Server A
    Server B
    Server C
    

    Si cada instancia mantiene su propio contador en memoria, un cliente podría obtener efectivamente tres veces la cantidad permitida, una por servidor.

    Para aplicar un límite consistente en todas las instancias, los equipos suelen recurrir a un almacenamiento compartido como Redis.

    La configuración se verá más o menos así:

    ┌── Server A
    Client → LB ──┼── Server B
                  └── Server C
                        |
                      Redis
    

    Con ese estado compartido, cada instancia aplica siempre el mismo límite de manera consistente.

    Nuevamente, esto es invisible cuando se ejecuta un único proceso localmente; no hay estado distribuido del que preocuparse hasta que la producción genera el problema.

    8. Suponiendo que las solicitudes siempre se completan en un tiempo razonable

    Esto te sorprenderá sin que te des cuenta.

    Imaginemos que creas un endpoint como este:

    app.get("/report", async (req, res) => {
      const data = await generateHugeReport();
      res.json(data);
    });
    

    Localmente, el informe abarca:

    1,000 records
    

    y se completa en:

    300 ms
    

    Parece bien.

    En producción, ese mismo informe ahora abarca:

    5 million records
    

    y la solicitud ahora tarda:

    45 seconds
    

    Mientras tanto, tu proxy inverso podría estar configurado para rendirse después de:

    30 seconds
    

    por lo que el cliente termina viendo:

    504 Gateway Timeout
    

    aunque tu backend sigue trabajando silenciosamente en segundo plano.

    Esa discrepancia genera un modo de fallo confuso.

    Las tareas que tardan en ejecutarse no deben realizarse dentro de un ciclo de solicitud-respuesta

    Cuando una tarea va a tomar tiempo, suele ser mejor tratarla como un trabajo en segundo plano en lugar de algo que el cliente espere directamente.

    En lugar de un flujo como:

    Client
      ↓
    POST /generate-report
      ↓
    Wait 45 seconds
      ↓
    Response
    

    pasas a algo como:

    Client
      ↓
    POST /generate-report
      ↓
    Job created
      ↓
    202 Accepted
    

    con el procesamiento real ocurriendo por separado:

    Queue
      ↓
    Worker
      ↓
    Generate report
      ↓
    Store result
    

    El cliente puede entonces consultar el estado mediante algo como:

    GET /reports/:id
    

    o simplemente recibir una notificación una vez que la tarea finalice.

    Las colas en segundo plano se encargan del trabajo pesado

    Ejemplos comunes de tareas que deben ejecutarse fuera del ciclo principal de solicitudes incluyen:

    • envío de correos electrónicos
    • generación de PDFs
  • Procesamiento de imágenes
  • Procesamiento de video
  • Elaboración de informes
  • Importación de datos
  • Envío de notificaciones
  • Generación de exportaciones en gran escala
  • Ejecución de tareas programadas
  • Al mantener estas operaciones fuera del hilo principal, su API central sigue siendo rápida y receptiva.

    No olvide los tiempos de espera

    Cualquier llamada que realice su servicio a un sistema externo debe tener un tiempo de espera razonable asignado.

    Como ejemplo:

    fetch(url, {
      signal: AbortSignal.timeout(5000)
    });
    

    Si lo omite, una dependencia que se bloquee puede hacer que su aplicación espere para siempre.

    Imagínese esta cadena:

    API
     ↓
    Payment service
     ↓
    hangs
    

    Su solicitud ahora está atascada, esperando algo que podría no responder nunca.

    Ahora imagine que esto ocurre con cientos de solicitudes simultáneas.

    En algún momento, su servidor se quedará sin recursos disponibles para manejar nada más.

    Cada dependencia necesita un plan de contingencia

    Para cada servicio externo con el que interactúa su aplicación, debería poder responder a una pregunta específica:

    ¿Qué sucede si este servicio no responde?

    No esta otra:

    ¿Qué ocurre cuando todo funciona correctamente?

    La segunda pregunta es fácil de responder.

    La primera es la que realmente determina si su sistema sigue operativo bajo presión.

    9. Confiar en la entrada del cliente

    Este error es casi incómodo de señalar porque el código problemático a menudo parece perfectamente razonable.

    Tomemos un endpoint como este:

    app.post("/users", async (req, res) => {
      const { name, email } = req.body;
      await createUser(name, email);
      res.json({
        message: "User created"
      });
    });
    

    A primera vista, nada parece incorrecto.

    Pero considere qué sucede si el cuerpo de la solicitud tiene este formato:

    {
      "name": "",
      "email": "hello"
    }
    

    O así:

    {
      "name": null,
      "email": null
    }
    

    O de esta manera:

    {
      "name": "A".repeat(1000000),
      "email": "..."
    }
    

    ¿Qué sucede si el cliente incluye campos que tu código nunca anticipó?

    Al desarrollar localmente, tiendes a enviar solicitudes de prueba cuidadosamente elaboradas.

    En producción, tu API recibe todo lo que cualquiera decida enviarle.

    La validación debe realizarse en el extremo

    Una API bien construida verifica los datos entrantes antes de que se ejecute cualquier lógica de negocio.

    Por ejemplo, podrías definir una estructura como esta:

    const schema = {
      name: "string",
      email: "email"
    };
    

    Con una biblioteca de validación, puedes imponer reglas como estas:

    name required
    name length <= 100
    email required
    email valid
    

    No importa tanto qué biblioteca de validación específica elijas.

    Lo importante es la regla subyacente:

    Nunca asumas que el cliente envió lo que esperabas.

    Ten en cuenta que “el cliente” podría ser:

    • tu propio frontend
    • una aplicación móvil
    • una integración de terceros
  • un script automatizado
  • una versión obsoleta de tu aplicación
  • alguien con intenciones maliciosas
  • Las verificaciones en el lado del cliente no son una capa de seguridad

    Una objeción común es:

    "Pero ya valido esto en el frontend, con React."

    Eso sí es útil para la experiencia del usuario.

    Pero no protege en absoluto tu backend.

    Cualquiera puede ignorar por completo tu código del frontend.

    Solo se necesita ejecutar:

    curl
    

    dirigido directamente a tu punto de extremo API.

    Tu código del lado del servidor sigue teniendo que aplicar las reglas por sí mismo.

    La inyección SQL es un riesgo relacionado

    Evita crear consultas concatenando cadenas, como esto:

    const query = `
      SELECT *
      FROM users
      WHERE email = '${email}'
    `;
    

    En su lugar, utiliza consultas parametrizadas:

    const result = await pool.query(
      "SELECT * FROM users WHERE email = $1",
      [email]
    );
    

    De esta manera, el controlador de base de datos mantiene los valores de los parámetros separados de la estructura de la consulta.

    La validación de entradas y las consultas parametrizadas abordan amenazas diferentes, pero un backend de nivel profesional necesita ambas.

    10. Pensar que “la aplicación está en ejecución” significa que “la aplicación está sana”

    Este podría ser el malentendido más fundamental de esta lista.

    Se inicia su servidor:

    Server listening on port 5000
    

    Los registros confirman:

    Application started successfully.
    

    En ese momento, es tentador pensar:

    Genial. Ya terminamos.

    Eso no es necesariamente cierto.

    Un proceso puede seguir ejecutándose incluso cuando la aplicación en sí está efectivamente dañada.

    Considere este estado:

    Backend process: running
    Database: unavailable
    Redis: unavailable
    Payment provider: unavailable
    Queue: stuck
    

    Desde la perspectiva del sistema operativo, el proceso está activo y en buen estado.

    El panel de monitoreo podría mostrar:

    CPU: 10%
    Memory: 20%
    Process: alive
    

    Mientras tanto, los usuarios reales ven:

    500 Internal Server Error
    

    Las verificaciones de estado cubren parte de la brecha

    Un endpoint básico de estado como:

    GET /health
    

    puede simplemente devolver:

    {
      "status": "ok"
    }
    

    Pero incluso esto puede ser engañoso.

    Si /health solo confirma que el servidor HTTP responde a las solicitudes, no dice nada sobre si las dependencias de las que realmente depende la aplicación están funcionando.

    Una configuración más completa separa dos endpoints. Uno:

    /health
    

    para una verificación básica de actividad, y otro:

    /ready
    

    para la disponibilidad.

    Conceptualmente, la distinción es la siguiente:

    Liveness: "Is this process alive?"
    

    y:

    Readiness:
    "Can this instance actually serve traffic?"
    

    Verificación específica del estado de la base de datos

    Una verificación de preparación puede ir más allá y confirmar que la conexión a la base de datos realmente funciona, por ejemplo ejecutando:

    SELECT 1;
    

    Si no se puede llegar a la base de datos, la aplicación puede marcarse como no lista.

    Esto permite a las herramientas de orquestación detener el envío de tráfico a una instancia que en realidad no puede atender las solicitudes adecuadamente.

    El tiempo de actividad por sí solo dice muy poco

    Un servidor puede informar:

    99.99% uptime
    

    y aun así ofrecer una experiencia extremadamente lenta a sus usuarios.

    Por eso, la supervisión en producción necesita seguir más indicadores que simplemente el hecho de que el proceso esté activo, incluyendo cosas como:

    • latencia de las solicitudes
    • tasa de errores
    • ancho de banda
    • Rendimiento de la base de datos
    • Uso de CPU
    • Uso de memoria
    • Profundidad de la cola
    • Fallas en servicios externos

    Una métrica particularmente útil es:

    p95 latency
    

    En lugar de preguntar:

    ¿Cuál es el tiempo de respuesta promedio?

    es más revelador preguntar:

    ¿Cuánto tiempo tardan el 95% de las solicitudes?

    Los promedios tienden a ocultar las peores experiencias.

    Considere este escenario:

    99 requests = 50 ms
    1 request = 30 seconds
    

    El tiempo de respuesta promedio aquí podría no parecer en absoluto alarmante.

    Pero esa única solicitud lenta siguió representando una experiencia realmente mala para quien la realizó.

    El error adicional: registrar todo menos las partes útiles

    Este ocupa su lugar en la lista porque ha salvado más de una sesión de depuración.

    Algo falla en producción.

    Abre los registros.

    Lo único que encuentra es:

    Something went wrong
    

    Ese es el mensaje completo.

    Ningún identificador de solicitud.

    Ninguna información sobre la ruta.

    No hay contexto del usuario.

    No hay rastro de la pila de llamadas.

    No hay indicación de qué llamada a la base de datos se produjo.

    No hay marca de tiempo lo suficientemente precisa como para correlacionarla con algo.

    Solo una línea simple que dice:

    Something went wrong
    

    En ese momento, se debe solucionar el problema sin contar con información real.

    Así es como debería verse una entrada de registro útil

    Cuando algo falla, las preguntas a las que se quiere responder son:

    When?
    Where?
    Which request?
    Which service?
    What failed?
    How long did it take?
    What dependency was involved?
    

    Una entrada de registro bien estructurada captura ese contexto, algo como:

    {
      "level": "error",
      "requestId": "abc-123",
      "route": "/api/orders",
      "method": "POST",
      "duration": 843,
      "error": "Database timeout"
    }
    

    Esa versión realmente proporciona algo con lo que trabajar.

    Nunca registre datos sensibles

    Al mismo tiempo, tenga cuidado de no registrar casualmente:

    password
    access token
    refresh token
    credit card information
    private keys
    

    El registro existe para ayudarlo a diagnosticar problemas, no para darle a los atacantes otro problema.

    Por qué estos problemas nunca aparecen en su ordenador portátil

    Puede preguntarse razonablemente por qué tantos desarrolladores repiten los mismos errores si son tan comunes.

    La respuesta es que una configuración local y un entorno de producción son sistemas fundamentalmente diferentes.

    Aquí está la diferencia.

    Así es el desarrollo local

    Típicamente se trabaja con:

    1 developer
    1 application
    1 database
    small dataset
    low traffic
    fast network
    known inputs
    stable filesystem
    single process
    

    Mientras tanto, la producción puede involucrar:

    thousands of users
    multiple application instances
    millions of database rows
    unpredictable traffic
    network latency
    invalid requests
    external dependencies
    ephemeral containers
    background workers
    multiple regions
    

    Eso no es una versión reducida del mismo sistema: es un sistema completamente distinto.

    El código fuente puede permanecer sin cambios.

    Pero el entorno que lo rodea no lo está.

    Caer en la costumbre del flujo óptimo

    Es natural probar solo el flujo exitoso.

    Un ejemplo típico:

    Create user
    ↓
    Login
    ↓
    Create order
    ↓
    Payment succeeds
    ↓
    Return response
    

    Esa secuencia representa el escenario ideal.

    Pero el tráfico de producción nunca se limita a escenarios ideales.

    También hay que tener en cuenta situaciones como:

    What if the database is slow?
    What if Redis is unavailable?
    What if the payment service times out?
    What if the user sends invalid JSON?
    What if the request is duplicated?
    What if two requests update the same record?
    What if the process restarts?
    What if the container disappears?
    What if the database connection pool is exhausted?
    What if the user clicks the button five times?
    What if the request arrives twice?
    What if the response is lost after the database transaction succeeds?
    

    En un sistema distribuido, estos no son casos marginales raros.

    Son situaciones habituales.

    Pasando de “¿Funciona?” a “¿Qué pasa si falla?”

    A medida que se adquiere experiencia con los sistemas backend, nuestros instintos cambian.

    Al principio, la pregunta es:

    ¿Funciona este código?

    Con el tiempo, se convierte en:

    ¿Qué ocurre si este código falla?

    Esa segunda pregunta es mucho más útil.

    Tomemos esta línea:

    const result = await paymentService.charge();
    

    El razonamiento de un principiante se detiene en:

    Si la llamada tiene éxito, seguir adelante.

    El razonamiento orientado a la producción continúa:

    ¿Qué pasa si se agota el tiempo de espera?

    Luego:

    ¿Qué pasa si la cobranza se realiza pero la respuesta a mi API se tiempo out?

    Luego:

    ¿Y si el cliente vuelve a intentar la solicitud?

    Luego:

    ¿Podría el cliente terminar siendo facturado dos veces?

    En ese punto se está analizando los modos de fallo, que es en realidad el núcleo de la ingeniería de backend.

    Idempotencia: Qué mantiene seguros los flujos de pago

    Continuando con el mismo ejemplo: imagine que un usuario hace clic

    Pay
    

    La solicitud llega a su servidor.

    La cobranza tiene éxito.

    Pero la conexión se interrumpe antes de que llegue la respuesta.

    El cliente no tiene forma de saber si el pago realmente se realizó, por lo que vuelve a intentarlo.

    Ahora el servidor ya ha procesado:

    Payment request #1 → success
    Payment request #2 → success
    

    Lo cual podría resultar fácilmente en:

    Customer charged twice.
    

    La idempotencia existe precisamente para evitar esto.

    Una clave de idempotencia podría verse así:

    payment_7f8c123
    

    El cliente adjunta esta clave a la solicitud, y el servidor la almacena junto con el resultado de esa operación. Si llega otra solicitud que lleva la misma clave, el servidor simplemente devuelve el resultado almacenado en lugar de ejecutar la operación una segunda vez.

    En resumen:

    Request
      ↓
    Idempotency key
      ↓
    Already processed?
      ├── Yes → return previous result
      └── No → process → store result
    

    Este patrón es de gran importancia para operaciones como:

    • pagos
    • creación de pedidos
    • registro de cuentas
    • envío de correos electrónicos
    • llamadas a APIs de terceros

    Los errores por solicitudes duplicadas rara vez se detectan durante las pruebas locales, pero aparecen constantemente cuando intervienen redes reales e inestables.

    Transacciones: Otra cosa que parece bien hasta que deja de serlo

    Piense en lo que sucede cuando se crea un pedido.

    El flujo de trabajo puede involucrar varias etapas:

    Create order
    Create order items
    Update inventory
    Create payment record
    

    Si no envuelve estas operaciones en una transacción, una de ellas puede fallar a mitad del proceso.

    Imagínese este escenario:

    Create order       ✓
    Create items       ✓
    Update inventory   ✓
    Payment record     ✗
    

    En este punto, su base de datos se encuentra en un estado incompleto.

    Existen las transacciones para poder agrupar operaciones que deben tener éxito todas o fallar todas como una unidad.

    Aquí está cómo se ve en la práctica:

    BEGIN;
    INSERT INTO orders (...);
    INSERT INTO order_items (...);
    UPDATE inventory
    SET quantity = quantity - 1
    WHERE product_id = 123;
    COMMIT;
    

    Y cuando algo falla en el proceso:

    ROLLBACK;
    

    No obstante, envolver cada operación individual en una sola transacción masiva tampoco es el enfoque adecuado.

    Los límites de las transacciones requieren un diseño cuidadoso.

    Las transacciones que duran demasiado tiempo pueden generar sus propios problemas.

    La pregunta que siempre debe hacerse es:

    Si esto falla a mitad de proceso, ¿en qué estado deja eso al sistema?

    Preguntas N+1: El asesino silencioso del rendimiento

    Hay otro problema que tiende a permanecer oculto mientras desarrollas localmente.

    Supongamos que obtienes una lista de usuarios:

    SELECT * FROM users LIMIT 100;
    

    Y luego, para cada usuario en particular, vas a buscar sus pedidos:

    SELECT * FROM orders WHERE user_id = ?;
    

    Eso suma un total de:

    1 query for users
    +
    100 queries for orders
    =
    101 queries
    

    Esta es la conocida trampa de las preguntas N+1.

    Si solo tienes 10 usuarios:

    11 queries
    

    Eso es manejable.

    Pero si escalas a 10,000 usuarios:

    10,001 queries
    

    de repente tienes un verdadero problema de rendimiento.

    La solución no es necesariamente “combinar todo en una sola consulta masiva”.

    Dependiendo de tu situación, podrías recurrir a:

    • uniones
    • carga anticipada
    • batching
  • IN (...) cláusulas
  • cargadores de datos
  • consultas diseñadas específicamente para evitar este patrón
  • Lo más importante es poder detectar el patrón desde un principio.

    Un backend puede parecer perfectamente rápido con un pequeño conjunto de datos local, mientras en realidad envía miles de consultas bajo carga real.

    La paginación no es opcional para colecciones grandes

    Un endpoint como este parece lo suficientemente inofensivo:

    GET /users
    

    Y la consulta que lo sustenta:

    SELECT *
    FROM users;
    

    funciona sin problemas cuando se tiene:

    50 users
    

    Pero ¿qué sucede cuando se trata con:

    5 million users?
    

    Enviar cinco millones de registros al cliente no es algo que se quiera hacer.

    La paginación ofrece una forma de limitar eso.

    Se podría utilizar paginación basada en offset:

    GET /users?page=2&limit=50
    

    o paginación basada en cursor:

    GET /users?cursor=abc123&limit=50
    

    La paginación por cursor suele funcionar mejor con conjuntos de datos grandes o en constante cambio, ya que evita algunos problemas de rendimiento y consistencia que surgen con desplazamientos muy grandes.

    Nuevamente, el problema principal es:

    localhost data = small
    production data = potentially huge
    

    Su API debe diseñarse teniendo en cuenta esa limitación.

    Las fugas de memoria no siempre se manifiestan de inmediato

    Este es otro tipo de error que solo se revela en entornos de producción.

    Su aplicación arranca utilizando:

    Memory: 150 MB
    

    Una hora después:

    300 MB
    

    Seis horas después:

    600 MB
    

    Y finalmente:

    Out of memory
    

    Luego se bloquea y se reinicia.

    En ese momento, todo vuelve a parecer normal.

    Ese patrón es una señal clara de que algo está reteniendo memoria que no debería.

    Los culpables más comunes incluyen:

    • arrays que crecen sin límite alguno
  • cache sin límite de eliminación
  • escuchadores de eventos que nunca se limpian
  • objetos grandes que permanecen en la memoria de forma involuntaria
  • referencias que sobreviven mucho más tiempo del necesario
  • flujos de datos que no se manejan correctamente
  • Cuando pruebas localmente, la aplicación rara vez sigue ejecutándose el tiempo suficiente como para que surja este tipo de problema.

    En producción, funciona el tiempo suficiente como para que la fuga de recursos sea relevante.

    Esa es una de las razones por las cuales observar el comportamiento de una aplicación durante períodos prolongados es tan valioso.

    El caché también puede arruinar la producción

    Sobre el papel, el caché parece ser una solución sencilla y beneficiosa:

    Database → slow
    Redis → fast
    

    Por lo tanto, el instinto es cachear todo.

    Pero luego los datos subyacentes cambian.

    Y la API sigue devolviendo información desactualizada.

    Ahora te encuentras con una discrepancia como esta:

    Database:
    balance = 100
    Cache:
    balance = 80
    

    La API responde con:

    80
    

    Y el usuario quiere saber:

    ¿Por qué está mal mi saldo?

    El caché te obliga a responder preguntas como:

    • ¿Cuándo deben vencer las entradas?
    • ¿Cuándo se invalida el caché?
    • ¿Cuál es la solución de respaldo si Redis deja de funcionar?
    • ¿Qué sucede cuando dos servidores escriben en la misma clave?
    • ¿Es aceptable en algún caso servir datos obsoletos?
    • ¿Qué debería ocurrir inmediatamente después de una implementación?

    La parte difícil del caché nunca fue almacenar los datos.

    Se trata de determinar el momento en que los datos almacenados dejan de ser fiables.

    Las fallas del caché deben tener una estrategia

    Si Redis desaparece de forma inesperada, ¿tu aplicación:

    fail completely
    

    o ¿hace lo siguiente?

    fall back to database
    

    No existe una respuesta universal: depende del sistema que estés desarrollando.

    Lo importante es que realice esa llamada de forma intencionada, y no por accidente.

    Secretos en Git: El error que puede convertirse en un incidente de seguridad

    Lo siguiente parece sentido común, pero sigue ocurriendo.

    Configura un archivo:

    DATABASE_PASSWORD=...
    JWT_SECRET=...
    API_KEY=...
    

    Y luego ejecuta:

    git add .
    git commit -m "config"
    git push
    

    En ese momento, el secreto queda integrado en la historia de Git.

    Borrar el archivo posteriormente no lo elimina; aún se puede recuperar su valor de los commits anteriores.

    Una configuración mejor consiste en mantenerlo:

    .env
    

    listado dentro de .gitignore, y en lugar de eso, guardar un template:

    .env.example
    

    con entradas de marcador de posición como:

    DATABASE_URL=
    JWT_SECRET=
    REDIS_URL=
    

    Los valores reales deben introducirse a través de la configuración de entorno de su plataforma de despliegue o de un gestor de secretos dedicado.

    Y si realmente algún secreto llega a incluirse en un commit:

    No basta con eliminar la línea y pensar que ya está hecho.

    Rotea ese secreto de inmediato.

    Considéralo comprometido, independientemente de si puedes demostrar que fue accedido alguna vez.

    CORS no es autenticación

    Aquí hay otro punto que confunde a la gente:

    Access-Control-Allow-Origin: *
    

    CORS regula cómo los navegadores manejan las solicitudes entre orígenes diferentes.

    No hace nada para proteger realmente tu API.

    Nada impide que un endpoint del servidor sea accedido directamente a través de:

    curl
    Postman
    mobile apps
    backend services
    scripts
    

    CORS nunca fue diseñado para funcionar como capa de autenticación.

    Tu API sigue necesitando realmente:

    • autenticación
    • autorización
    • validación de entrada
    • limitación de frecuencia
    • control de acceso

    La seguridad debe implementarse de forma explícita, no por implicación de las políticas del navegador.

    La autenticación y la autorización son diferentes

    Confundir estos dos conceptos conduce a verdaderas brechas de seguridad.

    Respuestas de autenticación:

    ¿Quién es usted?

    Respuestas de autorización:

    ¿Tiene permiso para hacer esto?

    Considere una solicitud como esta:

    GET /users/123
    

    Es muy posible que el usuario esté autenticado.

    Pero eso no dice nada sobre si se le debe permitir ver:

    user 123
    

    Si la persona que realiza la solicitud es:

    user 456
    

    el servidor aún debe determinar si:

    456 → allowed to access 123?
    

    Un error frecuente se presenta de esta manera:

    if (req.user) {
      return getUser(req.params.id);
    }
    

    Ese código solo verifica que alguien esté conectado.

    Nunca comprueba si tiene permiso para acceder a ese recurso específico.

    Un patrón más seguro tiene en cuenta la propiedad o los permisos explícitos:

    if (req.user.id !== req.params.id) {
      return res.status(403).json({
        error: "Forbidden"
      });
    }
    

    Las reglas específicas varían según la aplicación.

    Pero el principio subyacente se mantiene igual en todas partes:

    El inicio de sesión no otorga acceso ilimitado a todo en el sistema.

    El despliegue forma parte de la aplicación

    Una lección que vale la pena asimilar desde temprano es que el despliegue no es una fase separada añadida al final del desarrollo.

    Forma parte del propio trabajo de ingeniería.

    Su aplicación no es solo:

    source code
    

    Es una combinación:

    source code
    +
    configuration
    +
    database
    +
    network
    +
    storage
    +
    secrets
    +
    dependencies
    +
    monitoring
    +
    deployment process
    

    Alguien que solo comprende el código de la aplicación aún puede sentirse abrumado una vez que las cosas pasan a producción.

    No es necesario dominar cada detalle de Kubernetes para ser eficaz.

    Pero sí se necesita una comprensión práctica del entorno en el que realmente funciona su código.

    Una lista de verificación para la preparación en producción que realmente uso

    Antes de declarar que algo está listo para producción, es útil revisar una lista de verificación como esta.

    Configuración

    • ¿Se validan las variables de entorno requeridas al iniciar el sistema?
    • ¿Se mantienen los datos confidenciales fuera del código fuente?
    • ¿Los valores predeterminados para producción son seguros en lugar de solo prácticos?
    • ¿Se mantienen separadas las configuraciones para desarrollo y producción?

    Banco de datos

    • ¿Está configurado el pooling de conexiones?
    • ¿Tiene el pool el tamaño adecuado?
    • ¿Están indexadas las consultas importantes?
    • ¿Se ha analizado realmente el rendimiento de las consultas lentas?
    • ¿Se utilizan transacciones donde sea necesario?
    • ¿Se realizan un seguimiento de las migraciones con control de versiones?

    API

    • ¿Se valida la entrada recibida?
    • ¿Se estructuran intencionadamente los datos de respuesta?
    • ¿Los conjuntos de resultados grandes se presentan en páginas?
    • ¿Se limita el tamaño del cuerpo de las solicitudes?
  • ¿Están configurados tiempos de espera razonables?
  • Seguridad

    • ¿Las contraseñas se hashean con un algoritmo seguro?
    • ¿Los secretos se almacenan de forma segura?
    • ¿Se aplica realmente la autorización?
    • ¿Las consultas SQL están parametrizadas?
    • ¿Se aplica el límite de frecuencia donde es importante?
    • ¿Los campos sensibles se mantienen fuera de los registros?

    Infraestructura

    • ¿Algo depende incorrectamente de localhost?
    • ¿Los datos persistentes se guardan fuera de los contenedores efímeros?
    • ¿Están expuestos los puntos de verificación de estado?
    • ¿Se implementa un apagado ordenado?
    • ¿Qué ocurre realmente cuando se reinicia la aplicación?

    Rendimiento

    • ¿Se ha probado el sistema con volúmenes de datos realistas?
    • ¿Existen patrones ocultos de consultas N+1?
    • ¿Las tareas costosas se transfieren a trabajos en segundo plano?
      • ¿Tienen los cachés un límite de tamaño?
      • ¿Se está monitoreando el uso de memoria?

      Observabilidad

      • ¿Los registros de errores incluyen suficiente contexto como para ser útiles?
      • ¿Está configurado el seguimiento de solicitudes?
      • ¿Se monitorean la latencia y las tasas de error?
      • ¿Los fallos en la base de datos son visibles en el monitoreo?
      • ¿Están realmente configuradas las alertas?

      Cierre ordenado: el pequeño detalle que la gente olvida

      Imagínese que su servidor recibe un:

      SIGTERM
      

      señal porque la plataforma de alojamiento quiere reiniciar la instancia.

      Si el proceso termina al instante, todas las solicitudes que aún están en trámite pueden fallar completamente.

      El cierre ordenado maneja esto mejor.

      La idea en el código se parece más o menos a:

      process.on("SIGTERM", async () => {
        console.log("Shutdown signal received");
      server.close(async () => {
          await pool.end();
          console.log("Server closed");
          process.exit(0);
        });
      });
      

      La implementación exacta variará según su framework.

      Pero la secuencia subyacente se mantiene constante:

      Stop accepting new requests
              ↓
      Finish active requests
              ↓
      Close database connections
              ↓
      Exit
      

      Esto es aún más importante cuando se ejecutan varias instancias de la aplicación al mismo tiempo.

      La lógica de reintentos puede empeorar los problemas

      Cuando falla una llamada a un servicio externo, reintentarla parece la solución obvia:

      try {
        await callService();
      } catch {
        await callService();
      }
      

      Por sí solo, eso parece lo suficientemente inofensivo.

      Pero ahora imagine mil solicitudes que siguen todos ese mismo patrón.

      Supongamos que la dependencia comienza a responder lentamente.

      Cada una de esas llamadas fallidas se vuelve a intentar automáticamente.

      La dependencia, ya sobrecargada, recibe ahora aún más carga que antes.

      Este patrón tiene un nombre: tormenta de reintentos.

      La lógica de reintentos debe diseñarse intencionadamente, teniendo en cuenta aspectos como:

      • un límite en los intentos
      • retraso exponencial
      • variabilidad en los tiempos
      • tiempos de espera razonables
      • garantías de idempotencia
      • protección por cortocircuito donde sea apropiado

      Volver a intentarlo no es automáticamente un mecanismo de recuperación.

      En algunos casos, es precisamente eso lo que convierte un pequeño contratiempo en una interrupción total.

      Por qué los errores en producción se sienten tan diferentes

      Un error típico local se reduce a algo como:

      I made a mistake.
      

      Un incidente típico en producción tiende a verse más como:

      Several individually reasonable things interacted badly.
      

      Como ilustración:

      Traffic increases
              ↓
      More API instances
              ↓
      More database connections
              ↓
      Database reaches connection limit
              ↓
      Queries fail
              ↓
      Requests retry
              ↓
      More load
              ↓
      System becomes even less stable
      

      Ninguna de las líneas individuales involucradas parece necesariamente peligrosa por separado.

      El verdadero problema radica en cómo interactúan bajo carga.

      Esta es precisamente la razón por la cual, con el tiempo, la ingeniería de backend desvía su enfoque de las funciones aisladas hacia el comportamiento del sistema en su conjunto.

      La frase más peligrosa en el desarrollo backend

      Tal vez la frase más arriesgada en todo el trabajo de backend sea:

      "Nadie va a hacer eso."

      Nadie va a:

      • Enviar 10,000 solicitudes de forma simultánea
      • Subir un archivo de 500 MB
      • Hacer doble clic en el botón de envío
      • Enviar JSON mal formado
      • Solicitar un millón de filas a la vez
      • Seguir utilizando un cliente obsoleto
      • Acceder directamente a la API en lugar de hacerlo a través de la interfaz de usuario
      • Fazer que el servicio externo se agote en tiempo
      • Desencadenar dos actualizaciones al mismo momento
      • Reiniciar el servidor en medio de una solicitud

      Pero, al final, alguien lo hace.

      A veces por accidente. A veces debido a un error en otra parte del sistema. A veces por problemas de red. A veces simplemente porque su producto comienza a ganar popularidad real.

      En entornos de producción no se necesita que haya actores malintencionados para que surjan suposiciones erróneas; la mera escala es suficiente para exponerlas por sí sola.

      Diseñe para el fracaso, no solo para el éxito

      Un cambio importante en la forma de pensar de los desarrolladores backend experimentados es el siguiente: al escribir una función, no basta con preguntarse qué sucede cuando tiene éxito; también hay que preguntarse qué ocurre cuando falla.

      What happens when it succeeds?
      
      What happens when it fails?
      

      Al consultar la base de datos, pregúntese qué sucede si esta no es accesible.

      What if it's unavailable?
      

      Al consultar Redis, pregúntese qué sucede si responde lentamente.

      What if it's slow?
      

      Al enviar un correo electrónico, pregúntese qué sucede si el tiempo de respuesta del proveedor se agota.

      What if the provider times out?
      

      Al subir un archivo, pregúntese qué sucede si el almacenamiento falla justo después de que ya se haya escrito el registro en la base de datos.

      What if storage fails after the database record is created?
      

      Al crear un pedido, pregúntese qué sucede si la misma solicitud llega dos veces.

      What if the request is repeated?
      

      Al procesar un pago, pregúntese qué sucede si el cliente nunca recibe la respuesta.

      What if the client never receives the response?
      

      Al desplegar, pregúntese qué sucedería si la nueva versión falla al iniciar.

      What if the new version crashes?
      

      Hacer estas preguntas de manera constante cambia la forma en que diseña su arquitectura.

      Un modelo mental simple para el desarrollo de backends en producción

      Al crear una funcionalidad de backend, es útil someterla a cinco niveles de análisis.

      1. Correctitud

      ¿Realmente hace la funcionalidad lo que se supone que debe hacer?

      Create order → order actually gets created
      

      2. Seguridad

      ¿Podría alguien realizar una acción para la cual no debería tener permiso?

      User A → access User B's order
      

      3. Rendimiento

      ¿Qué sucede cuando el volumen de datos o el tráfico aumentan significativamente?

      100 users → 10 million users
      

      4. Confiabilidad

      ¿Qué ocurre cuando los sistemas de los que depende esta funcionalidad fallen?

      Database unavailable
      

      5. Operabilidad

      Si algo se rompe, ¿puede realmente diagnosticarlo y repararlo?

      Request failed at 03:42:17
      

      Al probar estos cinco niveles, suele surgir una cantidad sorprendente de problemas antes de que lleguen a la producción.

      De “Funciona” a “Sobrevive”

      Existe una brecha real entre el software que simplemente funciona y el software capaz de resistir las condiciones del mundo real.

      Una compilación local solo necesita demostrar una cosa:

      feature works
      

      Una compilación para producción debe demostrar varias cosas:

      feature works
      feature fails safely
      feature handles load
      feature protects data
      feature can be monitored
      feature can recover
      feature can be deployed
      feature can be rolled back
      

      Eso representa un estándar mucho más alto de cumplir. También es lo que hace que la ingeniería de backend sea realmente interesante como disciplina.

      Los 10 errores nuevamente

      Aquí hay un resumen rápido de todo lo tratado.

      1. Configuración del entorno faltante

      No asuma que la configuración que tiene en su ordenador portátil existirá también en la producción. Verifique las variables de entorno necesarias al iniciar y genere un error claro si faltan.

      2. Codificación fija de localhost

      Dentro de un contenedor, localhost se refiere únicamente a ese contenedor en sí. Depende de un sistema adecuado de detección y configuración de servicios que se adapte a cada entorno.

      3. Creación de conexiones a la base de datos por solicitud

      Utilice un pool de conexiones y tenga en cuenta el número total de conexiones en todas las instancias en ejecución de su aplicación.

      4. Devolver todo desde la base de datos

      Evite usar SELECT * por costumbre. Devuelva solo los campos que realmente necesiten los consumidores de su API.

      5. Ignorar índices

      Una consulta que funciona bien con mil filas puede fallar al tratar con millones. Pruebe con volúmenes de datos similares a los de producción.

      6. Tratar el almacenamiento local como permanente

      Los contenedores, y muchas plataformas en la nube en general, no ofrecen ninguna garantía de que los archivos locales permanezcan. Dirija el contenido subido por los usuarios a un almacenamiento diseñado para ser duradero.

      7. Sin límites de velocidad

      Los clientes no controlan con qué frecuencia llaman a su API. Establezca límites en los puntos de extremo que sean sensibles o costosos de ejecutar.

      8. Solicitudes síncronas de larga duración

      El procesamiento intensivo suele corresponder a tareas en segundo plano, no algo por lo que una solicitud HTTP deba esperar.

      9. Confiar en la entrada del cliente

      Valide todo nuevamente en el límite del backend. Las verificaciones únicamente en el frontend nunca son suficientes.

      10. Confundir “proceso activo” con “aplicación sana”

      Depienda de verificaciones de estado, monitoreo, registros estructurados, métricas y alertas que realmente tengan sentido.

      Consideraciones finales

      Lo sorprendente de los problemas de producción es que casi ninguno parece alarmante a primera vista.

      Un localhost codificado de forma fija no parece ser un problema. La falta de un índice no parece ser un problema. Abrir una nueva conexión a la base de datos por solicitud no parece ser un problema. La falta de tiempo de espera no parece ser un problema. Una ruta API sin límites no parece ser un problema. Escribir un archivo directamente en el disco no parece ser un problema.

      Hasta que aparecen los usuarios reales.

      Es entonces cuando estas pequeñas suposiciones, aparentemente inofensivas, comienzan a chocar entre sí. Y de repente te encuentras frente a:

      slow APIs
      database connection exhaustion
      memory spikes
      timeouts
      failed deployments
      missing files
      duplicate payments
      security issues
      

      El objetivo no es anticipar con antelación cada posible fallo. Eso es imposible de lograr. El objetivo es dejar de crear sistemas partiendo de la premisa de que todo siempre funcionará sin problemas.

      Porque no será así.

      Las redes fallan. Las bases de datos se ralentizan. Los contenedores se reinician. Los usuarios envían entradas que nunca esperabas. Las dependencias posteriores se desconectan. El tráfico aumenta sin previo aviso. Los servidores se quedan sin memoria. Las solicitudes se duplican. Las implementaciones salen mal.

      Nada de eso tiene por qué ser catastrófico, siempre y cuando tu sistema haya sido diseñado teniendo en cuenta esas posibilidades desde el principio.

      Lo que realmente diferencia un backend que solo funciona en localhost de uno que resiste en producción no es el lenguaje de programación que hayas elegido, sino la mentalidad detrás del diseño.

      En localhost, la pregunta es simplemente: ¿funciona?

      En producción, la pregunta importante es: ¿qué sucede cuando no funciona?

      Esa segunda pregunta, planteada una y otra vez, te enseña mucho más sobre ingeniería de backend que cualquier framework o herramienta.

      Si estás creando tu primer sistema de producción, resiste la tentación de arreglarlo todo de una vez. Empieza poco a poco: asegúrate de que la configuración funcione correctamente, añade gestión de pooling de conexiones, valida cada entrada, crea índices según la forma en que realmente consultas los datos, maneja explícitamente los tiempos de espera, almacena los archivos en el lugar adecuado, agrega un registro detallado de actividades, monitorea lo que es importante y piensa en escenarios de fallo a medida que avanzas. Luego, repite ese proceso una y otra vez.

      La preparación para producción no es algo que marcas una vez y dejas atrás. Es un hábito que se construye con el tiempo como desarrollador.

      Poco a poco, eso marca la diferencia entre un sistema que solo dice “funciona en mi máquina” y uno que realmente resiste cuando es necesario.

      Lecturas relacionadas