La experiencia es un eje central de todo lo que desarrollamos en Fintoc. Tanto la experiencia de los desarrolladores integrando nuestra plataforma como la experiencia de los usuarios que le pagan a distintos comercios usando nuestra API.

Tener que esperar —lo que sea— hace que la experiencia de cualquier interacción empeore sustancialmente. Y el flujo de un pago no es la excepción. Cuando llegó el momento de rediseñar nuestro widget, la velocidad fue un tema central cuando consideramos qué arquitectura usar.

Hablé un poco de las fundaciones de esta arquitectura en el post del meta-framework que armamos para el front-end de Fintoc. En este post hablaré asumiendo que entiendes lo que es una máquina de estados, un actor y cómo los usamos en nuestro widget. Si alguna de esas cosas no te suena familiar, te recomiendo que leas el post anterior, te dará el contexto necesario para entender algunas cosas de las que hablaré acá.

Con eso de lado, hablemos un poco de los problemas más grandes (en términos de velocidad) que teníamos en el widget antes de re-diseñarlo. Eran principalmente tres:

  1. Como nuestro widget era una SPA estándar y sin muchas optimizaciones antes de este cambio, todo el frontend se bundleaba en un mismo archivo grande, haciendo que cambios en algunas partes del widget enlentecieran la descarga de otras. Por ejemplo, agregar la imagen de un banco en Chile hacía que un usuario abriendo el widget de México tuviera que descargar esa imagen. Como esto también aplicaba para el código, los usuarios de Chile también tenían que descargar la lógica del widget de México.
  2. Nuestro widget se integra importando un tag script a la aplicación y luego ejecutando widget.open(). Lo que hace ese .open()  es básicamente insertar un iframe con el widget en la aplicación de nuestros clientes. Por si no sabías, los iframes se descargan en el browser tal como se tiene que descargar una aplicación web estándar. Y por la arquitectura que teníamos, esa descarga podía llegar a tomar algunos segundos después de hacer .open(). Este tiempo de espera tenía que desaparecer (o al menos reducirse drásticamente).
  3. Una vez que se abría el widget, había un spinner que duraba unos segundos más. Este tiempo de espera es inevitable, es el momento en el que el widget carga su configuración. Pero se sentía más lento de lo que debería.

Primero hablaremos de las reducciones en los tiempos de descarga y después de las mejoras en la sensación de velocidad. Pero antes de poder hablar de cómo resolvimos nuestro problema, hablaré un poco de los fundamentos del problema mismo.

1. Build tools, bundles y chunks

Particularmente (pero no exclusivamente) en el contexto de una SPA, el código que uno escribe rara vez es el que termina ejecutándose en el browser de los usuarios. Habitualmente, los devs escriben código con indentación, variables con nombres descriptivos y otras cosas que hacen que el software sea legible para un ser humano, pero que termina siendo innecesariamente pesado para una máquina. Usamos herramientas que los browsers no soportan nativamente, como TypeScript e incluso JSX (para aquellos que usan React) u otros formatos específicos para el framework de turno (.vue, por ejemplo, para aquellos que usan Vue).

Antes de hacer deploy de una SPA, comúnmente uno pasa su aplicación por una serie de build tools que transforman los lenguajes que el browser no conoce a JavaScript y hacen varias optimizaciones al código. Una de estas optimizaciones suele ser tomar el código y hacer un gran archivo de JavaScript que contiene todo el código. A este mamotreto le llamamos bundle.

En una SPA, el browser descarga el bundle completo desde el servidor que tiene la aplicación y luego renderea la UI (hay más pasos entremedio, pero los dejaremos para otro post). Esto significa que si el bundle es demasiado grande, el tiempo hasta que la UI pueda renderearse es demasiado alto.

Una estrategia para disminuir el tiempo de carga inicial es separar el bundle en varios chunks, que serían distintos archivos de JavaScript que se descargan solamente cuando van a ser usados. Esta estrategia disminuye el tamaño del bundle inicial, pero hace que sea necesario descargar los chunks antes de usarlos, lo que podría agregar tiempo a la navegación (porque hay que esperar a que terminen las descargas).

2. Pre-fetching de vistas inteligente

La mayoría de los routers para SPA soportan de una u otra forma separar cada vista en su propio chunk.

Aprovechamos esa funcionalidad para nuestro widget y cada vista —con todos sus assets y lógica asociada— es su propio chunk. El desglose se ve similar a esto (digo similar porque este código cambia constantemente, en Fintoc deployeamos más de 100 veces al mes).

El rectángulo rojo de la derecha es el bundle principal y los rectángulos de colores son distintos chunks. Esto tiene varias ventajas:

  • El bundle inicial es más pequeño que la aplicación completa
  • El bundle inicial no crecerá a medida que se le agreguen assets y vistas nuevas al widget
  • El código de un país o producto no afecta al peso de los demás. Es decir, si se agrega una imagen en cl/payments no aumenta el tamaño de la descarga para mx/payments

Pero también tiene una gran complicación: nuestro widget no es una SPA tradicional y no puede haber un delay entre que un usuario hace click en “continuar” y se cambia de vista. Hacer esperar al usuario es justamente lo que queremos evitar. Necesitábamos precargar las vistas siguientes de alguna manera inteligente.

Como nuestra navegación es programática —el actor se encarga de manejar la vista que se está rendereando y no se hace con botones o links—, no tenemos un tag a al que le podamos enchufar un listener para que cuando entre al viewport se le haga pre-fetch a la URL asociada.  Algunos routers traen esa funcionalidad incluida, pero no es nuestro caso.

La solución a este problema vino por partes.

Lo primero que necesitábamos saber era qué rutas teníamos guardadas y a qué componente correspondían. Para eso, armamos una función que define las rutas en el router del widget. Se usa de la siguiente manera:

// src/cl/payments/routes.ts

export const routes = {
  path: `/${NAMESPACE}`,
  component: () => import('./layout/Layout.vue'),
  children: [
    generatePrefetchedRoute({ name: 'bank-selection', component: () => import('./modules/bank-selection/BankSelection.vue') }),
  ],
} as const satisfies RouteRecordSingleViewWithChildren;

La función no hace más que guardar los datos de la ruta en una variable interna y devolver una ruta formateada para Vue Router. Acá una versión simplificada.

namespace corresponde al NAMESPACE que se usa arriba, pero me salté un par de abstracciones para simplificar el código interno. En la vida real hay una función con toda la lógica que recibe un namespace y hace de fábrica para cada máquina de estados, lo que nos ayuda a tipar fuertemente todo por separado:

import type { RouteRecordRaw } from 'vue-router';

type PrefetchedRegistry = {
  component: () => Promise<unknown>;
  fetched: boolean;
}

const routes: Record<string, PrefetchedRegistry | undefined> = {};

const generatePrefetchedRoute = <TComponent, TName extends string>(
  { name, component }: { name: TName, component: () => Promise<TComponent> }
) => {
  const namespacedName = `${namespace}/${name}` as const;
  routes[namespacedName] = { component, fetched: false };
  const path = `/${name}` as const;
  return { path, name: namespacedName, component } satisfies RouteRecordRaw;
}

Hasta acá, no hemos hecho más que definir nuestras rutas. Estas definiciones nos permiten escribir la siguiente función (dentro del mismo archivo que generatePrefetchedRoute):

const handlePrefetchRequest = async (
  { namespace, name }: { namespace: string, name: string },
) => {
  const namespacedName = `${namespace}/${name}` as const;
  const route = routes[namespacedName];
  
  if (route === undefined || route.fetched) {
    return;
  }
  
  await route.component();
  route.fetched = true;
}
routes en handlePrefetchRequest es el mismo objeto que se usó en generatePrefetchedRoute

La línea await route.component() obliga al browser a ir a buscar el componente al servidor si no lo ha descargado aún 🤯. Recordemos que component va a ser algo como () => import('./modules/bank-selection/BankSelection.vue'), así lo definimos en las rutas.

Ahora solo queda saber cuándo hacer el pre-fetch. Para esto, podemos pedirle ayuda a nuestro querido actor.

El actor sabe en qué estado está y puede computar (a partir de la máquina de estados) a qué estados puede llegar directo desde el estado actual. Sin adentrarnos mucho en los detalles, podemos enchufarnos a los cambios de estado de un actor usando el método subscribe. Una versión relativamente simple de lo que hace el actor con las abstracciones que construimos previamente:

import type { AnyStateMachine, AnyMachineSnapshot } from 'xstate';
import { createActor } from 'xstate';

const handlePrefetching = (machine: AnyStateMachine) => (
  async (snapshot: AnyMachineSnapshot) => {
    const currentView = extractView(machine, snapshot.value);
    const nextStates = extractNextStates(machine, snapshot.value);
    if (currentView) {
      await handlePrefetchRequest(currentView);
    }
    nextStates.forEach((nextState) => {
      const view = extractView(machine, nextState);
      if (view) {
        handlePrefetchRequest(view);
      }
    });
  }
);

const startActorForMachine = (machine: AnyStateMachine) => {
  const actor = createActor(machine);
  actor.subscribe(handlePrefetching(machine));
  actor.start();
};

Acá hay una cosa interesante que notar.

Si lees de cerca, se ve que hacemos await del handlePrefetchRequest de la vista a la que corresponde el estado actual antes de hacer cualquier otra cosa. Esto es porque la primera vista no está cargada inmediatamente y, si hacemos el prefetch de esa vista más las potenciales vistas siguientes, entonces la descarga de la vista que hay que mostrar inmediatamente puede tomar más tiempo (estaríamos ocupando el bandwidth con todas las otras vistas).

Con este código ya tenemos el pre-fetch listo 🙌

El comportamiento es más o menos el siguiente:

Y con eso, una combinación país/producto nunca necesita descargar assets ni lógica de otro país/producto, haciendo que la descarga inicial sea mucho más rápida, ya que solamente se descarga el código y assets que potencialmente serán usados (en el GIF de arriba vemos cómo X0, X1, Z0, Z1 y Z2 nunca son descargados). Por supuesto, el dev no tiene que preocuparse por nada de esto, ya que ocurre automáticamente.

3. Preloading del widget y assets

Con el paso anterior ya disminuimos el tiempo que se demora el iframe en descargarse (dado que el bundle inicial es bastante más pequeño y se mantendrá así frente a cualquier cambio), pero aún podía mejorar.

Se me ocurrió que podríamos pre-cargar el widget completo. Esto requirió un par de cambios a nuestro Script (el que se importa en el HTML de la aplicación de nuestro cliente).

Esta nueva versión de nuestro Script agrega el iframe al HTML de la aplicación de nuestro cliente después de que el HTML se terminó de parsear (acá omito otros pasos, pero es suficientemente preciso), cosa que hace que todo el bundle inicial de nuestro widget se descargue mientras la aplicación de nuestro cliente se está rendereando. O sea que cuando el usuario llegue al botón de pagar, es posible que el widget se abra de manera casi instantánea. En el peor de los casos, el tiempo de descarga será el mismo que si se inserta el iframe en el momento de abrir el widget.

Tener el iframe abierto también trajo una posibilidad impensada. Como la aplicación técnicamente ya está abierta (antes de que siquiera se inicie el flujo del pago), podemos preloadear assets. Así, esos assets no necesitan descargarse durante el flujo de pago y pueden quedar descargados desde antes.

Hay una sola cosa con la que hay que tener cuidado acá. Si se hacen muchas descargas en paralelo, se repartirá el bandwidth entre todas las descargas y se demorarán mucho más, haciendo que en la práctica no se use el preloading de ningún asset.

Como quizás ya sabes, un gran foco para mí es la DX de nuestros devs. Es muy importante que la abstracción sea simple de usar y que no haya que inventar nada.

Armé entonces un método que descarga en paralelo todos los componentes que se le pasen en un arreglo initial y después de eso descarga uno por uno los componentes en un arreglo deferred, partiendo por el primero. El código por debajo es relativamente simple:

type Options<TComponent> = {
  initial?: Array<() => Promise<TComponent>>,
  deferred?: Array<() => Promise<TComponent>>,
}

export const usePreloadedComponents = async <TComponent>(options: Options<TComponent>) => {
  const { initial = [], deferred = [] } = options;

  const preloadInitialComponents = () => initial.map((component) => component());

  const preloadDeferredComponents = () => deferred.reduce(
    async (promise: Promise<TComponent | void>, component) => promise.then(component),
    Promise.resolve(),
  );

  await Promise.all(preloadInitialComponents());
  await preloadDeferredComponents();
};

De esta forma, los devs pueden definir declarativamente qué quieren que se preloadee y el código lo hará automáticamente. Hay una vista que se monta en preload, donde se llama este método. Así se vería más o menos la llamada:

usePreloadedComponents({
  initial: [
    () => import('@/mx/payments/modules/loading-config/LoadingConfig.vue'),
    () => import('@/cl/payments/modules/loading-config/LoadingConfig.vue'),
  ],
  deferred: [
    () => import('@/mx/shared/assets/banks/banregio.svg'),
    () => import('@/cl/shared/assets/banks/bice.svg'),
  ],
});

Como explicaba un poco antes, lo que está en initial se descargará en paralelo y, luego, lo que está en deferred empezará a descargarse uno a uno. Acá estoy además preloadeando tanto assets individuales (banregio.svg y bice.svg son svgs individuales) como vistas completas (los LoadingConfig.vue son las vistas que se usan para la carga de la configuración del widget), por lo que es bastante versátil.

Para que se entienda mejor cómo esto mejoró los tiempos de carga, a continuación te dejo un ejemplo de una aplicación simple (Main App) en la que el usuario elige el monto de su pago (Introduce payment amount), se genera un Payment Intent (Generate PI) necesario para abrir el widget y se llama Fintoc.open con el PI recién generado. En el checkpoint user sees loading screen recién se ve el widget abierto.

El flujo de levantar el widget sin preloading. El tiempo entre Fintoc.open y que el usuario vea la pantalla de carga es muy alto.
El flujo de levantar el widget con preloading. El tiempo entre Fintoc.open y que el usuario vea la pantalla de carga es prácticamente 0.
Notar que mount y render no están hechos a escala, deberían ser muchísimo más pequeños, pero sería difícil mostrar a qué corresponden

Adiós al spinner de la muerte

Un último problema que queríamos remediar era la percepción de velocidad de apertura del widget. Como comenté en la intro, lo primero que debe hacer el widget al abrirse es cargar su configuración desde el servidor. Esta configuración depende de cada pago individual, por lo que no se puede pre-loadear como hacemos con la aplicación misma.

Esto significa que siempre existe un momento al principio del flujo en que hay que comunicar un tiempo de espera. Hasta antes del rework del widget, esta parte inicial se veía así:

La decisión de mostrar un spinner nunca fue una decisión consciente, solamente una respuesta a la necesidad de comunicar una espera. Pero podíamos hacerlo mejor.

El problema del spinner era que no entregaba ninguna información sobre qué se estaba cargando. Si la carga de la configuración se demoraba —por temas de latencia en la conexión del usuario, por ejemplo—, estar mirando un overlay con un spinner en la mitad por 3 segundos daba la sensación de que algo se había roto y de que el widget nunca se abriría.

Para remediar esto, nuestro equipo de diseño armó un Skeleton para la carga de la configuración:

Este Skeleton se ajusta a lo que venga después. Arriba muestro dos de las posibles pantallas para la carga de la configuración, que cambia dependiendo de cual sea la vista inicial del widget

Sin entrar en muchos detalles técnicos (dejaré eso para un post del equipo de diseño de Fintoc 👀), la ventaja que tiene usar un Skeleton en vez de un spinner es que la estructura del widget ya está cargada. No se siente como que se está cargando algo tan pesado, porque solamente “falta que se cargue el contenido”,  diferencia del spinner que no da luces sobre la estructura de lo que viene a continuación. Si la carga de la configuración se demora, por lo menos la forma del widget ya se mostró, entonces no se siente como que algo se rompió al intentar abrir el widget.

Usar un Skeleton en vez del spinner también tiene el efecto que queríamos de hacer que la percepción de velocidad de la carga de la configuración mejorara. Acá un artículo interesante sobre Skeletons por si quieres leer un poco más sobre este tema.

Nota sobre la experiencia

Con un poco de ingenio, creatividad y atención a los detalles (y tiempo) se pueden hacer cosas bastante sorprendentes. Pudimos mejorar mucho la experiencia de nuestros usuarios con un poco de ingenio, aprendimos harto de la prueba/error y fue muy satisfactorio de diseñar.

Algo de lo que no hablé tanto a lo largo de este post (a pesar de que lo mencioné brevemente) fue de la experiencia de desarrollo. Gran parte de lo que hace la experiencia de desarrollo de nuestro widget espectacular es el tipado end-to-end del que nos preocupamos tanto desde el momento 0. En mi próximo blog voy a contarte cómo armamos nuestro sistema para ser a prueba de balas en términos de TypeScript (incluso en entornos extremadamente dinámicos), cómo los tipos mejoran la experiencia de desarrollo en vez de empeorarla (hint: los tipos van en las abstracciones) y cómo estructuramos el sistema para que los tipos sean resilientes y no se puedan romper a medida que devs nuevos modifican la aplicación.


Si te apasiona aprender sobre construcción de herramientas para desarrolladores o mejorar la experiencia de tus usuarios más allá de la UI, probablemente te gustaría trabajar en Fintoc. Te invito a postular a alguna de las vacantes que tenemos abiertas!