Cinco errores engañosos en Node.js que pasan desapercibidos por la revisión de código
Exploraremos cinco errores reales en Node.js relacionados con forEach, promesas flotantes y copias superficiales para comprender por qué el código que funciona bien puede fallar aún así en producción.
Cada uno de los cinco ejemplos de código a continuación se ejecuta sin generar errores, y es probable que cada uno pase desapercibido en una revisión rápida del código. Sin embargo, todos ellos han causado interrupciones reales en algún sistema de producción, más de una vez. Antes de leer la explicación debajo de cada fragmento, intente averiguar por su cuenta qué está mal.
Bug 1
async function notifyAllUsers(userIds) {
userIds.forEach(async (id) => {
const user = await getUser(id);
await sendNotification(user);
});
console.log("All notifications sent!");
}
Haga una pausa aquí y reflexione sobre lo que realmente ocurre cuando se ejecuta este código.
El error: la línea console.log("All notifications sent!") se ejecuta antes de que cualquier notificación sea enviada realmente, y la función exterior misma finaliza sin esperar a que se completen los envíos individuales.
La razón es que Array.prototype.forEach no tiene concepto de promesas. Invoca la función de callback para cada elemento y pasa inmediatamente al siguiente, ignorando por completo el valor que devuelva dicha función. Declarar la función de callback como async no cambia en nada el comportamiento de forEach; simplemente significa que cada llamada ahora genera una promesa que forEach descarta sin examinarla. Las notificaciones siguen siendo enviadas eventualmente, solo de forma asíncrona en segundo plano, sin un orden garantizado y sin mecanismo alguno para que quien llama pueda detectar si se completó o falló.
async function notifyAllUsers(userIds) {
await Promise.all(userIds.map(async (id) => {
const user = await getUser(id);
await sendNotification(user);
}));
console.log("All notifications sent!");
}
Cambiar a map implica que las promesas se recopilan en un array en lugar de ser descartadas, y al envolver ese array con Promise.all se obliga a la función a esperar realmente hasta que termine cada envío. Ahora la declaración de registro dice la verdad.
Bug 2
app.post("/orders", async (req, res) => {
const order = await createOrder(req.body);
sendConfirmationEmail(order.customerEmail);
res.status(201).json(order);
});
Parece que la ruta funciona bien: se crean los pedidos, se envían correos electrónicos y la respuesta llega rápidamente. ¿Cuál es entonces el problema?
El problema: sendConfirmationEmail se llama sin usar await, por lo que la promesa que devuelve queda completamente sin gestionar. Si esa promesa se rechaza, nada la captura. Este patrón se conoce como promesa flotante, y no es solo un problema estilístico: representa un riesgo operativo. En las versiones actuales de Node.js, el rechazo no manejado de una promesa puede hacer colapsar todo el proceso, arrastrando consigo todas las demás solicitudes en ejecución, en lugar de que el envío del correo fallue silenciosamente.
También hay aquí una verdadera cuestión arquitectónica, más allá del manejo inadecuado de errores: ¿debería un correo electrónico de confirmación fallido impedir que el pedido se complete, o debería el pedido seguir adelante de todos modos? En la mayoría de los casos se preferiría lo segundo: el pedido realmente se completó aunque la notificación no llegara. Pero existe una diferencia entre elegir no bloquear algo y no manejar en absoluto sus errores, y este código ha hecho accidentalmente lo segundo aunque probablemente pretendía hacer lo primero.
app.post("/orders", async (req, res) => {
const order = await createOrder(req.body);
sendConfirmationEmail(order.customerEmail).catch((err) => {
logger.error({ orderId: order.id, err }, "Failed to send confirmation email");
});
res.status(201).json(order);
});
Con esta versión, el correo electrónico realmente no detiene la respuesta HTTP, pero ahora se registra el fallo en lugar de desaparecer silenciosamente o hacer que el servidor se caiga.
Bug 3
function applyDiscount(cart) {
const updatedCart = { ...cart };
updatedCart.items.forEach((item) => {
item.price = item.price * 0.9;
});
return updatedCart;
}
A primera vista, esto se parece al principio estándar “copiar en lugar de mutar” que encontrarías en cualquier guía sobre cómo evitar efectos secundarios. En realidad se trata de una trampa más sutil.
El problema: la operación de propagación { ...cart } solo realiza una copia superficial. Crea un nuevo objeto en el nivel superior, pero updatedCart.items sigue apuntando al mismo array — y a los mismos objetos de elemento — que cart.items. Por lo tanto, cuando el bucle forEach modifica item.price, también está modificando de forma invisible los elementos del carrito original, ya que el operador de propagación nunca afecta nada más allá del primer nivel de la estructura.
console.log(cart.items[0].price); // already discounted, unintentionally
console.log(updatedCart.items[0].price); // same object, same value
Cualquiera que asumiera que el cart original permanecería intacto —una expectativa razonable dado que el nombre de la función implica que devuelve algo nuevo— termina trabajando con datos corruptos de forma silenciosa.
function applyDiscount(cart) {
return {
...cart,
items: cart.items.map((item) => ({ ...item, price: item.price * 0.9 })),
};
}
Cada capa anidada que necesite ser modificada debe copiarse explícitamente en esa capa. Una expansión superficial solo protege el nivel en el que actúa directamente, no nada anidado debajo de él.
Bug 4
function updateUserSettings(user, updates) {
return Object.assign({}, user, updates);
}
app.patch("/settings", (req, res) => {
const updated = updateUserSettings(req.user, req.body);
saveUser(updated);
res.json(updated);
});
Parece ser una rutina ordinaria de “combinar un objeto de actualización con uno existente”. Entonces, ¿dónde se esconde el peligro?
El bug: req.body proviene directamente del cliente, y aquí no hay nada que limite qué claves pueden integrarse en el objeto del usuario. Si el cuerpo de la solicitud contiene algo como "role": "admin" o "isVerified": true, esas propiedades se combinan con la misma facilidad que cualquier campo de configuración legítimo, ya que Object.assign no tiene concepto de qué claves deben ser modificables; combina todo lo que se le proporciona.
Esto se enmarca en una categoría bien conocida de vulnerabilidad llamada asignación masiva, y aparece con frecuencia en APIs que envían directamente los cuerpos de las solicitudes a los modelos de base de datos sin filtrarlos primero mediante una lista de permisos. El riesgo aumenta realmente a medida que la función de fusión se vuelve más genérica y reutilizable, lo cual es precisamente lo que hace que este tipo de código parezca fiable desde un principio.
function updateUserSettings(user, updates) {
const allowedFields = ["displayName", "timezone", "emailNotifications"];
const safeUpdates = {};
for (const field of allowedFields) {
if (field in updates) safeUpdates[field] = updates[field];
}
return { ...user, ...safeUpdates };
}
Al definir una lista de permisos explícita, garantiza que una solicitud nunca pueda afectar un campo para el cual no se haya autorizado específicamente su modificación, independientemente de las claves adicionales que alguien incluya en el payload.
Bug 5
async function getUserWithPosts(userId) {
const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
const posts = await db.query("SELECT * FROM posts WHERE user_id = $1", [userId]);
return { ...user, posts };
}
Nada en este código está técnicamente mal. Pero, ¿cuál es el costo de escribirlo de esta manera?
El problema — o más bien, la oportunidad perdida — es que estas dos consultas no dependen en absoluto una de la otra. La segunda consulta no necesita ningún resultado de la primera para poder ejecutarse. Al enlazarlas con llamadas secuenciales de await, el tiempo total de espera se convierte en la suma de las duraciones de ambas consultas, con una bloqueando a la otra sin motivo real.
async function getUserWithPosts(userId) {
const [user, posts] = await Promise.all([
db.query("SELECT * FROM users WHERE id = $1", [userId]),
db.query("SELECT * FROM posts WHERE user_id = $1", [userId]),
]);
return { ...user, posts };
}
El uso de Promise.all permite que ambas consultas se ejecuten de forma concurrente en lugar de una tras otra, por lo que el tiempo total se reduce aproximadamente al de la consulta más lenta, en lugar de ser la suma de ambas. Esto no es un error en el sentido de que genere resultados incorrectos, ya que la versión secuencial devuelve datos perfectamente precisos. Es un error porque desperdicia silenciosamente el rendimiento disponible, y es ese tipo de patrón que se utiliza por costumbre y rara vez se cuestiona, ya que nada en el código parece estar roto al leerlo casualmente.
Qué tienen en común los cinco
Cada uno de estos fragmentos se ejecutó sin errores, generó resultados razonables y pasaría sin problemas una revisión rápida. Ninguno de ellos se detectaría si su único criterio de prueba fuera “¿funciona cuando lo pruebo una vez?”. Para identificarlos se necesita un tipo específico de escepticismo: ¿este código asíncrono realmente espera todo por lo que parece estar esperando?; ¿esta operación de copia realmente duplica cada capa que necesita?; ¿esta lógica de fusión confía en datos en los que no debería confiar? Ese tipo de instinto no se desarrolla memorizando más sintaxis, sino tras haber sufrido las consecuencias de cada uno de estos cinco patrones exactos al menos una vez antes.
Lecturas relacionadas
- Errores comunes de JavaScript y TypeScript que roban el código en silencio — Explica problemas sutiles en JavaScript y TypeScript, desde comparaciones con NaN hasta cuestiones de sincronización asíncrona y coerción de tipos, que causan errores a pesar de parecer correctos.
- Cinco emboscadas de la Temporal API que vuelven a introducir errores con fechas — Aprenda cómo la Temporal API de JavaScript aún puede causar problemas relacionados con zonas horarias, serialización, precisión y duración, a menos que se corrijan cinco patrones comunes de uso incorrecto.