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 lanzamos PROXX, un clon moderno de Minesweeper 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 tan bien en un teléfono con funciones básicas como en un dispositivo de escritorio de alta gama. Los teléfonos de gama media tienen limitaciones de varias maneras:

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

Sin embargo, ejecutan un navegador moderno y son muy asequibles. Por esta razón, los teléfonos básicos están resurgiendo en los mercados emergentes. Su precio permite que un público completamente nuevo, que antes no podía pagarlo, se conecte a Internet y use la Web moderna. Se proyecta que, en 2019, se venderán alrededor de 400 millones de teléfonos básicos solo en la India, por lo que los usuarios de teléfonos básicos podrían convertirse en una parte significativa 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 condiciones de teléfonos básicos?

Juego de PROXX.

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

Esta es la parte 1 de una serie de dos partes. La parte 1 se enfoca en el rendimiento de carga y la parte 2 se enfocará en el rendimiento del entorno de ejecución.

Captura el statu quo

Es fundamental probar 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 3G es una buena velocidad para medir. Si bien es posible que estés acostumbrado a la conexión 4G, LTE o, pronto, incluso 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. Es probable que la experiencia que tengas allí sea más cercana a la de 3G y, a veces, incluso peor.

Dicho esto, nos enfocaremos en la red 2G en este artículo porque PROXX segmenta sus anuncios de forma explícita para teléfonos de gama media y mercados emergentes en su público objetivo. Una vez que WebPageTest ejecute la prueba, verás una cascada (similar a la que ves en DevTools) 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:

En el video de diapositivas, se muestra lo que ve el usuario cuando se carga PROXX en un dispositivo real de gama baja a través de una conexión 2G emulada.

Cuando se carga a través de 3G, el usuario ve 4 segundos de pantalla en blanco. En 2G, el usuario no ve absolutamente nada durante más de 8 segundos. Si leíste por qué el rendimiento es importante, sabes 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. La ventaja de esta situación es que, en cuanto aparece algo en la pantalla, también es interactivo. ¿O no?

El [primer procesamiento de imagen con significado][FMP] en la versión no optimizada de PROXX es _técnicamente_ [interactivo][TTI], pero inútil para el usuario.

Después de descargar alrededor de 62 KB de JS comprimidos con gzip y generar el DOM, el usuario puede ver nuestra app. La app es técnicamente interactiva. Sin embargo, si observas el elemento visual, se muestra 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 trata ninguna de las entradas. Tarda otro segundo en 3G y 3 segundos en 2G hasta que la app está lista para funcionar. En total, la app tarda 6 segundos en 3G y 11 segundos en 2G en volverse interactiva.

Análisis en 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 registro de 2G para PROXX, podemos ver dos indicadores importantes:

  1. Hay varias líneas finas multicolores.
  2. Los archivos JavaScript forman una cadena. Por ejemplo, el segundo recurso solo comienza a cargarse una vez que se termina el primero, y el tercero solo comienza cuando se termina el segundo.
La cascada proporciona información sobre qué recursos se cargan, cuándo y cuánto tiempo tardan.

Cómo reducir el recuento de conexiones

Cada línea delgada (dns, connect, ssl) representa la creación de una nueva conexión HTTP. La configuración de una conexión nueva es costosa, ya que tarda alrededor de 1 segundo en 3G y alrededor de 2.5 segundos 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

No se puede evitar la nueva conexión para index.html. El navegador debe crear una conexión con nuestro servidor para obtener el contenido. Se podría evitar la nueva conexión de Google Analytics si se incorpora algo como Minimal Analytics, pero Google Analytics no impide que nuestra app se renderice o se vuelva interactiva, por lo que no nos importa la velocidad de carga. Lo ideal es que Google Analytics se cargue durante el tiempo de inactividad, cuando ya se cargó todo lo demás. De esta manera, no ocupará ancho de banda ni potencia de procesamiento durante la carga inicial. La especificación de recuperación prescribe la nueva conexión para el manifiesto de la app web, ya que el manifiesto se debe cargar a través de una conexión sin credenciales. Una vez más, el manifiesto de la app web no impide que nuestra app se renderice o se vuelva interactiva, por lo que no tenemos que 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 se trata de dos reglas @font-face, una para cada fuente. De hecho, los estilos de la fuente son tan pequeños que decidimos incorporarlos en nuestro código HTML y quitar una conexión innecesaria. Para evitar el costo de la configuración de conexión de los archivos de la fuente, podemos copiarlos en nuestro propio servidor.

Cómo paralelizar cargas

Si observamos la cascada, podemos ver que, una vez que se termina de cargar el primer archivo JavaScript, los archivos nuevos comienzan a cargarse 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 es darse cuenta de 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 en el momento en que recibimos nuestro HTML.

Resultados

Veamos qué logramos con nuestros cambios. Es importante no cambiar ninguna otra variable en la 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 qué logramos con nuestros cambios.

Estos cambios redujeron nuestro TTI de 11 a 8.5, que es aproximadamente los 2.5 segundos de tiempo de configuración de conexión que queríamos quitar. Bien hecho.

Renderización previa

Si bien acabamos de reducir nuestro TTI, no afectamos la pantalla blanca eterna que el usuario debe soportar durante 8.5 segundos. Se podría decir que las mayores mejoras para la FMP se pueden lograr enviando un marcado con diseño en tu index.html. Las técnicas comunes para lograr esto 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 a 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 nuevo index.html. Dado que PROXX es una app de JAMStack y no tiene un 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 código JavaScript y, luego, volver a leer el DOM como una cadena de HTML. Como usamos módulos de CSS, obtenemos la intercalació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, podemos esperar una mejora en nuestro FMP. Aún debemos cargar y ejecutar la misma cantidad de JavaScript que antes, por lo que no deberíamos esperar que el TTI cambie mucho. En cualquier caso, nuestro index.html se hizo más grande y podría retrasar un poco nuestro TTI. Solo hay una manera de averiguarlo: ejecutar WebPageTest.

La película muestra una clara mejora en nuestra métrica de FMP. El TTI no se ve afectado en su mayoría.

Nuestro primer procesamiento de imagen significativa pasó de 8.5 segundos a 4.9 segundos, una mejora enorme. Nuestro TTI sigue ocurriendo en alrededor de 8.5 segundos, por lo que no se vio afectado en gran medida por este cambio. Lo que hicimos aquí es un cambio perceptual. Algunos incluso lo llamarían un truco de manos. Cuando renderizamos una imagen intermedia del juego, cambiamos el rendimiento de carga percibido para mejorarlo.

Inclusión

Otra métrica que nos proporcionan DevTools y WebPageTest es el tiempo hasta el primer byte (TTFB). 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 denominar 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 del servidor. DevTools y WebPageTest visualizan el TTFB con un color claro dentro del bloque de solicitud o respuesta.

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

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

Este problema fue el motivo por el que se concibió originalmente HTTP/2 Push. El desarrollador de la app sabe que se necesitan ciertos recursos y puede enviarlos por el cable. Cuando el cliente se da cuenta de que necesita recuperar recursos adicionales, estos ya están en las cachés del navegador. Resultó demasiado difícil hacerlo bien y se considera desaconsejable. Este espacio de problemas se revisará durante la estandarización de HTTP/3. Por ahora, la solución más fácil es anidar todos los recursos críticos a costa de la eficiencia del almacenamiento en caché.

Nuestro CSS crítico ya está intercalado gracias a los módulos CSS y nuestro renderizador previo basado en Puppeteer. Para JavaScript, debemos intercalar nuestros módulos críticos y sus dependencias. La dificultad de esta tarea varía según el empaquetador que uses.

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

Esto redujo 1 segundo nuestro TTI. Ahora llegamos al punto en el que nuestro index.html contiene todo lo necesario para la renderización inicial y para que se vuelva 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 código HTML, la app es interactiva.

División de código agresiva

Sí, nuestro index.html contiene todo lo necesario para que sea interactivo. Pero, tras una inspección más detallada, resulta que también contiene todo lo demás. Nuestro index.html es de alrededor de 43 KB. Pongamos 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, algún 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 esenciales.

Para comprender de dónde proviene el tamaño del 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 muchas utilidades. Solo se necesita un pequeño subconjunto de estos módulos para la página de destino. Si mueves todo lo que no es estrictamente necesario para la interactividad a un módulo de carga diferida, la TTI disminuirá significativamente.

El análisis del contenido de "index.html" de PROXX muestra muchos recursos innecesarios. Se destacan los recursos críticos.

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 y alineará 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 a import(). Por supuesto, acceder a la red tiene un costo y solo debe hacerse si tienes tiempo libre. El mantra aquí es importar de forma estática los módulos que son fundamentales en el momento de la carga y cargar todo lo demás de forma dinámica. Sin embargo, no debes esperar hasta el último momento para cargar de forma diferida los módulos que se usarán definitivamente. El Idle Until Urgent de Phil Walton es un excelente patrón para encontrar un punto intermedio saludable entre la carga diferida y la carga anticipada.

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 cargados de forma diferida de forma predeterminada. Por este motivo, escribimos un pequeño wrapper de componentes 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, <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 la FMP y el TTI? WebPageTest te lo dirá.

La película confirma que nuestro TTI ahora es de 5.4 s. Una mejora drástica respecto de nuestros 11s originales.

Nuestra FMP y TTI solo se diferencian en 100 ms, ya que solo es cuestión de analizar y ejecutar el código 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 trucos de manos

Si observas nuestra lista de módulos críticos anterior, verás que el motor de renderización no forma parte de ellos. Por supuesto, el juego no puede comenzar hasta que tengamos nuestro motor de renderización para renderizarlo. 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 la configuración del juego como para que esto no sea 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 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 terminen los módulos restantes.

Conclusión

La medición es importante. Para evitar dedicar tiempo a problemas que no son reales, te recomendamos que siempre realices mediciones antes de implementar optimizaciones. Además, las mediciones deben realizarse en dispositivos reales con una conexión 3G o en WebPageTest si no tienes un dispositivo real a mano.

La tira de película puede brindar información sobre cómo se siente el usuario cuando carga tu app. La cascada puede indicarte qué recursos son responsables de los tiempos de carga potencialmente largos. Esta es una lista de tareas que puedes realizar para mejorar el rendimiento de carga:

  • Publica tantos recursos como sea posible en una conexión.
  • Carga previa o incluso recursos intercalados que se requieren para la primera renderización y la interactividad.
  • Renderiza tu app de antemano para mejorar el rendimiento de carga percibido.
  • Usa una 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 hablaremos sobre cómo optimizar el rendimiento del entorno de ejecución en dispositivos con restricciones extremas.