Inicio / Artículos / Siete suposiciones ocultas que hacen que JavaScript falle bajo tráfico real

Siete suposiciones ocultas que hacen que JavaScript falle bajo tráfico real

Aprenda qué suposiciones sobre tipos, reintentos, concurrencia, orden de las respuestas, volumen de datos, datos faltantes y estado en memoria hacen que el código JavaScript falle una vez llegue a producción.

3164 palabras

La mayor parte del JavaScript que falla en producción nunca tuvo un error como el que se produce por una sintaxis incorrecta. Era correcto bajo un conjunto de condiciones que solo existían en la computadora portátil del desarrollador: conjuntos de datos pequeños, respuestas rápidas, un usuario cuidadoso y un solo proceso. A continuación se presentan siete de esas condiciones ocultas, cómo cada una falla bajo tráfico real, y los cambios de diseño concretos que permiten convertirlas en decisiones explícitas y probadas, en lugar de sorpresas durante un incidente.

Por qué el desarrollo local es un mal indicador

Un entorno de desarrollo es excepcionalmente tolerante. La base de datos contiene solo unas pocas filas, las solicitudes llegan en milisegundos, nadie hace clic en nada dos veces, y cada servicio se ejecuta en la misma máquina o en una red local fiable. El código escrito bajo esas condiciones puede parecer sólido durante semanas: las funciones se ejecutan sin problemas, se espera el cumplimiento de cada promesa, el conjunto de pruebas da resultados positivos y la consola permanece en silencio.

La producción modifica los datos de entrada en lugar del idioma. Los registros fueron generados por cinco versiones diferentes de la aplicación y no todos tienen la misma estructura. La gente hace doble clic porque parece que la pantalla está atascada. Las respuestas llegan en un orden diferente al de las solicitudes enviadas. Las APIs de terceros son lentas, devuelven cargas parciales o se agotan el tiempo de espera. Varias instancias del mismo servicio actualizan las mismas filas al mismo tiempo. Los campos que siempre se completaban localmente aparecen como "", null, un valor de enum que ya no se utiliza desde hace dos versiones, o como una cadena en la que debería estar un número.

Nada de esto empeora el código después del despliegue. Simplemente elimina las condiciones que hacían que los límites débiles parecieran seguros. La buena práctica es dejar de preguntarse únicamente “¿funciona esto con la entrada esperada?” y empezar a analizar qué asume en silencio el código respecto al tiempo, el volumen, la propiedad, la estructura de los datos y las fallas. Muchas de esas suposiciones son perfectamente razonables. El riesgo radica en no expresarlas hasta que los usuarios reales las demuestren falsas.

1. Los valores no siempre tienen el tipo que parecen tener

Un valor en JavaScript puede cruzar varios límites y perder su tipo original en cada uno de ellos. Un ID numérico de base de datos se convierte en cadena al encontrarse dentro de una URL. Una casilla de verificación llega al servidor como "true" o "false". Un campo en blanco se convierte en "" mientras que el backend esperaba null. Una marca de tiempo ISO viaja como texto plano y es tratada como si fuera un Date simplemente porque parece serlo.

A nivel local esto rara vez ocurre, ya que el mismo desarrollador escribe ambos extremos de la solicitud y los datos de prueba ya están adaptados para la función que se está probando. En producción, las entradas provienen de clientes móviles antiguos, del comportamiento nativo de los formularios del navegador, de integraciones con socios, de cargas cacheadas obsoletas y de registros que alguien editó a mano en una herramienta administrativa.

La coerción produce respuestas que parecen casi correctas

Los errores graves ocurren cuando JavaScript realiza una conversión plausible en lugar de lanzar un error. "10" se compara correctamente con números usando < y >, pero "10" + 1 produce "101". La cadena "false" se considera verdadera, por lo que una bandera destinada a desactivar una función puede terminar activándola. Una cadena vacía se considera “faltante” en una función auxiliar y como un valor legítimo en otra.

Nada se cae. La paginación salta la página incorrecta, una opción deshabilitada se vuelve seleccionable, o userId === record.ownerId falla silenciosamente porque un lado es un número y el otro una cadena. Entonces los equipos corrigen cada síntoma donde aparece, y la base de código acumula gradualmente varias reglas de normalización ligeramente diferentes.

Normalice una sola vez, en el límite

Los sistemas robustos convierten los valores cuando cruzan un límite significativo y nunca más. Los parámetros de consulta y los cuerpos de solicitud se analizan para convertirlos en tipos internos validados antes de que la lógica empresarial los procese. Las respuestas de las APIs externas se mapean a modelos de dominio estables. La entrada de los formularios se convierte de forma intencionada, en lugar de dejar que dependa de la propiedad “truthiness” o de la coerción de operadores. El objetivo no es convertir JavaScript en un lenguaje estrictamente tipado, sino asegurarse de que el resto de la aplicación nunca tenga que adivinar qué significa un valor.

TypeScript documenta la estructura prevista, pero los tipos se eliminan en tiempo de ejecución y no permite verificar qué es lo que realmente llega a través de la conexión. Un manejador cuyo parámetro está tipado perfectamente aún puede recibir cualquier cosa. El patrón más seguro es utilizar un esquema en tiempo de ejecución junto con un tipo estático que describan el mismo contrato, idealmente con el tipo derivado del esquema. Las bibliotecas de esquemas como Zod hacen esto práctico, ya que una sola definición puede tanto validar en tiempo de ejecución como generar el tipo de TypeScript.

2. Un clic no significa una ejecución

La pantalla muestra un único botón, por lo que resulta natural imaginar una sola solicitud: el usuario hace clic, el servidor realiza el trabajo y la respuesta lo confirma. Las pruebas manuales refuerzan esta idea, ya que los desarrolladores hacen clic una vez y esperan pacientemente.

Los usuarios reales y las redes reales se comportan de manera diferente. Alguien hace clic nuevamente porque no apareció el indicador de carga. Una aplicación móvil vuelve a intentarlo después de que se interrumpa la conexión. Un proxy o balanceador de carga vuelve a enviar una solicitud tras un fallo temporal. Una cola de mensajes vuelve a entregar una tarea porque el proceso que la procesaba terminó la tarea pero se cayó antes de confirmarlo. Lo que parecía un único punto de entrada ahora tiene varias formas de ejecutarse dos veces.

Dónde realmente causan problemas las duplicaciones

Repetir una lectura suele ser inofensivo. Pero repetir la creación de cuentas, la reserva de inventario, un pago, una invitación o una exportación generada sí causa problemas: se obtienen filas duplicadas, múltiples correos electrónicos, el inventario se disminuye dos veces o al cliente se le cobra dos veces.

Desactivar el botón después del primer clic mejora la experiencia, pero no es una garantía. Es posible eludir las restricciones del cliente, intentar las solicitudes fuera de la interfaz de usuario, y ambas instancias del servicio pueden recibir la misma acción lógica. La protección en el frontend es una cortesía; el backend y la base de datos deben asegurar el cumplimiento de esta invariante.

Diseño de operaciones de escritura que toleran la repetición

Las operaciones de escritura importantes deben diseñarse bajo la premisa de que se intentarán más de una vez:

  • Una clave de idempotencia que permanece estable en todos los intentos permite al servidor tratarlas como una sola operación lógica.
  • Una restricción de unicidad en la base de datos impide los duplicados, incluso cuando dos solicitudes concurrentes superan la verificación a nivel de aplicación de “¿existe ya?”.
  • Una tabla con los IDs de mensajes procesados permite al consumidor de colas omitir un evento que ya haya aplicado.

La idea clave es que un tiempo de espera agotado o una respuesta de error no demuestran que la operación falló. Es posible que el servidor haya completado el trabajo después de que el cliente dejara de esperar. Volver a intentarlo sin una identidad estable para la operación convierte esa incertidumbre en duplicaciones. Un sistema fiable no es aquel que evita todas las repeticiones; es aquel en el que una repetición no cambia lo que la operación significa en última instancia. La mecánica del lado del servidor se explica con más detalle en entendiendo las claves de idempotencia en los endpoints POST de Node.js.

3. El código con await aún puede sufrir competencia

async/await hace que una función se lea como una secuencia protegida: cargar el registro, verificar su estado, actualizarlo y devolverlo. Cada paso se espera, por lo que el flujo parece estar controlado. Sin embargo, ese control solo existe dentro de una única invocación.

Mientras una llamada está suspendida en un await, el motor de ejecución puede procesar otro manejador de solicitudes, callback de evento o tarea para la misma función. Dos invocaciones pueden leer el mismo estado, ambas determinar que la operación está permitida y ambas realizar escrituras. Cada una de ellas es correcta por separado, pero juntas violan la regla de negocio.

Carrera de verificación y acción en el servidor

Imagínese un endpoint de aprobación que carga un elemento pendiente y luego lo marca como aprobado. Dos administradores abren la misma pantalla y hacen clic en aprobar con unos segundos de diferencia. Ambas solicitudes leen el estado pending antes de que se realice cualquier actualización. La fila final puede parecer correcta, pero si la acción también envía una notificación o escribe un registro de auditoría, ambos efectos secundarios ocurren dos veces.

La misma carrera en el navegador

En la interfaz de usuario, el patrón se asemeja a un cuadro de búsqueda. Se inicia una solicitud para la consulta anterior, luego otra para la consulta más reciente, y la respuesta más reciente llega primero. La pantalla muestra los resultados correctos por un momento, hasta que llega la respuesta anterior y los sobrescribe. Ambas solicitudes tuvieron éxito y ambas actualizaciones de estado utilizaron datos válidos. Lo que faltaba era una decisión sobre qué solicitud seguía teniendo derecho a actualizar la pantalla.

Un await no toma un bloqueo, no congela los valores que se leyeron anteriormente ni coloca en cola las llamadas a la misma función. Suspende una ejecución precisamente para que el resto del trabajo pueda continuar.

Elegir un mecanismo de protección

La solución depende de dónde ocurre la competencia:

  • Una actualización condicional como “establecer el estado en aprobado cuando el estado sea pendiente”, verificando la cantidad de filas afectadas.
  • Concurrencia optimista con una columna de versión que debe coincidir.
  • Una transacción de base de datos con un nivel de aislamiento adecuado.
  • Una restricción de unicidad que hace que la segunda escritura falle de manera evidente.
  • Cancelación de solicitudes, o un identificador de operación que permita que solo el resultado más reciente modifique el estado compartido.
  • La creencia peligrosa es que el código escrito de forma secuencial implica un sistema que se comporta también de manera secuencial. JavaScript puede hacer que una sola función sea muy fácil de seguir, aun cuando muchas copias simultáneas de ella se ejecuten al mismo tiempo.

    4. Las solicitudes no finalizan en el orden en que comenzaron

    Dado que el código se escribe de arriba hacia abajo, resulta tentador razonar sobre el trabajo asíncrono según el orden de creación: la solicitud A se envió antes que la B, por lo que A debería devolverse primero. Las redes, los cachés, las bases de datos y los proveedores externos no hacen tal promesa.

    La primera solicitud podría enfrentarse a una consulta lenta, mientras que la segunda se sirve desde el caché. Una región del proveedor responde de inmediato, mientras que otra vuelve a intentarlo internamente. Una carga grande tarda más en procesarse, incluso si el servidor responde antes.

    Las respuestas obsoletas son datos correctos en el momento equivocado

    Esto se convierte en un error en cuanto el orden de finalización determina el estado actual. Los resultados de búsqueda, la validación de formularios, los cargadores de rutas, los paneles de control y el autocompletado son las víctimas habituales: el usuario avanza, pero una respuesta tardía hace que la interfaz retroceda. Los datos de esa respuesta no están equivocados; simplemente ya no son relevantes para lo que el usuario está viendo.

    El rebote ayuda al reducir la cantidad de solicitudes iniciadas, pero no elimina las superposiciones. Un usuario puede hacer una pausa lo suficientemente larga como para iniciar una solicitud y luego seguir escribiendo mientras esta aún está en tránsito, por lo que la solicitud anterior podría terminar por última.

    Determine quién es el propietario del resultado

    La solución fiable comienza con una regla de propiedad. En muchas interfaces, la solicitud más reciente debe tener prioridad; por lo tanto, o bien se cancelan las solicitudes anteriores o cada una se etiqueta con un número de secuencia y se descartan los resultados que ya no son actuales. Otros flujos de trabajo requieren un procesamiento por orden de llegada o un estado independiente para cada operación. La misma regla de propiedad también debe aplicarse al estado de carga y a los errores: una solicitud abandonada no debe eliminar el indicador de procesamiento de la solicitud activa ni mostrar un error para una consulta que el usuario ya reemplazó. Para conocer cómo se maneja esto específicamente en React, consulte Búsqueda en React con propiedad de estado clara.

    En entornos de producción no se respeta el orden en que se crearon las promesas. Su código debe decidir, en el momento de resolución, si ese resultado sigue siendo relevante.

    5. Las colecciones pequeñas no permanecen pequeñas

    Algunas transformaciones parecen inofensivas con unas pocas decenas de elementos: mapear un array y llamar a find en otro array para cada elemento, filtrar una lista con includes en relación con otra, o generar un informe filtrando toda la colección una vez por grupo.

    Con los fixtures de prueba, estas operaciones se completan al instante, y el código sigue siendo lo suficientemente breve como para que su costo algorítmico sea imperceptible. A medida que crece la producción, los datos aumentan sin cambiar la apariencia del código. Un find dentro de un map sobre dos colecciones de diez mil registros implica hasta aproximadamente cien millones de comparaciones. Un includes dentro de un bucle añade otro escaneo lineal por cada elemento. Un informe creado para un equipo de repente se ejecuta en toda la organización.

    Ajuste la estructura al patrón de acceso

    Los métodos de array no son el problema; lo que sí lo es es usar una estructura secuencial para búsquedas repetidas por clave o pruebas de pertenencia. Crea un Map indexado por id una sola vez, y cada búsqueda tardará en promedio tiempo constante. Utiliza un Set cuando la pregunta sea “¿está esto en la colección?”. A menudo, la mejor solución es una unión con una base de datos, así que nunca cargues ambas colecciones en memoria ni las combines en el código de la aplicación.

    Esto no significa que debas reemplazar cada array. Crear un índice tiene su propio costo, y para una lista realmente pequeña find podría ser la opción más clara. El cálculo cambia cuando la operación se ejecuta con frecuencia o cuando los datos pueden crecer considerablemente.

    También escalan la memoria y la concurrencia

    El volumen también afecta la memoria y el uso de recursos. Cargar cada fila antes de filtrar, enlazar varias llamadas a map y filter que asignan un nuevo array en cada ocasión, o ejecutar una promesa por elemento con Promise.all puede funcionar bien en entornos locales, pero resultar destructivo en producción. El proceso podría quedarse sin memoria, agotar el pool de conexiones a la base de datos o ocupar completamente el bucle de eventos, lo que ralentiza todas las solicitudes que esperan su procesamiento. La paginación, el streaming y la limitación de la concurrencia son las soluciones habituales.

    La mayoría de los problemas de rendimiento se deben a que el código ordinario se enfrenta a un volumen extraordinario. Conozca la escala esperada, evite realizar tareas innecesarias y analice el camino real antes de recurrir a optimizaciones complejas. A menudo, la línea de código más costosa es aquella que parece demasiado familiar como para cuestionarla.

    6. La falta de datos no demuestra que no haya habido errores

    JavaScript hace que los mecanismos de solución alternativa sean sencillos. La cadena opcional evita errores al acceder a propiedades, la unión con valor nulo proporciona valores por defecto, y un bloque catch puede devolver un array vacío. Estos recursos son valiosos cuando se espera y se comprende la ausencia de algo. Se vuelven perjudiciales cuando borran la diferencia entre “no hay nada” y “no pudimos averiguarlo”.

    Cuando las soluciones alternativas ocultan incidentes

    Una consulta en el panel de control falla porque la base de datos está inactiva, el servicio devuelve [], y la interfaz muestra amablemente “No se encontraron registros”. Una verificación de permisos falla, la cadena opcional genera undefined, y el código lo trata como un false normal. Una respuesta mal formada produce undefined, que a su vez se convierte en un objeto por defecto tres niveles más abajo. La aplicación parece estable porque nunca se cae, pero le muestra a los usuarios algo que en realidad no sabe.

    Estos resultados no son intercambiables:

    • Una consulta que se ejecutó y devolvió cero filas frente a una consulta que nunca se ejecutó.
    • Un avatar opcional que falta frente a un objeto de usuario que falta.
    • Un rechazo deliberado por parte del negocio frente a un tiempo de espera de red.

    Cada uno puede necesitar su propio mensaje de usuario, política de reintentos, alertas y plan de acción para soporte. En producción, estos fallos son algo habitual y no teórico: las dependencias dejan de funcionar, cambian los permisos, las implementaciones progresivas ejecutan brevemente versiones mixtas, y los datos antiguos violan las nuevas reglas. Si cada resultado irregular se trata como “nulo”, los incidentes permanecen invisibles hasta que algún otro indicio se vuelve lo suficientemente evidente como para ser detectado.

    Define primero el contrato y luego agrega sintaxis defensiva

    Utilice la cadena opcional cuando un valor sea realmente opcional. Use una solución alternativa cuando el sistema cuente con una opción válida que ofrecer. Al capturar errores, tradúzcalos a categorías estables como no encontrado, prohibido, indisponible o inválido, pero mantenga la causa y el contexto originales para los registros y el monitoreo. La degradación elegante debe mantener la aplicación útil mientras mantiene la fidelidad a la realidad; nunca debe hacer que el sistema parezca exitoso al devolver un valor plausible.

    7. El estado en memoria no se comparte en toda la aplicación

    El ámbito de módulo hace que el estado en memoria sea conveniente. Una variable de nivel superior puede contener un caché, rastrear tareas en ejecución, contar solicitudes para el control de velocidad o recordar si ya se realizó la inicialización. A nivel local, un único proceso atiende cada solicitud, por lo que esa variable se comporta como un estado global de la aplicación.

    En producción, el mismo código puede ejecutarse en varios procesos, contenedores, instancias serverless o regiones, y cada uno tiene su propia memoria:

    • Una caché actualizada en una instancia sigue estando desactualizada en las demás.
    • Una marca de “ya inicializado” solo protege al proceso que la estableció.
    • Un limitador de velocidad en memoria permite que un cliente supere el límite simplemente accediendo a instancias diferentes.
    • Un temporizador programado en memoria desaparece cuando se reinicia el contenedor.

    Incluso un proceso es menos permanente de lo que parece. Cada despliegue lo reinicia, las plataformas serverless congelan y reciclan instancias, los fallos borran todo lo que no se ha persistido, y bajo presión de memoria la plataforma puede terminar el proceso sin darle la oportunidad de enviar a procesar el trabajo pendiente.

    Dale al estado el alcance que realmente necesita

    El estado en memoria sigue siendo excelente para cachés por proceso, optimizaciones locales, coordinación de corta duración dentro de una solicitud y cualquier valor cuya pérdida sea aceptable. Los problemas comienzan cuando se le asignan responsabilidades que requieren autoridad a nivel de todo el sistema o durabilidad. Los límites de tasa compartidos suelen ir en un almacén central como Redis. Las tareas que deben sobrevivir a los reinicios deben encontrarse en una cola o base de datos. Los bloqueos distribuidos necesitan un mecanismo que todos los procesos competidores puedan ver. La configuración crítica debe provenir de una fuente fiable, no de una variable mutable en una única instancia.

    Las preguntas que hay que hacerse se refieren al alcance y a la vida útil. ¿Este estado existe por cada llamada a función, por cada sesión de usuario, por cada proceso, por cada despliegue, o en todo el sistema? ¿Y qué le sucede cuando el proceso desaparece? En la mayoría de las arquitecturas modernas, un entorno de ejecución de JavaScript no es la aplicación; es solo un participante temporal dentro de un sistema más grande.

    La producción es más honesta, no más aleatoria

    Es tentador calificar la producción como impredecible. Es más preciso decir que finalmente proporciona el momento oportuno, la escala, los datos históricos, los usuarios concurrentes y los límites de infraestructura que la aplicación siempre estuvo destinada a manejar. JavaScript sigue exactamente las mismas reglas: las cadenas no vacías se consideran verdaderas, las funciones asíncronas se superponen entre llamadas, los arrays se buscan de forma lineal y las variables de módulo pertenecen a un único entorno de ejecución. Las sorpresas surgen de suposiciones que las condiciones locales nunca obligaron a nadie a probar.

    El código fiable no intenta defenderse contra cada escenario imaginable. Identifica las suposiciones que resultarían costosas si resultaran falsas y las hace explícitas. El código adicional suele ser pequeño; el verdadero beneficio es eliminar la ambigüedad. El próximo desarrollador puede ver qué valores son válidos, qué operación genera un resultado, qué significa el éxito y si es seguro intentarlo de nuevo.

    Puntos clave

    • Analice y valide los valores externos una sola vez en el límite, manteniendo sincronizados los esquemas en tiempo de ejecución y los tipos estáticos.
    • Trate cada escritura importante como algo que podría ejecutarse más de una vez, y asegúrese de la unicidad donde se almacenan los datos.
    • Tenga en cuenta que await suspende una llamada; no serializa un sistema ni bloquea el estado compartido.
    • Decida qué resultado asíncrono es responsable del estado actual, incluidos los indicadores de carga y errores.
    • Elija las estructuras de datos y consultas según el volumen que tendrá, no según la configuración con la que realice pruebas.
    • Mantenga los resultados “vacíos” y “fallidos” como situaciones distintas hasta llegar al usuario y a sus sistemas de monitoreo.
    • Almacene el estado en el alcance y con la durabilidad que realmente requiera, asumiendo que cualquier proceso individual puede desaparecer.

    Lecturas relacionadas