El arranque en frío: los milisegundos que nadie diseña
Entre que el usuario abre la pestaña y la base está lista hay una secuencia de etapas que fallan por separado, y lo que la interfaz muestre durante ese intervalo es una decisión de arquitectura, no de maquetación.
Hay un intervalo en toda aplicación local-first que casi nadie diseña y que sin embargo es lo primero que el usuario experimenta: el que va desde que pulsa el marcador hasta que la base de datos puede responder a la primera pregunta. Durante ese intervalo tu aplicación no está lenta, es que literalmente no existe todavía, y la diferencia importa porque las dos situaciones piden respuestas distintas de la interfaz. La ironía del asunto es difícil de exagerar: el software local-first se vende con la promesa de que no habrá esperas, y la arquitectura que hace posible esa promesa introduce una espera al principio que puede superar con creces la de cargar una página convencional contra un servidor. Esta lección desmonta ese intervalo etapa por etapa, lo convierte en una máquina de estados explícita, y examina la técnica que permite enseñar algo verdadero antes de que la base esté disponible.
- Descomponer el arranque en frío en sus etapas reales y saber cuál domina en cada caso.
- Modelar el arranque como una máquina de estados explícita en lugar de como un indicador booleano de carga.
- Aplicar la hidratación optimista con sus tres reglas, y reconocer cuándo es peligrosa.
- Decidir con criterio qué mostrar en cada tramo del intervalo y qué contar cuando algo falla.
La secuencia real, etapa por etapa
Lo que la gente llama arrancar la base son en realidad ocho etapas encadenadas, con perfiles de coste muy distintos y, sobre todo, con modos de fallo independientes. Enumerarlas es la mitad del trabajo, porque el error habitual es tratarlas como un bloque opaco que termina bien o mal.
- Carga del documento y del código de la aplicación. Antes de que exista ninguna base, el navegador tiene que analizar y ejecutar tu código. Es la etapa que ya conocías y la única que las herramientas convencionales miden bien.
- Creación del Worker. Barata, pero no gratuita, y ocurre después de que tu código haya empezado a correr.
- Adquisición del bloqueo de líder. Si otra pestaña ya lo tiene, esta ruta no continúa: la pestaña se convierte en seguidora, que es un camino distinto con un coste distinto y casi siempre menor.
- Descarga del binario del motor. Decenas o cientos de kilobytes en el primer arranque, servidos desde la caché en los siguientes. Es la etapa que más se beneficia de la compilación en flujo mientras se descarga.
- Compilación e instanciación. Coste real de procesador, muy dependiente del dispositivo, y donde la diferencia entre un portátil de desarrollo y un móvil de gama media alcanza un factor incómodo de mirar.
- Apertura del almacén. Adquirir el manejador exclusivo sobre el fichero. Rápida cuando funciona; cuando no, produce el error de contención que conoces del nivel 10.
- Verificación del esquema. Leer la versión almacenada y compararla con la que el código espera. Milisegundos.
- Migración. La única etapa sin cota superior. Añadir una columna es instantáneo; reescribir una tabla de medio gigabyte no lo es, y ocurre en el dispositivo del usuario, una sola vez, sin que nadie lo haya podido ensayar a esa escala.
Casi todo el esfuerzo de optimización de arranque se dedica a las etapas cuatro y cinco, que son las que aparecen en las guías de rendimiento y las que tienen soluciones conocidas. Pero en una aplicación con datos reales, la etapa que decide la experiencia es la octava, y tiene dos particularidades que la hacen especialmente peligrosa: su duración crece con el volumen acumulado del usuario, de modo que empeora justo para quien más usa tu aplicación, y solo ocurre una vez por despliegue, lo que significa que jamás la verás en tu propio equipo después del primer intento. Mide las migraciones contra una base del percentil noventa y cinco de tus usuarios antes de publicarlas, o publicarás una espera que no puedes ni estimar.
La máquina de estados
Un indicador booleano de carga es insuficiente porque no distingue entre estar arrancando y haber fracasado, ni entre las distintas razones de fracaso, que piden mensajes y acciones diferentes. Lo que necesitas es una máquina de estados explícita, publicada por el Worker y observable desde la interfaz.
stateDiagram-v2 [*] --> Arrancando Arrancando --> Seguidora: otra pestana tiene el bloqueo Arrancando --> Cargando: bloqueo obtenido Cargando --> Instanciando: binario descargado Instanciando --> Abriendo: modulo listo Abriendo --> Verificando: manejador obtenido Abriendo --> Bloqueada: manejador denegado Verificando --> Migrando: esquema desfasado Verificando --> Listo: esquema correcto Migrando --> Listo: migracion completada Migrando --> Rota: migracion fallida Seguidora --> Listo: proxy establecido Seguidora --> Cargando: el lider desaparecio Listo --> [*]
Tres observaciones sobre este diagrama que no son evidentes en el código. La primera es que el camino de la seguidora es un camino de primera clase, no una excepción: en una aplicación que la gente usa con varias pestañas, es el camino más frecuente, suele ser más rápido porque se salta la descarga y la instanciación, y tiene su propio modo de fallo cuando el líder desaparece a mitad. La segunda es que hay dos estados terminales de error distintos —bloqueada y rota— que exigen mensajes opuestos: uno se resuelve cerrando otra pestaña y el otro no se resuelve con nada que el usuario pueda hacer. La tercera es que la transición de seguidora a candidata a líder debe existir, porque el usuario cerrará la pestaña líder tarde o temprano y las demás no pueden quedarse muertas.
Sobre las etapas cuatro y cinco conviene decir lo que sí funciona, porque hay dos técnicas que rinden mucho y son casi gratuitas. La primera es compilar mientras se descarga: las interfaces de instanciación que aceptan directamente la respuesta de red solapan ambas etapas en lugar de encadenarlas, y en conexiones modestas eso recorta un tramo apreciable sin cambiar nada más. La segunda es empezar antes: nada obliga a esperar a que tu aplicación termine de arrancar para lanzar la descarga del binario y la creación del Worker, y adelantarlas al principio del documento las solapa con el análisis y la ejecución de tu propio código. Las dos juntas suelen convertir dos etapas secuenciales en una sola espera, que es la única forma real de mejorar una cadena.
Mientras la máquina no llega a listo, las peticiones que la interfaz emita tienen que encolarse, nunca perderse ni fallar. Esa cola es la pieza que permite que el resto de la aplicación se escriba sin pensar en el arranque.
const cola = [];
let estado = 'arrancando';
export function pedir(metodo, args) {
if (estado === 'listo') return enviar(metodo, args);
if (estado === 'rota') return Promise.reject(new Error('BASE_NO_DISPONIBLE'));
return new Promise((resolve, reject) => cola.push({ metodo, args, resolve, reject }));
}
function alPasarAListo() {
estado = 'listo';
const pendientes = cola.splice(0); // vaciar antes de reenviar
for (const p of pendientes) enviar(p.metodo, p.args).then(p.resolve, p.reject);
}
Hidratación optimista
Existe una forma de romper la espera que consiste en no esperar: guardar en un almacén de acceso inmediato una instantánea de lo último que la interfaz mostró, pintarla en el primer fotograma y reconciliarla cuando la base esté disponible. La aplicación aparece llena de contenido en decenas de milisegundos, y la base termina de arrancar por detrás sin que nadie la mire.
Dónde guardar esa instantánea es la primera decisión y tiene una restricción dura: debe poder leerse antes de que exista ningún Worker y sin esperar a nada asíncrono que retrase el primer pintado. Eso descarta la propia base, descarta el almacén de ficheros del origen y deja como candidatos los almacenes de clave y valor de acceso inmediato para volúmenes pequeños, o la caché de recursos para volúmenes mayores a cambio de una espera breve. La regla de tamaño es tajante: si guardar o leer la instantánea empieza a costar tanto como arrancar la base, la técnica ha dejado de tener sentido y solo estás añadiendo una fuente más de estado desactualizado.
La técnica funciona bien y tiene tres reglas que no admiten excepción.
Regla uno: la instantánea es solo para mostrar. Es un fotograma congelado de una vista, no una réplica del estado. Nada que se calcule a partir de ella puede escribirse ni acumularse, porque no sabes cuánto ha envejecido ni qué le falta.
Regla dos: se marca como provisional y se dice. No basta con saber internamente que el dato es viejo: la interfaz tiene que comunicarlo de una forma discreta pero real, porque un usuario que actúa sobre información caduca creyéndola actual acabará culpando a la aplicación con toda la razón.
Regla tres: no se aceptan escrituras hasta la reconciliación. Una intención emitida contra un estado que no has verificado es la vía más corta a un conflicto que nadie sabrá diagnosticar. Los controles de escritura permanecen inertes, con su motivo visible, hasta que la máquina llegue a listo.
// Al terminar cada pintado con datos confirmados
guardarInstantanea('panel', { version: ultimaVersion, filas: visibles.slice(0, 50) });
// En el arranque, antes de que el Worker exista siquiera
const foto = leerInstantanea('panel');
if (foto) pintarProvisional(foto.filas); // sin permitir escrituras
// Cuando el Worker anuncia que esta listo
const real = await pedir('cargarPanel', {});
reconciliar(real); // puede contradecir a la instantanea
Una instantánea que sabe con qué número de secuencia se generó vale mucho más que una que solo guarda filas. Con ella, la reconciliación puede a veces evitarse por completo si la versión coincide con la que el escritor reporta, puedes descartar automáticamente instantáneas demasiado viejas para ser útiles, y sobre todo puedes medir cuánto se equivocó la hidratación optimista, que es el único dato que te dirá si la técnica está ayudando o mintiendo. Sin ese número, la reconciliación siempre repinta todo y nunca sabrás cuántas veces mostraste algo incorrecto.
Qué mostrar mientras tanto
La decisión final es de comunicación, y se rige por un principio que conviene enunciar sin adornos: lo que muestras debe corresponder a la duración esperada, no a la duración real. Un indicador de progreso que aparece durante ochenta milisegundos es un parpadeo molesto; su ausencia durante cuatro segundos es una aplicación rota.
Arranque muy breve
Cuando la etapa esperada dura menos que un par de fotogramas, no muestres nada. Cualquier indicador que aparezca y desaparezca a esa velocidad se percibe como un fallo visual, no como información.
Arranque corto
Un esqueleto con la forma real de la pantalla que va a aparecer. Comunica que hay algo en camino y evita el salto de maquetación cuando el contenido llega.
Arranque largo
Cuando pasas de un segundo, el usuario necesita saber que sigues vivo y por qué. Nombra la etapa en su idioma: preparar la base de datos, actualizar el formato de tus datos. La honestidad compra paciencia.
Arranque fallido
Un mensaje por causa, con exactamente una acción. Otra pestaña tiene la base abierta pide cerrarla; una migración fallida pide exportar y contactar. Nunca el mismo texto genérico para las dos.
Queda un caso que no encaja en ninguna de las cuatro tarjetas y que hay que tratar aparte: el arranque de una pestaña seguidora. Es habitualmente el más rápido de todos, porque se salta la descarga, la compilación, la apertura del fichero y la migración, y se reduce a establecer el proxy con la líder. Merece por tanto un tratamiento distinto: donde la líder muestra un esqueleto, la seguidora puede a menudo mostrar contenido directamente. Y merece también su propio modo de fallo, porque si la líder desaparece durante ese establecimiento, la seguidora tiene que reintentar la adquisición del bloqueo y recorrer entonces el camino largo, pasando de un arranque de decenas de milisegundos a uno de cientos sin ninguna acción del usuario que lo explique.
Una advertencia sobre la etapa larga que se aprende cara: si vas a nombrar las etapas, no muestres una barra de progreso a menos que tengas una estimación real de cuánto falta. Una barra que se detiene al ochenta por ciento durante diez segundos daña más la confianza que un indicador indeterminado acompañado de un texto sincero, porque promete una precisión que no tienes. Y para migraciones largas, el mejor movimiento es directamente informar del número de registros procesados, que es un dato verdadero, monótono y comprensible.
Vale la pena mirar de frente la contradicción aparente de esta lección, porque de ella se deduce cuándo tiene sentido toda la arquitectura y cuándo no. Local-first prometía eliminar la espera, y lo que has construido tiene una espera inicial que puede ser mayor que la de la aplicación convencional a la que sustituye. No es un fallo del enfoque: es su intercambio fundamental, y conviene enunciarlo con precisión porque enunciado mal lleva a decisiones equivocadas. La arquitectura convencional distribuye un coste moderado a lo largo de todas las interacciones: cada lectura es una pregunta remota, cada pregunta cuesta una ida y vuelta por la red, y ese coste se paga tantas veces como el usuario actúe, para siempre, en cada sesión y en cada dispositivo. La arquitectura local-first concentra un coste mayor en una sola interacción, la primera, y hace que todas las demás sean gratuitas. La aritmética de si sale a cuenta es elemental y sorprendentemente poco practicada: multiplica la latencia por operación por el número de operaciones de una sesión típica y compárala con el arranque. Para una herramienta de trabajo donde alguien pasa horas, con cientos o miles de interacciones, el intercambio es abrumadoramente favorable y el arranque se amortiza en los primeros segundos de uso. Para una página que el usuario visita treinta segundos, mira dos datos y abandona, el intercambio es ruinoso y ninguna cantidad de optimización del binario lo arreglará, porque el problema no es el tamaño sino la forma de la curva. De ahí se sigue el criterio que deberías aplicar antes de escribir una línea de esta arquitectura, y que es el mismo que el nivel 5 planteaba en abstracto y que ahora puedes formular con números concretos: local-first no es para aplicaciones que se visitan, es para aplicaciones que se habitan. Y de ahí se sigue también la consecuencia técnica que casi nadie extrae: si tu ventaja depende de amortizar el arranque, entonces evitar arranques en frío es tu optimización de mayor rendimiento, muy por encima de acelerar el que ya tienes. Mantener el Worker vivo entre navegaciones, no destruirlo al cambiar de vista, hacer que las pestañas nuevas se conviertan en seguidoras de una que ya arrancó, y aprovechar la caché de retroceso en lugar de combatirla valen más que cualquier reducción del binario, porque el mejor arranque en frío es el que no ocurre.
- Instrumenta las ocho etapas por separado con marcas de rendimiento y publica los tiempos. Averigua cuál domina en tu caso, sin suponerlo.
- Repite la medición en un dispositivo modesto y con la caché vacía. Anota el factor respecto de tu equipo de desarrollo.
- Implementa la máquina de estados completa, incluidos los dos estados terminales de error, y escribe el mensaje de cada uno con una sola acción.
- Añade la cola de peticiones previas a listo y comprueba que ninguna petición emitida durante el arranque se pierde.
- Implementa la hidratación optimista con las tres reglas, guarda la versión junto a la instantánea y mide cuántas veces la reconciliación contradice lo mostrado.
- Genera una migración deliberadamente lenta sobre una base grande y decide qué ve el usuario durante ella. Después, cronométrala.