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.
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
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
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
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áusulasLo 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
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?
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?
- ¿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?
Observabilidad
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
- 20 patrones de Node.js que evitan el cese del servidor 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 impiden los fallos antes de que sea necesario reiniciar el servidor.
- Errores comunes en el contrato de API que afectan la fiabilidad del frontend — Conozca diez defectos recurrentes en el diseño de APIs backend, desde formas de respuesta incoherentes hasta una paginación frágil, que socavan la confianza del frontend y cómo solucionarlos.