Inicio / Artículos / Conceptos erróneos comunes sobre async/await que causan errores en producción

Conceptos erróneos comunes sobre async/await que causan errores en producción

Explica nueve malentendidos sutiles sobre async/await, desde condiciones de carrera hasta rechazos no manejados, que dañan silenciosamente las aplicaciones JavaScript en el mundo real.

3620 palabras

Durante mucho tiempo, muchos desarrolladores asumen que tienen un dominio sólido de async/await simplemente porque lo utilizan con fluidez. Saber que una función async siempre devuelve una promesa, saber colocar await delante de una llamada que accede a la red, y saber que try/catch hace que el manejo de errores asíncronos sea más manejable puede parecer suficiente. En comparación con el código basado en callbacks, este estilo se ve ordenado y se lee casi como JavaScript síncrono tradicional: obtener al usuario, cargar su cuenta, actualizar la interfaz de usuario y capturar cualquier error que surja en el proceso. La sintaxis es tan intuitiva que resulta fácil no detenerse a analizar el modelo mental que está detrás de ella.

El problema real es que esta fluidez superficial solo abarca la forma de usar async/await, no las reglas subyacentes relacionadas con la gestión asíncrona. Es común tratar await como si congelara todo el programa, asumir que llamar a una función asíncrona vincula automáticamente su resultado con quien la llama, y creer que el código escrito en un estilo lineal, de arriba hacia abajo, no podría competir consigo mismo. Estas suposiciones rara vez causan daños visibles en demostraciones pequeñas, ya que solo se ejecuta una operación a la vez, las respuestas llegan rápidamente y los datos simulados se resuelven en un orden predecible. Los sistemas reales de producción son mucho menos tolerantes.

Los errores que finalmente aparecen no se parecen a los típicos fallos asíncronos descritos en los libros de texto. Una función devuelve antes de que algún efecto secundario haya finalizado realmente. Una solicitud obsoleta sobrescribe el estado establecido por una más reciente. Un bloque try/catch permite que una excepción pase desapercibida. Un trabajo en lote envía más tareas simultáneas de las que el sistema puede manejar realmente. Cada promesa, considerada por separado, parece estar perfectamente bien; sin embargo, el flujo de trabajo en su conjunto no se comporta en absoluto como se pretendía originalmente.

Puede llevar mucho tiempo asimilar por completo la lección: async/await no elimina la concurrencia en JavaScript. Lo único que hace es proporcionar a una función async una forma más legible de pausarse y reanudarse. Todo lo demás sigue dependiendo de qué promesas se devuelven, cuáles se esperan, cuáles se descartan en silencio, cuáles se cancelan o se intentan nuevamente, y cuáles pueden modificar el estado compartido a lo largo del proceso.

Pensé que await pausaba más de una función

Un error conceptual común es bastante sencillo. Cada vez que aparece await, es tentador pensar mentalmente que pausa todo lo que está a su alrededor. JavaScript nunca bloquea la pestaña del navegador ni el proceso de Node.js, pero es fácil asumir aún así que la lógica circundante esperará su turno de alguna manera.

Eso no es así como funciona. await solo suspende la función async específica en la que se encuentra. El control vuelve al entorno de ejecución mientras la promesa esperada sigue pendiente, y JavaScript continúa manejando todo lo demás que está listo para ejecutarse. Los listeners de eventos pueden activarse, los temporizadores pueden expirar, las solicitudes no relacionadas pueden resolverse e incluso otra llamada a la misma función puede iniciarse mientras tanto.

async function loadProfile() {
  console.log("Loading profile");

const profile = await fetchProfile();
  console.log("Profile loaded");
  return profile;
}
console.log("Before");
loadProfile();
console.log("After");

Nada en el código exterior espera solo porque loadProfile contenga un await. Al llamar a la función, esta se inicia de inmediato y devuelve una promesa. A menos que quien la llamó decida esperarla o devolver esa promesa, la ejecución simplemente continúa sin detenerse.

Eso podría sonar como un detalle técnico menor, pero sus efectos van mucho más allá de registros de consola desordenados. Un manejador de solicitudes podría enviar su respuesta mientras una escritura en el registro de auditoría aún está en proceso. Una herramienta CLI podría cerrarse mientras una escritura en un archivo sigue pendiente. Una prueba podría indicar éxito antes de que se ejecuten las afirmaciones incluidas dentro de una devolución asincrónica. La función asíncrona se comporta exactamente como fue diseñada; la verdadera deficiencia es que quien la llama nunca vinculó su propia finalización con la de esa llamada asíncrona.

Por lo tanto, la pregunta importante no es si una función utiliza await internamente. Es si la promesa que representa toda la operación está realmente conectada al código que necesita saber cuándo se ha completado.

Llamé a funciones asíncronas sin decidir quién se encargaría de su finalización

Un error que aparece constantemente es ejecutar una función asíncrona sin esperar su resultado ni devolverlo. A veces se trata de una simple omisión. Otras veces, la tarea parece lo suficientemente poco importante como para dejarla ejecutarse en segundo plano.

El verdadero problema no es necesariamente que la tarea se ejecute de forma independiente, sino que nadie ha decidido realmente quién es responsable de su resultado.

Imagínese que se guarda un pedido y luego se envía una notificación. Si esa notificación debe funcionar sin falta para que toda la operación se considere completada, quien llama a la función debe esperar su resultado. Si realmente es opcional, dejarla ejecutarse por sí sola podría estar bien, pero en caso de rechazo aún hay que hacer algo al respecto. Simplemente descartar la promesa devuelta no convierte mágicamente esa llamada en una tarea en segundo plano fiable; solo priva a quien llama de cualquier forma de saber qué sucedió después.

Esto se vuelve especialmente arriesgado en el código de backend. Una función podría devolver una respuesta exitosa aunque algún efecto secundario asíncrono posterior haya fallado. El usuario pensaría que la acción tuvo éxito, mientras que el sistema no cuenta con un registro duradero que indique que aún queda trabajo pendiente. Si el proceso se reinicia en ese momento, ese trabajo pendiente simplemente desaparece.

Es útil tratar cada promesa no esperada como una elección arquitectónica y no como algo pensado después. O bien la operación actual es responsable del resultado y debe esperar a que se complete, o bien alguna otra capa se hace cargo de ello y necesita un mecanismo adecuado para rastrear su finalización, los intentos de repetición y las fallas. No hay nada malo en el trabajo en segundo plano realmente intencional, pero no debería surgir solo porque alguien olvidó escribir await.

Una promesa que nadie está vigilando no es, por diseño, una tarea en segundo plano. Simplemente se trata de trabajo sin dueño.

Tratar forEach como si comprendiera las promesas

Uno de los primeros patrones asíncronos que rompe silenciosamente el modelo mental habitual es combinar forEach con una función de callback asíncrona. La sintaxis parece perfectamente razonable, lo que hace fácil pasar por alto el motivo por el cual la función anfitriona finaliza antes de que se haya realizado realmente cualquier trabajo real.

async function saveUsers(users) {
  users.forEach(async (user) => {
    await saveUser(user);
  });

  console.log("All users saved");
}

Puede aparecer un mensaje de finalización antes de que se haya guardado siquiera un registro de usuario. La razón es que forEach invoca la función de callback pero descarta cualquier valor que esta devuelva. Una función de callback asíncrona siempre devuelve una promesa, por lo que cada llamada genera silenciosamente una promesa a la que nadie mantiene una referencia. Al no haber nada más por lo que esperar, la función externa se resuelve de inmediato, independientemente de lo que estén haciendo aún sus llamadas internas.

Esto en realidad no es un error específico de forEach. Proviene de asumir que pasar una función asíncrona a cualquier método de array actualiza automáticamente el contrato de dicho método para que entienda las promesas. Eso no ocurre. Un auxiliar de iteración síncrona sigue siendo síncrono sin importar el tipo de función que se le pase, a menos que ese auxiliar esté diseñado específicamente para coordinar tareas asíncronas.

La misma trampa se presenta con map, filter y reduce. Utilizar map con una función de callback asíncrona devuelve un array lleno de promesas pendientes, y no un array con los valores finales. filter nunca espera a que se evalúe un predicado asíncrono, por lo que evalúa los propios objetos de promesa —que siempre son verdaderos— en lugar de los resultados que eventualmente producen. Es posible crear una versión asíncrona de reduce que funcione correctamente, pero suele ser complicado de seguir, ya que el acumulador en sí mismo es una promesa que debe desempaquetarse con cuidado en cada iteración.

Una vez que esto queda claro, escribir código correcto deja de tratarse sobre memorizar qué método de array está “permitido” y pasa a consistir en elegir el modelo de ejecución que realmente requiere la tarea. Cuando cada paso depende genuinamente de que el anterior termine, un bucle for...of con await dentro expresa directamente esa intención. Cuando los pasos son independientes y pueden realizarse al mismo tiempo, mapearlos en un array de promesas y esperarlos todos juntos con Promise.all suele ser la opción adecuada. Y cuando es necesario limitar la concurrencia, ni un bucle simple ni un Promise.all ilimitado logran el objetivo.

La sintaxis debe derivarse de la necesidad operativa, y no al revés. Es tentador elegir primero el patrón que parezca más idiomático y esperar que del mismo surja el comportamiento deseado, pero ese orden de operaciones es incorrecto.

Suponiendo que Promise.all aceleró las cosas sin riesgo

Después de descubrir que forEach nunca espera, el siguiente paso lógico es recurrir a Promise.all, que generalmente es la herramienta adecuada: convertir los elementos en operaciones asíncronas, pasar las promesas resultantes a Promise.all y esperar a que todo el conjunto se resuelva al mismo tiempo.

Ese patrón funciona bien cuando las operaciones individuales no dependen unas de otras, el tamaño del lote es razonable y un único fallo debe invalidar todo el resultado. Los problemas surgen cuando se utiliza fuera de esos límites.

Promise.all no limita automáticamente nada. La mayor parte del trabajo subyacente comienza en el momento en que se crean las promesas, por lo que al procesar unos pocos miles de registros se pueden enviar unas pocas mil consultas a la base de datos o solicitudes externas casi simultáneamente. Este tipo de código puede funcionar bien con un pequeño conjunto de datos local, pero luego puede agotar los pools de conexiones, alcanzar los límites de tasa o consumir toda la memoria al enfrentarse a volúmenes de datos propios del entorno de producción.

Su manejo de errores también puede ser sorprendente. Cuando una promesa del grupo falla, las demás no se detienen automáticamente. Siguen ejecutándose, lo que significa que aún pueden escribir registros, enviar mensajes o modificar el estado externo incluso después de que el código que las llama ya haya entrado en el bloque catch. Afirmar que “el lote falló” es técnicamente preciso, pero eso no implica que no haya ocurrido ningún efecto secundario.

Al intentar nuevamente todo el lote posteriormente, se puede volver a realizar el trabajo que ya tuvo éxito en la primera pasada. En ese punto, el problema ya no es la mecánica de las promesas, sino la idempotencia y el manejo de completaciones parciales.

Antes de recurrir por defecto a Promise.all, es útil plantearse otro conjunto de preguntas: ¿Cuántas operaciones es realmente seguro ejecutar al mismo tiempo? ¿Son verdaderamente independientes entre sí? ¿Qué debería significar un fallo para el resto del lote? ¿Existe alguna forma de detener el trabajo que ya ha comenzado? Si se produce un intento de repetición, ¿podrían duplicarse los elementos que ya se completaron? ¿Necesita realmente el llamante cada uno de los resultados, o sería aceptable un éxito parcial honesto?

A veces Promise.all sigue siendo la solución adecuada. Otras veces Promise.allSettled es más apropiado. En otras situaciones, se requiere un bucle secuencial, un limitador de concurrencia, una cola o una transacción que envuelva toda la operación. Cuál es la opción correcta depende de las garantías que deba ofrecer la operación, no de lo rápido que parezca el código a primera vista.

Suponiendo que el código leído de arriba hacia abajo no puede presentar condiciones de carrera

Tal vez el malentendido más costoso sea creer que await protege al código de las condiciones de carrera. Dentro de una misma función, las instrucciones después de un await se ejecutan en orden, lo que hace que la función parezca una secuencia continua. Pero con múltiples llamadas concurrentes a esa misma función, varias ejecuciones pueden superponerse fácilmente.

Imagínese dos solicitudes que cada una lee un saldo, calcula un nuevo saldo y lo guarda. Ambas solicitudes esperan a que se complete la lectura y también esperan a que se realice la escritura. Cada línea dentro de cada llamada se ejecuta en el orden esperado. Sin embargo, la condición de carrera aparece igualmente, porque ambas solicitudes pueden leer el mismo saldo inicial antes de que cualquiera de ellas haya escrito su actualización.

La misma clase de error se presenta en el frontend. Se realiza una búsqueda para una consulta anterior, y luego otra búsqueda para una más reciente. La solicitud más reciente se resuelve primero y actualiza correctamente la interfaz. La solicitud anterior finaliza después y sobrescribe ese estado correcto con un resultado obsoleto. Cada llamada esperó a que se completara su propia consulta tal como estaba previsto; lo que falta es una regla que indique qué llamada aún tiene autoridad para actualizar la interfaz.

Esto es algo que vale la pena tener en cuenta para cada await que se encuentra entre la lectura de un estado y la acción que se realiza sobre él. La pausa no es simplemente un intervalo inofensivo en el tiempo; es un período durante el cual pueden ejecutarse otras tareas que podrían invalidar las suposiciones hechas por la función justo antes de pausarse.

Las verificaciones realizadas a nivel de aplicación no sobreviven automáticamente a ese período. Aunque se confirme que un nombre de usuario está disponible y luego se inserte, eso no ofrece protección contra dos solicitudes que realicen la misma verificación casi al mismo tiempo. Verificar que un registro sigue pendiente antes de aprobarlo no garantiza que otro proceso no haya modificado ya su estado en ese intervalo.

La protección real suele tener que provenir de un nivel inferior: una restricción de unicidad a nivel de base de datos, una actualización condicional, el bloqueo optimista, una clave de idempotencia, una transacción o reglas explícitas de propiedad aplicadas en la interfaz de usuario. await solo gestiona la finalización de una promesa específica. No hace nada para bloquear el estado compartido ni para garantizar que un valor leído anteriormente siga siendo válido cuando se actúe sobre él.

Esperar que try/catch maneje operaciones que nunca se esperaron

Otro error común parece completamente seguro durante la revisión del código. Una llamada asíncrona se encuentra dentro de un bloque try/catch, y parece razonable asumir que cualquier rechazo será manejado allí.

try {
  sendAnalyticsEvent(event);
} catch (error) {
  logError(error);
}

Cuando sendAnalyticsEvent es una función asíncrona que rechaza después de haber devuelto ya su promesa, el bloque catch circundante nunca percibe ese fallo. La parte síncrona de la llamada tuvo éxito en el momento en que devolvió una promesa. El rechazo ocurre posteriormente, fuera del marco de pila que está monitoreando el bloque try.

El bloque catch solo funciona si se espera la promesa desde dentro de él:

try {
  await sendAnalyticsEvent(event);
} catch (error) {
  logError(error);
}

Dicho de forma sencilla, la diferencia parece obvia, pero la versión anterior parece segura precisamente porque la llamada asíncrona se encuentra visualmente dentro de un manejador de errores. La disposición sugiere una relación que el código en realidad nunca establece.

Esa misma situación se presenta dentro de las funciones de callback y los manejadores de eventos. Una función de callback asíncrona puede rechazarse internamente, pero si el código que la invoca nunca espera ni inspecciona la promesa que devuelve, ese rechazo no tiene adónde ir. Envolver el lugar de la llamada en un try/catch no ayuda, porque el rechazo pertenece a una promesa que nada en el ámbito está monitoreando.

La regla fundamental es que los errores en el código asíncrono se transmiten a través de las promesas, no por la indentación. Para capturar un rechazo es necesario esperar directamente a la promesa, devolverla dentro de una cadena que ya tenga un manejador, o adjuntar una función de callback explícita para el rechazo. Si se descarta la promesa, su ruta de error también se descarta junto con ella.

Esto también es importante para los bloques de captura que absorben demasiado. Convertir cada error en un valor por defecto null genera otro problema: los llamantes ya no pueden distinguir entre un error real y un resultado legítimamente vacío. Simplemente capturar el error no es el objetivo; el código debe preservar el significado de ese error para quien lo utilice a continuación.

Confundir la cancelación con la reversión

Cuando un equipo adopta por primera vez AbortController, es tentador asumir que cancelar una solicitud significa que el trabajo subyacente realmente se ha detenido. En el caso de las solicitudes realizadas desde el navegador, la cancelación evita que el cliente siga esperando y, a menudo, impide un procesamiento adicional innecesario en el propio cliente. Esto es realmente útil para cosas como búsquedas en tiempo real, salir de una página o descartar una lectura que ya no es relevante.

Lo que no garantiza es que el trabajo ya enviado al servidor sea deshecho.

Si una solicitud de escritura se aborta después de que el servidor ya la haya recibido, el backend puede seguir completando la actualización de la base de datos o la llamada externa sin importar nada. Lo único que realmente sabe el cliente es que dejó de esperar la respuesta. Volver a intentar esa solicitud sin algún tipo de protección de idempotencia conlleva el riesgo de repetir una operación que ya tuvo éxito en el lado remoto.

Esta distinción existe porque la cancelación en el lado del cliente y el estado en el lado del servidor se encuentran en extremos opuestos de una frontera de red. El cliente puede retirar su interés en un resultado, pero no tiene forma de trascender esa frontera y deshacer los efectos que ya se han producido.

La cancelación debe considerarse más bien como una señal sobre la relevancia que como una garantía respecto al estado. Indica al resto de la aplicación que quien llama ya no está interesado en un resultado específico, por lo que ese resultado no debería permitirse sobrescribir el estado actual. En las operaciones de lectura, abortar la solicitud es una optimización razonable. En las escrituras, aún se necesita un mecanismo separado para saber si la operación finalizó, si sigue en curso o si puede intentarse nuevamente sin duplicar su efecto.

La cancelación aborda si se sigue deseando un resultado. No aborda si la acción subyacente sigue siendo consistente.

Escribir sintaxis asíncrona antes de definir el flujo de trabajo

El hilo conductor de todos estos errores es tratar el diseño asíncrono como si fuera únicamente una cuestión de sintaxis, decidiendo si utilizar await, Promise.all o una función de callback asíncrona antes de determinar qué garantías realmente necesita ofrecer la operación.

Las preguntas más útiles surgen antes. ¿Necesita quien llama esperar a que esto termine para continuar? ¿Pueden ejecutarse múltiples tareas simultáneamente de forma segura? ¿Importa su orden relativo? ¿Cuál es la respuesta adecuada cuando algunas tienen éxito y otras no? ¿Es seguro intentar la operación de nuevo? ¿Quién es responsable de manejar el error? ¿Podría un resultado obsoleto seguir sobrescribiendo el estado compartido? Y cuando algo se agota el tiempo, ¿significa eso que la operación falló, o solo que este llamante en particular dejó de esperar?

Una vez respondidas esas preguntas, el código JavaScript en sí suele encajar sin mucha dificultad.

Una secuencia donde el orden es realmente importante puede utilizar un bucle que espere cada paso por turnos. Las tareas independientes con un número conocido y limitado pueden ejecutarse de forma concurrente. Los lotes más grandes se pueden procesar con un límite de concurrencia. El trabajo que no necesita bloquear al llamante puede ser transferido a una cola duradera. Las actualizaciones del estado compartido pueden controlarse mediante verificaciones explícitas de propiedad. Las escrituras en la base de datos pueden aplicar sus invariantes de forma atómica en la capa de almacenamiento. Las operaciones en las que un duplicado sería costoso pueden utilizar claves de idempotencia para que un intento de repetición no cree una segunda copia del mismo efecto.

La parte difícil nunca es colocar correctamente el await; se trata de determinar qué significa realmente “terminado” en cada punto de transición de la operación.

Saber la palabra clave pero no el contrato

Cometer errores al usar async/await durante años tiene menos que ver con olvidar cómo funciona la sintaxis, y más con el hecho de que hace que el código asíncrono parezca mucho más secuencial, local y predecible de lo que realmente es.

Ver un await lleva a suponer que todo lo que está a su alrededor también se ha detenido. Es fácil pasar por alto el hecho de llamar a una función asíncrona sin decidir quién será responsable de su finalización. Ejecutar métodos síncronos de array con callbacks asíncronos los trata como si ambos se entendieran, cuando en realidad no es así. Recurrir a Promise.all sin pensar en la carga o en lo que ocurre cuando solo algunas promesas tienen éxito es un hábito común pero arriesgado. Confiar en código que se lee de arriba hacia abajo, incluso cuando varias llamadas pueden superponerse realmente en el tiempo, oculta los problemas de concurrencia justo delante de nuestros ojos. Esperar que try/catch capture las rechazos de promesas que nunca se esperaron, y confundir una solicitud cancelada con prueba de que no ocurrió nada en el servidor, provienen ambos del mismo punto ciego.

Cada uno de estos errores se debe al mismo problema: la brecha entre cómo se ve el código y lo que realmente promete.

Entender bien el código asíncrono implica seguir el rastro de las promesas más allá de los límites de las funciones, en lugar de limitarse a leerlo de arriba hacia abajo. Significa averiguar quién las crea, quién las espera, quién es responsable de manejar los rechazos y qué parte del estado sigue teniendo autoridad sobre el resultado una vez que todo se resuelve. Implica prestar mucha atención a cada punto en el que await genera una pausa, porque esa pausa es precisamente donde otro trabajo puede cambiar las suposiciones en las que se basaba la función.

El código puede seguir pareciendo secuencial en la página. Esa apariencia ya no significa que el sistema se comporte de esa manera.

Este cambio convierte al JavaScript asíncrono de un conjunto de palabras clave en un modelo basado en la propiedad, el tiempo y los fallos. También explica por qué el código que parecía correcto durante años puede seguir comportándose de manera errónea en producción a pesar de superar todas las pruebas.

Aprender a esperar una promesa es la parte fácil.

Comprender qué está haciendo el resto del programa mientras se produce esa espera lleva mucho más tiempo.

Lecturas relacionadas

  • Errores comunes de JavaScript y TypeScript que roban el código en silencio — Explica problemas sutiles de JavaScript y TypeScript, desde comparaciones con NaN hasta cuestiones de temporización asíncrona y coerción de tipos, que causan errores a pesar de parecer correctos.
  • Concepciones erróneas comunes de Node.js y bases de datos que causan errores en producción — Aprenda por qué async/await, el pooling de conexiones y los ORMs no evitan automáticamente las condiciones de carrera, el agotamiento de conexiones o la inyección SQL en aplicaciones Node.js.
  • Nueve patrones de Promise para un JavaScript asincrónico confiable en producción — Aprenda patrones prácticos de Promise: solicitudes paralelas, tiempos de espera, reintentos, límites de concurrencia y cancelación, para crear un JavaScript asincrónico resistente y apto para producción.
  • Arreglando errores en el manejo de errores con Async/Await en código de producción de Node.js — Conozca cinco errores comunes al manejar errores con async/await en JavaScript y Node.js que causan fallos silenciosos y condiciones de carrera, además de soluciones concretas.
  • Siete idiomas comunes de JavaScript que introducen en silencio problemas futuros — Explica cómo los patrones cotidianos de JavaScript como las comprobaciones de valor verdadero, la cadena opcional y la sintaxis de propagación ocultan suposiciones que se rompen en silencio a medida que el código evoluciona.