Técnicas para que una app web se cargue rápido, incluso en teléfonos de gama media

Cómo usamos la división de código, la incorporación de código y la renderización del servidor en PROXX.

En Google I/O 2019, Mariko, Jake y yo enviamos PROXX, un limpiaminas cloneas modernas para la Web. Algo que distingue a PROXX es el enfoque en la accesibilidad (¡puedes jugarlo con un lector de pantalla!) y la capacidad de ejecutarse tanto en teléfonos de gama media como en dispositivos de escritorio de alta gama. Los teléfonos de gama media tienen varias restricciones:

  • CPU débiles
  • GPU débiles o inexistentes
  • Pantallas pequeñas sin entrada táctil
  • Cantidad de memoria muy limitada

Sin embargo, ejecutan un navegador moderno y son muy asequibles. Por esta razón, los teléfonos de gama media están haciendo un resurgimiento en los mercados emergentes. Su precio permite que un público totalmente nuevo, que antes no podía costearlo, se conecte y use la Web moderna. En 2019, se proyecta que se venderán alrededor de 400 millones de teléfonos de gama media solo en la India, por lo que los usuarios de estos teléfonos podrían convertirse en una parte importante de tu público. Además, las velocidades de conexión similares a las 2G son la norma en los mercados emergentes. ¿Cómo logramos que PROXX funcionara bien en teléfonos de gama media?

. Juego de PROXX

El rendimiento es importante, y eso incluye el rendimiento de carga y el del entorno de ejecución. Se demostró que el buen rendimiento se correlaciona con una mayor retención de usuarios, una mejora en las conversiones y, lo más importante, un aumento en la inclusión. Jeremy Wagner tiene muchos más datos y estadísticas sobre por qué es importante el rendimiento.

Esta es la primera parte de una serie de dos. La parte 1 se enfoca en el rendimiento de carga y la parte 2 se enfoca en el rendimiento del tiempo de ejecución.

Captura el statu quo

Probar el rendimiento de carga en un dispositivo real es fundamental. Si no tienes un dispositivo real a mano, te recomiendo WebPageTest, específicamente, la "simple" configuración. WPT ejecuta una batería de pruebas de carga en un dispositivo real con una conexión 3G emulada.

3G es una buena velocidad para medir. Aunque es posible que ya estés acostumbrado a la conectividad 4G, LTE o, pronto, incluso a 5G, la realidad de la Internet móvil es bastante diferente. Tal vez estás en un tren, en una conferencia, en un concierto o en un vuelo. Lo que experimentarás allí probablemente sea más cerca de la conexión 3G y, a veces, incluso peor.

Dicho esto, nos vamos a enfocar en 2G en este artículo porque PROXX se orienta explícitamente a teléfonos de gama media y mercados emergentes en su público objetivo. Una vez que WebPageTest haya ejecutado su prueba, obtendrás una cascada (similar a la que ves en Herramientas para desarrolladores) y una tira de película en la parte superior. La tira de película muestra lo que el usuario ve mientras se carga la app. En 2G, la experiencia de carga de la versión no optimizada de PROXX es bastante mala:

. El video de la tira de película muestra lo que el usuario ve cuando PROXX se carga en un dispositivo real de gama baja a través de una conexión 2G emulada.

Cuando se carga en 3G, el usuario ve 4 segundos de nada en blanco. Con la conexión 2G, el usuario no ve absolutamente nada durante más de 8 segundos. Si lees por qué es importante el rendimiento, sabrás que perdimos una buena parte de nuestros usuarios potenciales debido a la impaciencia. El usuario debe descargar los 62 KB de JavaScript para que aparezca algo en la pantalla. El aspecto positivo de este escenario es que apenas aparece algo en la pantalla, también es interactivo. ¿O no?

La [Primera pintura significativa][FMP] en la versión no optimizada de PROXX es _técnicamente_ [interactiva][TTI], pero inútil para el usuario.

Una vez que se hayan descargado aproximadamente 62 KB de JS en gzip y se haya generado el DOM, el usuario puede ver nuestra app. La app es interactiva técnicamente. Sin embargo, observar el elemento visual muestra una realidad diferente. Las fuentes web aún se están cargando en segundo plano y el usuario no verá texto hasta que estén listas. Si bien este estado califica como Primera pintura significativa (FMP), seguramente no califique como interactiva de forma adecuada, ya que el usuario no puede saber de qué se trata ninguna de las entradas. La app tarda otro segundo en 3G y 3 segundos en 2G. En pocas palabras, la app tarda 6 segundos en conectarse a 3G y 11 segundos en la conexión 2G en ser interactiva.

Análisis de cascada

Ahora que sabemos qué ve el usuario, debemos averiguar el por qué. Para ello, podemos observar la cascada y analizar por qué los recursos se cargan demasiado tarde. En nuestro seguimiento 2G para PROXX, podemos ver dos señales de alerta importantes:

  1. Existen varias líneas finas de varios colores.
  2. Los archivos JavaScript forman una cadena. Por ejemplo, el segundo recurso solo comienza a cargarse una vez que finaliza el primero, y el tercer recurso solo se inicia cuando finaliza el segundo.
La cascada brinda estadísticas sobre qué recursos se cargan, cuándo y cuánto tiempo.

Reduce la cantidad de conexiones

Cada línea delgada (dns, connect, ssl) representa la creación de una nueva conexión HTTP. Configurar una nueva conexión es costoso, ya que tarda alrededor de 1 s en 3G y aproximadamente 2.5 s en 2G. En nuestra cascada, vemos una nueva conexión para lo siguiente:

  • Solicitud n° 1: Nuestro index.html
  • Solicitud n° 5: Los estilos de fuente de fonts.googleapis.com
  • Solicitud n.° 8: Google Analytics
  • Solicitud n° 9: Un archivo de fuentes de fonts.gstatic.com
  • Solicitud n° 14: El manifiesto de la app web

La nueva conexión para index.html es inevitable. El navegador tiene que crear una conexión con nuestro servidor para obtener el contenido. La nueva conexión para Google Analytics se puede evitar si se integra algo como Análisis mínimo, pero Google Analytics no impide que nuestra app se renderice o se vuelva interactiva, por lo que en realidad no nos importa qué tan rápido se cargue. Lo ideal sería que Google Analytics se cargue en tiempo de inactividad, cuando todo lo demás ya se cargó. De esta manera, no consumirá ancho de banda ni potencia de procesamiento durante la carga inicial. La especificación de recuperación indica la nueva conexión para el manifiesto de app web, ya que el manifiesto debe cargarse mediante una conexión sin credenciales. Una vez más, el manifiesto de la aplicación web no impide que la app se renderice ni se vuelva interactiva, por lo que no es necesario que nos preocupe tanto.

Sin embargo, las dos fuentes y sus estilos son un problema, ya que bloquean la representación y también la interactividad. Si observamos el CSS que entrega fonts.googleapis.com, solo hay dos reglas @font-face, una para cada fuente. Los estilos de fuente son tan pequeños que decidimos insertarlos en nuestro HTML, lo que quitó una conexión innecesaria. Para evitar el costo de configurar la conexión de los archivos de fuente, podemos copiarlos en nuestro propio servidor.

Paraleliza cargas

Observando la cascada, podemos ver que una vez que el primer archivo JavaScript termina de cargarse, los archivos nuevos comienzan a cargarse de inmediato. Esto es habitual en las dependencias de módulos. Nuestro módulo principal probablemente tenga importaciones estáticas, por lo que JavaScript no puede ejecutarse hasta que se carguen esas importaciones. Es importante tener en cuenta que este tipo de dependencias se conocen en el momento de la compilación. Podemos usar etiquetas <link rel="preload"> para asegurarnos de que todas las dependencias comiencen a cargarse apenas recibamos el código HTML.

Resultados

Observemos lo que lograron con nuestros cambios. Es importante no cambiar ninguna otra variable en nuestra configuración de prueba que pueda sesgar los resultados, por lo que usaremos la configuración simple de WebPageTest para el resto de este artículo y observaremos la tira de película:

. Usamos la tira de película de WebPageTest para ver los resultados de nuestros cambios.

Estos cambios redujeron nuestro TTI de 11 a 8.5, que es aproximadamente los 2.5 s del tiempo de configuración de conexión que intentamos quitar. Buen trabajo.

Renderización previa

Si bien acabamos de reducir nuestro TTI, no hemos afectado realmente a la pantalla blanca eterna que el usuario debe soportar durante 8.5 segundos. Podría decirse que las mejoras más importantes para FMP se pueden lograr enviando lenguaje de marcado con estilo en tu index.html. Las técnicas comunes para lograrlo son la renderización previa y la del servidor, que están estrechamente relacionadas y se explican en el artículo Renderización en la Web. Ambas técnicas ejecutan la app web en Node y serializan el DOM resultante en HTML. La renderización del servidor lo hace por solicitud en el servidor, mientras que la renderización previa lo hace en el tiempo de compilación y almacena el resultado como tu index.html nuevo. Como PROXX es una app de JAMStack y no tiene acceso al servidor, decidimos implementar la renderización previa.

Existen muchas formas de implementar un renderizador previo. En PROXX, elegimos usar Puppeteer, que inicia Chrome sin ninguna IU y te permite controlar de forma remota esa instancia con una API de Node. Usamos esto para insertar nuestro lenguaje de marcado y JavaScript, y luego volver a leer el DOM como una cadena de HTML. Debido a que usamos módulos de CSS, incorporamos CSS en los estilos que necesitamos de forma gratuita.

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(rawIndexHTML);
  await page.evaluate(codeToRun);
  const renderedHTML = await page.content();
  browser.close();
  await writeFile("index.html", renderedHTML);

Con esto implementado, podemos esperar una mejora en nuestro FMP. Debemos cargar y ejecutar la misma cantidad de JavaScript que antes, por lo que no deberíamos esperar que TTI cambie demasiado. En todo caso, nuestra index.html es más grande y puede retrasar un poco nuestra TTI. Solo hay una forma de averiguarlo: si ejecutas WebPageTest.

. La tira de película muestra una mejora clara en la métrica FMP. El TTI no se ve afectado principalmente.

Nuestra primera pintura significativa pasó de 8.5 segundos a 4.9 segundos, una mejora enorme. Nuestra TTI sigue ocurriendo alrededor de 8.5 segundos, por lo que este cambio no afectó en gran medida. Lo que hicimos aquí fue un cambio perceptivo. Algunos incluso lo llaman prestidigitación. Al renderizar una imagen intermedia del juego, mejoraremos el rendimiento de carga percibido.

Integración

Otra métrica que proporcionan Herramientas para desarrolladores y WebPageTest es Tiempo hasta el primer byte (TTFB). Es el tiempo que transcurre desde el primer byte de la solicitud que se envía hasta el primer byte de la respuesta que se recibe. Este tiempo también suele denominarse tiempo de ida y vuelta (RTT), aunque técnicamente existe una diferencia entre estos dos números: el RTT no incluye el tiempo de procesamiento de la solicitud en el servidor. DevTools y WebPageTest visualizan el TTFB con un color claro dentro del bloque de solicitud/respuesta.

La sección simple de una solicitud indica que esta está esperando para recibir el primer byte de la respuesta.

Si observamos nuestra cascada, podemos ver que todas las solicitudes pasan la mayor parte de su tiempo esperando a que llegue el primer byte de la respuesta.

Este problema es para lo que se concibió originalmente el envío HTTP/2. El desarrollador de la app sabe que se necesitan ciertos recursos y puede empujarlos. Cuando el cliente se da cuenta de que necesita recuperar recursos adicionales, ya están en la memoria caché del navegador. El envío de HTTP/2 resultó ser demasiado difícil de hacer bien y se considera desaconsejable. Este espacio problemático se revisará durante la estandarización de HTTP/3. Por ahora, la solución más fácil es intercalar todos los recursos críticos a expensas de la eficiencia del almacenamiento en caché.

Nuestro CSS crítico ya está intercalado gracias a los módulos de CSS y a nuestro procesador previo basado en Puppeteer. En el caso de JavaScript, necesitamos intercalar nuestros módulos fundamentales y sus dependencias. Esta tarea tiene diferentes dificultades, según el agrupador que uses.

Con la incorporación de nuestro JavaScript, redujimos el TTI de 8.5 s a 7.2 s.

Esto redujo 1 segundo de nuestra TTI. Llegamos al punto en el que index.html contiene todo lo que se necesita para la renderización inicial y la interacción. El HTML se puede renderizar mientras se descarga, lo que crea nuestro FMP. En el momento en que el código HTML termina de analizarse y ejecutarse, la aplicación se vuelve interactiva.

División de código agresiva

Sí, nuestro index.html contiene todo lo que se necesita para ser interactivo. Pero tras una inspección más detallada, descubrimos que también contiene todo lo demás. Nuestro index.html es de alrededor de 43 KB. Veamos esto en relación con lo que el usuario puede interactuar al principio: tenemos un formulario para configurar el juego que contiene un par de componentes, un botón de inicio y, probablemente, código para conservar y cargar la configuración del usuario. Eso es todo. 43 KB parece ser mucho.

La página de destino de PROXX. Aquí solo se usan componentes críticos.

Para comprender de dónde proviene el tamaño de nuestro paquete, podemos usar un explorador de mapas de origen o una herramienta similar para desglosar los elementos que contiene el paquete. Como se predijo, nuestro paquete contiene la lógica del juego, el motor de renderización, la pantalla ganadora, la de pérdida y muchas utilidades. Solo se necesita un pequeño subconjunto de estos módulos para la página de destino. Mover todo lo que no es estrictamente necesario para la interactividad a un módulo de carga diferida disminuirá significativamente el TTI.

Al analizar el contenido del `index.html` de PROXX, se muestran muchos recursos innecesarios. Los recursos críticos están destacados.

Lo que debemos hacer es dividir el código. La división de código divide tu paquete monolítico en partes más pequeñas que se pueden cargar de forma diferida a pedido. Los agrupadores populares como Webpack, Rollup y Parcel admiten la división de código mediante import() dinámico. El agrupador analizará tu código e intercalará todos los módulos que se importen de manera estática. Todo lo que importes de forma dinámica se pondrá en su propio archivo y solo se recuperará de la red una vez que se ejecute la llamada a import(). Por supuesto, conectarse a la red tiene un costo y solo debería hacerse si tiene tiempo de sobra. El lema es importar de forma estática los módulos que se necesitan fundamentalmente en el tiempo de carga y cargar todo lo demás de forma dinámica. Pero no deberías esperar al último momento para usar los módulos de carga diferida que definitivamente se usarán. Idle Until Urgent de Phil Walton es un gran patrón para lograr un equilibrio saludable entre la carga diferida y la carga inmediata.

En PROXX, creamos un archivo lazy.js que importa de forma estática todo lo que no necesitamos. En nuestro archivo principal, podemos importar lazy.js de forma dinámica. Sin embargo, algunos de nuestros componentes Preact terminaron en lazy.js, lo que resultó ser un poco complicado, ya que no puede manejar componentes de carga diferida desde el primer momento. Por este motivo, escribimos un pequeño wrapper del componente deferred que nos permite renderizar un marcador de posición hasta que se cargue el componente real.

export default function deferred(componentPromise) {
  return class Deferred extends Component {
    constructor(props) {
      super(props);
      this.state = {
        LoadedComponent: undefined
      };
      componentPromise.then(component => {
        this.setState({ LoadedComponent: component });
      });
    }

    render({ loaded, loading }, { LoadedComponent }) {
      if (LoadedComponent) {
        return loaded(LoadedComponent);
      }
      return loading();
    }
  };
}

Una vez hecho esto, podemos usar una promesa de un componente en nuestras funciones render(). Por ejemplo, el componente <Nebula>, que renderiza la imagen de fondo animada, se reemplazará por un <div> vacío mientras se carga el componente. Una vez que el componente esté cargado y listo para usarse, se reemplazará <div> por el componente real.

const NebulaDeferred = deferred(
  import("/components/nebula").then(m => m.default)
);

return (
  // ...
  <NebulaDeferred
    loading={() => <div />}
    loaded={Nebula => <Nebula />}
  />
);

Una vez implementado todo esto, redujimos nuestro index.html a tan solo 20 KB, menos de la mitad del tamaño original. ¿Qué efecto tiene esto en el FMP y TTI? WebPageTest lo dirá.

. La tira de película confirma que nuestra TTI ahora es de 5.4 s. Una mejora drástica con respecto a nuestras 11 originales.

Nuestro FMP y TTI solo están a 100 ms de distancia, ya que solo es cuestión de analizar y ejecutar el JavaScript intercalado. Después de solo 5.4 s en 2G, la app es completamente interactiva. Todos los demás módulos menos esenciales se cargan en segundo plano.

Más prestigio

Si observas nuestra lista de módulos críticos anterior, verás que el motor de renderización no forma parte de los módulos críticos. Por supuesto, el juego no puede comenzar hasta que tengamos nuestro motor de renderización para renderizarlo. Podríamos deshabilitar la opción de inicio hasta que el motor de renderización esté listo para iniciar el juego. Sin embargo, según nuestra experiencia, el usuario suele tardar el tiempo suficiente en configurar el juego y esto no es necesario. La mayoría de las veces, el motor de renderización y los otros módulos restantes terminan de cargarse cuando el usuario presiona "Iniciar". En el caso poco probable de que el usuario sea más rápido que su conexión de red, se muestra una pantalla de carga simple que espera a que finalicen los módulos restantes.

Conclusión

Las mediciones son importantes. Para evitar perder tiempo en problemas que no son reales, recomendamos medir siempre primero antes de implementar optimizaciones. Además, las mediciones deben realizarse en dispositivos reales con una conexión 3G o en WebPageTest si no hay un dispositivo real a mano.

La tira de película puede brindar información sobre cómo siente el usuario cargar tu app. La cascada puede indicarte qué recursos son responsables de los tiempos de carga potencialmente largos. A continuación, te presentamos una lista de verificación de lo que puedes hacer para mejorar el rendimiento de carga:

  • Publica la mayor cantidad posible de recursos a través de una conexión.
  • Precarga o incluso recursos intercalados que son necesarios para la primera renderización y la primera interactividad.
  • Renderiza previamente tu app para mejorar el rendimiento de carga percibido.
  • Haz una división de código agresiva a fin de reducir la cantidad de código necesario para la interactividad.

No te pierdas la segunda parte, en la que analizaremos cómo optimizar el rendimiento del tiempo de ejecución en dispositivos hiperlimitados.