Técnicas para que una app web se cargue rápido, incluso en un teléfono celular básico

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

En Google I/O 2019, Mariko, Jake y yo lanzamos PROXX, un clon moderno de Buscaminas para la Web. Algo que distingue a PROXX es su enfoque en la accesibilidad (¡puedes jugarlo con un lector de pantalla!) y la capacidad de ejecutarse tanto en un teléfono de gama media como en un dispositivo de escritorio de alta gama. Los teléfonos de gama media tienen varias limitaciones:

  • CPUs débiles
  • GPU débiles o inexistentes
  • Pantallas pequeñas sin entrada táctil
  • Cantidades de memoria muy limitadas

Sin embargo, ejecutan un navegador actualizado y son muy económicas. Por este motivo, los teléfonos de gama media están resurgiendo en los mercados emergentes. Su precio permite que un público completamente nuevo, que antes no podía pagarlo, se conecte y use la Web moderna. Para 2019, se proyecta que se venderán alrededor de 400 millones de teléfonos de gama media solo en 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 de 2G son la norma en los mercados emergentes. ¿Cómo logramos que PROXX funcione bien en las condiciones de un teléfono de gama media?

Modo de juego de PROXX.

El rendimiento es importante, y eso incluye tanto el rendimiento de carga como el de tiempo de ejecución. Se demostró que un buen rendimiento se correlaciona con una mayor retención de usuarios, mejores conversiones y, lo que es más importante, una mayor 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 enfocará en el rendimiento del tiempo de ejecución.

Captura el statu quo

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

La velocidad de 3G es una buena opción para medir. Si bien es posible que estés acostumbrado a las redes 4G, LTE o, pronto, incluso 5G, la realidad del 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 se acerque más a la tecnología 3G y, a veces, incluso peor.

Dicho esto, en este artículo nos enfocaremos en la red 2G, ya que 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 un gráfico de cascada (similar al que ves en las Herramientas para desarrolladores) y una tira de película en la parte superior. La tira de película muestra lo que ve el usuario 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 ve el usuario cuando PROXX se carga en un dispositivo real de gama baja a través de una conexión 2G emulada.

Cuando se carga con 3G, el usuario ve 4 segundos de nada en blanco. En 2G, el usuario no ve absolutamente nada durante más de 8 segundos. Si leíste por qué es importante el rendimiento, sabrás que ahora 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. Lo positivo de este caso es que, en cuanto aparece algo en pantalla, también es interactivo. ¿O no?

El [Primer renderizado significativo][FMP] en la versión no optimizada de PROXX es _técnicamente_ [interactivo][TTI], pero inútil para el usuario.

Después de que se descargan alrededor de 62 KB de JS comprimido con gzip y se genera el DOM, el usuario puede ver nuestra app. La app es técnicamente interactiva. Sin embargo, si observamos el gráfico, vemos una realidad diferente. Las fuentes web aún se están cargando en segundo plano y, hasta que estén listas, el usuario no podrá ver ningún texto. Si bien este estado califica como una Primera pintura significativa (FMP), seguramente no califica como interactivo, ya que el usuario no puede saber de qué se tratan las entradas. Se necesita otro segundo en 3G y 3 segundos en 2G hasta que la app esté lista para usarse. En total, la app tarda 6 segundos en 3G y 11 segundos en 2G en volverse interactiva.

Análisis de cascada

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

  1. Hay 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 tercero solo comienza cuando finaliza el segundo.
El gráfico de cascada proporciona información sobre qué recursos se cargan, cuándo y cuánto tiempo tardan.

Reduce el recuento de conexiones

Cada línea delgada (dns, connect, ssl) representa la creación de una nueva conexión HTTP. Configurar una conexión nueva es costoso, ya que tarda alrededor de 1 s en 3G y aproximadamente 2.5 s en 2G. En nuestra cascada, vemos una conexión nueva 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 fuente de fonts.gstatic.com
  • Solicitud nº 14: El manifiesto de la app web

La nueva conexión para index.html es inevitable. El navegador debe crear una conexión con nuestro servidor para obtener el contenido. La nueva conexión para Google Analytics se podría evitar incorporando algo como Minimal Analytics, pero Google Analytics no impide que nuestra app se renderice o se vuelva interactiva, por lo que no nos importa qué tan rápido se cargue. Idealmente, Google Analytics debería cargarse en el tiempo de inactividad, cuando todo lo demás ya se haya cargado. De esa manera, no ocupará ancho de banda ni potencia de procesamiento durante la carga inicial. La nueva conexión para el manifiesto de la app web está prescrita por la especificación de recuperación, ya que el manifiesto se debe cargar a través de una conexión sin credenciales. Nuevamente, el manifiesto de la app web no impide que nuestra app se renderice o se vuelva interactiva, por lo que no debemos preocuparnos demasiado.

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

Paraleliza las cargas

Si observamos el gráfico de cascada, podemos ver que, una vez que se termina de cargar el primer archivo JavaScript, se empiezan a cargar archivos nuevos de inmediato. Esto es típico de las dependencias de módulos. Es probable que nuestro módulo principal tenga importaciones estáticas, por lo que JavaScript no se puede ejecutar hasta que se carguen esas importaciones. Lo importante que debes tener en cuenta aquí es que estos tipos 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 en el momento en que recibimos nuestro HTML.

Resultados

Veamos qué se logró con los 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 veremos la tira de película:

Usamos la tira de película de WebPageTest para ver qué lograron nuestros cambios.

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

Renderización previa

Si bien acabamos de reducir nuestro TTI, no afectamos realmente la pantalla blanca eternamente larga que el usuario tiene que soportar durante 8.5 segundos. Podría decirse que las mejoras más importantes para el FMP se pueden lograr enviando lenguaje de marcado con diseño en tu index.html. Las técnicas comunes para lograrlo son la renderización previa y la renderización del servidor, que están estrechamente relacionadas y se explican en 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 hace esto por solicitud en el servidor, mientras que la renderización previa lo hace en el momento de la compilación y almacena el resultado como tu nuevo index.html. Como PROXX es una app de JAMStack y no tiene un lado del 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 nuestro JavaScript, y luego volver a leer el DOM como una cadena de HTML. Como usamos módulos de CSS, obtenemos la inserción de CSS de 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. Aún necesitamos cargar y ejecutar la misma cantidad de JavaScript que antes, por lo que no deberíamos esperar que el TTI cambie mucho. En todo caso, nuestro index.html aumentó y podría retrasar un poco nuestro TTI. Solo hay una manera de averiguarlo: ejecutando WebPageTest.

La tira de película muestra una mejora clara para nuestra métrica de FMP. El TTI no se ve afectado en gran medida.

Nuestro primer procesamiento de imagen significativo pasó de 8.5 segundos a 4.9 segundos, una mejora enorme. Nuestro TTI sigue ocurriendo alrededor de los 8.5 segundos, por lo que este cambio no lo afectó en gran medida. Lo que hicimos aquí es un cambio perceptual. Algunos incluso podrían llamarlo un truco de magia. Renderizar un elemento visual intermedio del juego mejora la percepción del rendimiento de carga.

Inlining

Otra métrica que nos proporcionan tanto las Herramientas para desarrolladores como WebPageTest es el tiempo hasta el primer byte (TTFB). Este es el tiempo que transcurre desde que se envía el primer byte de la solicitud hasta que se recibe el primer byte de la respuesta. Este tiempo también se suele llamar tiempo de ida y vuelta (RTT), aunque técnicamente hay 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 clara de una solicitud indica que esta espera recibir el primer byte de la respuesta.

Si observamos nuestro gráfico de cascada, podemos ver que todas las solicitudes pasan la mayor parte del tiempo esperando a que llegue el primer byte de la respuesta.

Este problema es el motivo por el que se concibió originalmente la función de envío de HTTP/2. El desarrollador de apps sabe que se necesitan ciertos recursos y puede enviarlos por la conexión. Para cuando el cliente se da cuenta de que necesita recuperar recursos adicionales, estos ya están en las cachés del navegador. La función HTTP/2 Push resultó ser demasiado difícil de implementar correctamente y se considera desaconsejada. Este espacio de problemas se volverá a analizar durante la estandarización de HTTP/3. Por ahora, la solución más sencilla 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 renderizador previo basado en Puppeteer. Para JavaScript, necesitamos intercalar nuestros módulos críticos y sus dependencias. Esta tarea tiene una dificultad variable, según el empaquetador que uses.

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

Esto redujo 1 segundo de nuestro TTI. Ahora llegamos al punto en el que nuestro index.html contiene todo lo necesario para la renderización inicial y para volverse interactivo. El HTML se puede renderizar mientras se descarga, lo que crea nuestro FMP. En el momento en que se termina de analizar y ejecutar el HTML, la app se vuelve interactiva.

División de código agresiva

Sí, nuestro index.html contiene todo lo necesario para volverse interactivo. Pero, si se observa con más detenimiento, resulta que también contiene todo lo demás. Nuestro index.html tiene alrededor de 43 KB. Relacionemos eso con los elementos con los 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, un código para conservar y cargar la configuración del usuario. Eso es todo. 43 KB parece 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 en qué consiste el paquete. Como se predijo, nuestro paquete contiene la lógica del juego, el motor de renderización, la pantalla de victoria, la pantalla de derrota y un montón de utilidades. Solo se necesita un pequeño subconjunto de estos módulos para la página de destino. Mover todo lo que no sea estrictamente necesario para la interactividad a un módulo de carga diferida disminuirá el TTI significativamente.

Si analizas el contenido del archivo `index.html` de PROXX, verás muchos recursos innecesarios. Se destacan los recursos críticos.

Lo que debemos hacer es dividir el código. La división de código separa 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 con import() dinámico. El agrupador analizará tu código y intercalará todos los módulos que se importen de forma estática. Todo lo que importes de forma dinámica se colocará en su propio archivo y solo se recuperará de la red una vez que se ejecute la llamada import(). Por supuesto, acceder a la red tiene un costo y solo se debe hacer si tienes tiempo de sobra. La regla de oro aquí es importar de forma estática los módulos que son esenciales en el momento de la carga y cargar de forma dinámica todo lo demás. Sin embargo, no deberías esperar hasta el último momento para cargar de forma diferida los módulos que definitivamente se usarán. Inactivo hasta que sea urgente de Phil Walton es un excelente patrón para un punto medio 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 de Preact terminaron en lazy.js, lo que resultó ser un poco complicado, ya que Preact no puede controlar los componentes de carga diferida de forma inmediata. Por este motivo, escribimos un pequeño wrapper de 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();
    }
  };
}

Con 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 se cargue y esté listo para usarse, el <div> se reemplazará por el componente real.

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

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

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

La tira de película confirma que nuestro TTI ahora es de 5.4 s. Una mejora drástica en comparación con los 11 s originales.

Nuestros FMP y TTI están separados por solo 100 ms, ya que solo se trata 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 juegos de manos

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 renderizar el juego. Podríamos inhabilitar el botón "Iniciar" hasta que nuestro motor de renderización esté listo para iniciar el juego, pero, según nuestra experiencia, el usuario suele tardar lo suficiente en configurar los parámetros del juego como para que esto no sea necesario. La mayoría de las veces, el motor de renderización y los demás módulos restantes terminan de cargarse cuando el usuario presiona "Iniciar". En el caso poco frecuente de que el usuario sea más rápido que su conexión de red, mostramos una pantalla de carga simple que espera a que finalicen los módulos restantes.

Conclusión

La medición es importante. Para evitar perder tiempo en problemas que no son reales, te recomendamos que siempre midas 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 disponible.

La tira de película puede darte una idea de cómo se siente el usuario cuando se carga tu app. La cascada puede indicarte qué recursos son responsables de los tiempos de carga potencialmente largos. A continuación, se incluye una lista de tareas que puedes realizar para mejorar el rendimiento de carga:

  • Publica la mayor cantidad posible de recursos en una conexión.
  • Precarga o incluso incorpora recursos que se requieren para la primera renderización y la interactividad.
  • Renderiza previamente tu app para mejorar el rendimiento de carga percibido.
  • Usa la división de código agresiva para reducir la cantidad de código necesaria para la interactividad.

No te pierdas la parte 2, en la que analizaremos cómo optimizar el rendimiento del entorno de ejecución en dispositivos con restricciones extremas.