Inicio / Artículos / Más allá del tamaño del paquete: descubriendo qué es lo que realmente ralentiza tu aplicación web

Más allá del tamaño del paquete: descubriendo qué es lo que realmente ralentiza tu aplicación web

Por qué raspar kilobytes rara vez soluciona un aplicación lenta, y cómo rastrear el tiempo real de espera a través de servidores, procesos en cascada, mecanismos de hidratación, scripts de terceros e imágenes.

3024 palabras

Una escena familiar se repite en los equipos de frontend: se pasan semanas intentando reducir 40 KB de un paquete JavaScript, mientras que una consulta a la base de datos que tarda 900 milisegundos permanece sin modificar en el camino crítico de las solicitudes. Se cambia una biblioteca de iconos, se reemplaza una dependencia, se configura otro plugin de empaquetado, y surge un debate sobre si un paquete pesa 18 KB o 12 KB. Luego alguien carga la aplicación en un teléfono real a través de una red real, y sigue pareciendo lenta.

Este artículo explica por qué el tamaño del paquete suele convertirse en el objetivo equivocado, de dónde proviene realmente la demora y cómo ejecutar un ciclo de optimización que resuelva primero los problemas más graves. Los paquetes más pequeños sí ayudan, a veces mucho. Pero si la página es lenta porque espera en el servidor, bloquea el renderizado, realiza tareas innecesarias, envía demasiadas solicitudes o carga un árbol de componentes enorme, reducir otros 20 KB no la salvará.

Por qué el tamaño del paquete se convirtió en la meta de rendimiento por defecto

El tamaño del paquete es atractivo porque se trata de un número. Tu proceso de compilación muestra algo similar a esto:

main.js       842 KB
vendor.js     611 KB
styles.css     94 KB

Alguien propone que el JavaScript no supere los 500 KB, y de repente el equipo tiene un objetivo. Se puede hacer cumplir en CI, seguir su evolución a través de las solicitudes de integración y celebrar cada reducción. Eso parece progreso en ingeniería, y a veces realmente lo es.

Los problemas comienzan cuando el número deja de ser un síntoma y se convierte en el objetivo. Los equipos tienden a optimizar la parte del rendimiento que pueden ver con mayor claridad, no aquella que le cuesta más tiempo a los usuarios. Para entender por qué eso es importante, comparemos dos aplicaciones hipotéticas.

Aplicación A: paquete pequeño, todo lo demás lento

La primera aplicación incluye un paquete ligero:

JavaScript: 250 KB

Pero todo lo demás relacionado con ella es costoso:

Server response: 1.2s
Database query: 700ms
API calls before rendering: 5
Main-thread work: 900ms

Aplicación B: paquete grande, ruta rápida al contenido

La segunda aplicación incluye casi tres veces más JavaScript:

JavaScript: 700 KB

Pero tanto su servidor como su cliente realizan mucho menos trabajo antes de que la página sea útil:

Server response: 150ms
Database query: 40ms
API calls before rendering: 1
Main-thread work: 180ms

La Aplicación A no gana automáticamente. Una aplicación de tipo B suele parecer más rápida, ya que tarda aproximadamente un segundo menos en el servidor, en la base de datos, en solicitudes secuenciales y en tareas del hilo principal, lo cual eclipsa la descarga adicional para la mayoría de los usuarios con una conexión razonable. El principio en el que debe basarse cualquier discusión sobre rendimiento es simple: los usuarios no perciben kilobytes, sino el tiempo de espera.

La carga de la página es un proceso largo, no tres pasos

Muchos desarrolladores tienen en mente un modelo simplificado del proceso de carga:

Download JavaScript
        ↓
Execute JavaScript
        ↓
Page appears

Una solicitud real pasa por muchas más etapas, cada una de las cuales puede causar retrasos:

DNS
 ↓
Connection
 ↓
TLS
 ↓
Request
 ↓
Server processing
 ↓
Database
 ↓
Response
 ↓
HTML parsing
 ↓
CSS processing
 ↓
JavaScript download
 ↓
JavaScript parsing
 ↓
JavaScript execution
 ↓
Hydration
 ↓
API requests
 ↓
Rendering
 ↓
Layout
 ↓
Paint

La búsqueda DNS, la configuración de la conexión y la negociación TLS ocurren antes de que el servidor vea algo. Luego vienen el procesamiento del servidor y las operaciones en la base de datos. A continuación, el navegador analiza el HTML, procesa el CSS, descarga, analiza y ejecuta JavaScript, carga los datos dinámicamente, envía solicitudes a la API, y solo entonces renderiza, diseña y dibuja el contenido. Y cuando el usuario hace clic, gran parte de este ciclo se repite.

Con tantas etapas, el paquete de recursos es simplemente un lugar donde el tiempo puede desaparecer. “Hacer que el paquete sea más pequeño” es un primer paso deficiente, ya que asume la solución antes de saber dónde se está perdiendo el tiempo.

El servidor podría ser la parte más lenta de tu frontend

Los ingenieros de frontend suelen considerar el rendimiento como un problema del navegador. Abren las Herramientas de Desarrollo, inspeccionan la pestaña Network y los fragmentos de JavaScript, y ejecutan Lighthouse. Pero una gran parte de la velocidad percibida en el frontend se determina antes incluso de que el navegador reciba algo útil.

Tomemos una solicitud al panel de control:

GET /dashboard

Detrás de esto, el servidor podría hacer todo esto antes de responder:

→ authenticate user
→ fetch organization
→ fetch permissions
→ query projects
→ query project statistics
→ query notifications
→ calculate recommendations
→ render response

Si esa cadena de operaciones tarda 1.4 segundos, reducir el tamaño del paquete de 600 KB a 500 KB apenas cambia la experiencia, ya que los primeros bytes significativos siguen llegando a los 1.4 segundos. El navegador no puede renderizar datos que aún no ha recibido.

Un culpable frecuente es el código de backend que espera que se realicen operaciones independientes una tras otra. Comienza con la obtención de información del usuario:

const user = await getUser();

Y continúa con una cadena de esperas adicionales para la organización, los proyectos y las notificaciones, cada una esperando a que termine la anterior:

const organization = await getOrganization(user.orgId);const projects = await getProjects(organization.id);const notifications = await getNotifications(user.id);

Algunos de estos pasos realmente son dependientes: la búsqueda de la organización necesita user.orgId, y los proyectos necesitan el ID de la organización. Pero todo aquello que no depende de un resultado anterior está pagando su latencia en serie sin motivo alguno. Cuando las operaciones son independientes, iniciarlas juntas y esperarlas conjuntamente puede reducir drásticamente el tiempo de respuesta:

const [user, notifications] = await Promise.all([
  getUser(),
  getNotifications()
]);

Observe que la versión paralela llama a getNotifications() sin un ID de usuario. Eso solo funciona si las notificaciones pueden obtenerse a partir de algo ya disponible, como la sesión; de lo contrario, esa llamada aún debe esperar al usuario. La regla general es mapear el verdadero grafo de dependencias y ejecutar cada nivel de él de forma concurrente. Para más información sobre cómo elegir entre estos patrones, consulte nuestra guía sobre Promise.all, Promise.race y awaits secuenciales. Un cambio como este puede superar con facilidad el rendimiento de cualquier tarea de empaquetado.

Los enfoques en cascada cuestan más que bytes

Uno de los lugares más productivos para comenzar una investigación es la pestaña Red, no el analizador de paquetes. Un patrón muy común se ve así:

HTML
 ↓
JavaScript
 ↓
API A
 ↓
API B
 ↓
API C
 ↓
API D

Cada paso espera al anterior, y cada transmisión añade un viaje de ida y vuelta que genera latencia. Compárelo con un diseño en el que la primera respuesta ya incluye todo lo que necesita la página:

HTML
 ↓
API response containing everything required

La segunda versión puede transferir más bytes y, aun así, ser drásticamente más rápida. Los bytes y la latencia son problemas separados. Una respuesta de 100 KB que llega de inmediato puede superar a una de 20 KB que necesita cuatro viajes secuenciales antes de que la página pueda hacer algo útil, especialmente en redes móviles donde cada viaje es costoso.

Por lo tanto, cuando observe que un endpoint devuelve 300 KB, su primer impulso será reducir el volumen de los datos. Eso puede ser útil, pero una mejor pregunta inicial es por qué el usuario necesita esa respuesta antes de poder interactuar con la página. La respuesta suele revelar tareas que se pueden posponer o eliminar por completo.

El rendimiento de JavaScript es más importante que su tamaño

Otra trampa relacionada es confundir el tamaño del código JavaScript con la labor que realiza. Un paquete de 500 KB no es necesariamente un desastre. Lo que realmente importa es lo que el navegador debe hacer con él:

  1. Descargarlo.
  2. Analizarlo.
  3. Compilarlo.
  4. Ejecutarlo.
  5. Crear el estado de la aplicación.
  6. Construir los árboles de componentes.
  7. Asociar manejadores de eventos.
  8. Actualizar el marcado generado por el servidor.
  9. Volver a calcular el diseño.
  10. Dibujar el resultado final.

Dos aplicaciones con tamaños de paquete similares pueden diferir enormemente en cuanto al costo de ejecución. Considere una tabla con 5,000 filas: rara vez el problema radica en los datos en sí; lo habitual es que sea la renderización de los nodos DOM correspondientes a esas 5,000 filas interactivas. La solución no consiste en reducir 50 KB de código, sino en renderizar solo las unas 30 filas que se muestran actualmente. Esa técnica, la virtualización, mantiene la misma aplicación y los mismos datos al tiempo que elimina en gran medida el trabajo del navegador.

Cuando la hidratación se convierte en el cuello de botella

Esta distinción es especialmente evidente en React y otros frameworks de componentes que realizan la renderización en el servidor. La renderización en servidor permite mostrar rápidamente el HTML en pantalla, pero el navegador podría necesitar luego hidratar un árbol de componentes muy grande antes de que algo responda a las entradas del usuario:

HTML arrives quickly
        ↓
User sees content
        ↓
Browser starts hydration
        ↓
Large amount of JavaScript executes
        ↓
Page becomes interactive

La página parece lista mucho antes de estarlo realmente. Por eso, medir únicamente cuando el contenido aparece por primera vez puede engañar. Un panel de control con 200 componentes interactivos puede generar HTML perfectamente válido y, aun así, consumir una cantidad significativa de recursos del procesador durante el proceso de hidratación, lo que hace que los clics queden sin respuesta.

La pregunta relevante aquí no es si el paquete es demasiado grande, sino por qué tanto código debe volverse interactivo de inmediato. Algunos componentes podrían no necesitar JavaScript en el cliente en absoluto. Algunas interacciones pueden aislarse en secciones pequeñas. Algunos widgets pueden cargarse más tarde, y algunos componentes generados por el servidor podrían no necesitar nunca el proceso de hidratación. Técnicas como estas, que analizamos en nuestra reseña sobre el preprocesamiento parcial y el procesamiento concurrente, ofrecen resultados mucho mejores que discutir sobre una dependencia de 30 KB.

Los scripts de terceros suelen superar en importancia al propio código

Antes de lanzar una campaña de gran tamaño, verifica cuánto del código que se envía fue escrito por otra persona. Ejemplos típicos:

  • análisis
  • widgets de chat
  • mapas de calor
  • pruebas A/B
  • publicidad
  • Herramientas de soporte al cliente
  • Grabación de sesiones
  • Incrustaciones en redes sociales
  • Píxeles de marketing
  • Gestión de consentimientos

Cada uno de ellos puede añadir solicitudes, ejecución de scripts, tareas de diseño y actividad en la red. Irónicamente, estos scripts a menudo no pasan por ninguna revisión mientras los ingenieros invierten horas ajustando el código de la aplicación. Una página podría cargar un conjunto como este:

app.js
analytics.js
chat.js
tracking.js
experimentation.js
heatmap.js

El equipo celebra una reducción de 70 KB en app.js, aunque la página sigue ejecutando cientos de kilobytes de código de terceros. Por eso los presupuestos de rendimiento necesitan un alcance más amplio. En lugar de preguntarse cuán grande es el paquete, hay que preguntarse cuánto código debe procesar el dispositivo del usuario antes de que esta página sea útil. Las dos preguntas tienen respuestas muy diferentes.

Las imágenes pueden superar con creces todo el presupuesto destinado al JavaScript

Las imágenes de tamaño excesivo son otro punto ciego. Una sola imagen principal puede superar en importancia a todo un bloque de JavaScript optimizado:

main.js          180 KB
hero.webp        1.4 MB
product.jpg      900 KB
background.png   2.1 MB

Si se aprueba una solicitud de integración que elimina una dependencia de 12 KB mientras que una imagen de fondo de 2.1 MB se envía sin cambios, el equipo está realizando una especie de teatro de priorización en lugar de realizar optimizaciones reales. Las imágenes merecen el mismo rigor que el código:

  • Preferir formatos modernos como WebP o AVIF cuando estén soportados y sean adecuados.
  • Utilice tamaños responsivos en lugar de una dimensión fija.
  • Nunca envíe imágenes en tamaño de escritorio a los teléfonos.
  • Cargue de forma diferida las imágenes que se encuentran más abajo en la página.
  • Cargue por adelantado solo las pocas imágenes que son realmente esenciales.
  • Sustituya las enormes imágenes de fondo cuando un archivo más pequeño cumpla con la función.
  • Elija los niveles de compresión según la forma en que se visualice realmente la imagen.
  • Ahorrar 15 KB de JavaScript significa poco si un teléfono sigue descargando 2 MB para una imagen que el usuario apenas nota.

    Muchos problemas de rendimiento son problemas de arquitectura

    Cuanto más profundiza, más claro se vuelve que muchos problemas de rendimiento no tienen nada que ver con ajustar el código. Proviene de la forma en que está estructurada la aplicación. Imagine una página de producto que necesita todo esto:

    Product
    Reviews
    Recommendations
    Inventory
    Shipping estimate
    User preferences
    Related products
    

    Si cada elemento se carga por separado después de que la página se haya cargado, es posible optimizar cada solicitud y aun así obtener una página lenta. La mejor estrategia es decidir qué debe ver el usuario primero. La respuesta inicial podría incluir únicamente:

    Product
    Price
    Availability
    Primary image
    

    Las reseñas, recomendaciones y productos relacionados pueden cargarse posteriormente. En ese momento ya no se está optimizando la implementación; se está redefiniendo qué significa “listo” para la página, y es allí donde a menudo se logran las mayores mejoras.

    Mida los hitos que realmente perciben los usuarios

    Un trabajo serio en rendimiento sustituye la pregunta “¿qué tan grande es el paquete?” por “¿cuándo puede el usuario hacer algo útil?”. Esa pregunta conduce a métricas más adecuadas.

    Tiempo hasta el primer contenido útil

    ¿Cuándo ve el usuario lo que vino a buscar? Esto suele ser específico de su producto: el saldo de la cuenta, los resultados de búsqueda, la foto del producto.

    Tiempo hasta la interacción

    ¿Cuándo puede el usuario interactuar de manera fiable, sin que los clics se vean afectados por tareas en curso? Las versiones recientes de Lighthouse ya no incluyen el TTI en su puntuación, pero la pregunta subyacente sigue siendo útil para monitorear sus propias páginas.

    Pintado del contenido más grande

    ¿Cuándo finaliza la renderización del contenido principal visible?

    Interacción hasta el siguiente pintado

    ¿Con qué rapidez responde visualmente la interfaz después de que el usuario interactúa?

    Cambio acumulativo de layout

    ¿El layout cambia constantemente mientras el usuario intenta leer o hacer clic?

    Tiempo total de bloqueo

    ¿Cuánto tiempo está bloqueada la hilera principal por tareas que impiden que el navegador responda a las entradas?

    Ninguno de estos elementos por sí solo cuenta toda la historia, pero juntos describen la experiencia mucho mejor que una sola línea como esta:

    bundle.js = 487 KB
    

    Una figura de paquete describe un recurso. Las métricas de rendimiento describen lo que experimenta el usuario.

    Un bucle de optimización en el que se puede confiar

    Cuando una aplicación lenta necesita reparación, eliminar dependencias no debería ser el primer paso. Un bucle bien estructurado funciona mejor.

    1. Reproducir en condiciones realistas

    Pruebe en dispositivos y redes representativos, no solo en una laptop rápida conectada a Wi-Fi de la oficina. Muchos de sus usuarios no cuentan con ninguno de estos.

    2. Medir para encontrar el punto lento

    Determine dónde se pierde el tiempo. ¿Es uno de estos los problemas principales?

    server response?
    network?
    rendering?
    JavaScript execution?
    layout?
    images?
    third-party scripts?
    

    3. Identificar el costo más importante

    Resista la tentación de arreglar cinco cosas a la vez. Encuentre el factor que más contribuye al problema.

    4. Cambiar una cosa

    Haga el cambio arquitectónico o de implementación más pequeño que resuelva ese cuello de botella, para poder atribuir el resultado.

    5. Mida nuevamente

    Si la mejora no se refleja en las cifras, no asuma que funcionó.

    6. Consolide los beneficios con una verificación de regresión

    Las mejoras se desvanecen rápidamente. Alguien agrega una dependencia, un equipo de producto incluye un widget, un componente deja de ser eficiente, una consulta pasa a ser secuencial, y tres meses después vuelve a donde empezó. El rendimiento necesita medidas de protección automatizadas en los procesos CI y en la supervisión, no solo limpiezas esporádicas.

    Tamaño del paquete sigue siendo importante, en su lugar adecuado

    Nada de esto hace que el tamaño del paquete sea irrelevante. Los paquetes grandes aumentan los costos de descarga, análisis, compilación y ejecución, y la penalización es más severa en dispositivos y redes lentas. El dividir el código, eliminar componentes innecesarios, cargar contenido de forma diferida y recortar dependencias no utilizadas son estrategias valiosas. Lo importante es aplicarlas cuando la evidencia indica que representan el mayor problema.

    Una revisión de rendimiento adecuada, ordenada por su impacto, podría ser la siguiente: primero, una respuesta lenta del servidor:

    Problem:
    900ms server response
    

    Se solucionó ejecutando las solicitudes al backend en paralelo:

    Action:
    parallelize backend requestsResult:
    -420ms
    

    Luego, la carga costosa del panel de control:

    Problem:
    large dashboard hydration
    

    Se abordó posponiendo la carga de componentes que no necesitan ser interactivos de inmediato:

    Action:
    defer non-critical interactive componentsResult:
    -280ms main-thread work
    

    Después, una imagen principal de tamaño excesivo:

    Problem:
    hero image is 1.8 MB
    

    Se resolvió con una entrega adaptativa en formatos modernos:

    Action:
    responsive WebP/AVIF deliveryResult:
    -1.2 MB transferred
    

    Solo después de todo eso es cuando una dependencia pesada de JavaScript llega al principio de la lista:

    Problem:
    large JavaScript dependency
    

    Sustituirla sigue permitiendo ahorrar una cantidad significativa:

    Action:
    replace dependencyResult:
    -60 KB
    

    Esa solución sigue siendo una buena labor. Simplemente debería estar en cuarto lugar en la cola, no en primer lugar.

    Puntos clave

    • Optimice por el tiempo de espera, no por el tamaño más pequeño posible del archivo. El tamaño del paquete es una de las muchas señales a considerar.
    • Analice el servidor y la secuencia de solicitudes antes de usar un analizador de paquetes; la latencia y los viajes de ida y vuelta suelen costar más que los bytes.
    • Mida el JavaScript según el trabajo que genera: análisis, ejecución, renderizado e integración con el DOM, y no solo su tamaño.
    • Haga una auditoría de los scripts e imágenes de terceros con el mismo rigor que aplicaría a su propio código.
    • Redefina qué significa “listo” para cada página para que el contenido crítico llegue primero y todo lo demás siga después.
  • Trabajar en un ciclo de reproducir, medir, cambiar algo, medir nuevamente y proteger cada avance con una verificación de regresión.
  • La pregunta más importante en el trabajo de rendimiento es qué hace que el usuario espere y por qué.
  • Lecturas relacionadas