- Hace 3 años, Notion logró mejorar la velocidad de la app de Notion para Mac y Windows usando una base de datos SQLite para almacenar datos en caché en el cliente
- Esta vez también lograron ofrecer la misma mejora a los usuarios que acceden a Notion a través del navegador
- Este artículo analiza en profundidad cómo mejoraron el rendimiento de Notion en el navegador usando la implementación
sqlite3 de WebAssembly (WASM)
- Al usar SQLite, el tiempo de navegación entre páginas mejoró un 20% en todos los navegadores modernos
- La diferencia fue aún más notable para usuarios cuyo tiempo de respuesta de la API era especialmente lento debido a factores externos como la conexión a internet
- Por ejemplo, para usuarios de Australia el tiempo de navegación entre páginas fue 28% más rápido, para usuarios de China 31% y para usuarios de India 33%
Tecnología clave: OPFS y Web Workers
- La biblioteca WASM SQLite usa una API moderna del navegador llamada Origin Private File System (OPFS) para mantener los datos entre sesiones
- OPFS solo puede usarse dentro de Web Workers
- Se puede pensar en un Web Worker como código que se ejecuta en un hilo separado del hilo principal, donde corre la mayor parte del JavaScript del navegador
- Notion se empaqueta con Webpack, que ofrece una sintaxis fácil de usar para cargar Web Workers
- Configuraron un Web Worker para crear un archivo de base de datos SQLite usando OPFS o cargar uno existente, y ejecutaron en ese Web Worker el código de caché ya existente
- Usaron la biblioteca Comlink para manejar fácilmente el paso de mensajes entre el hilo principal y el Worker
Enfoque basado en SharedWorker
- La arquitectura final se basa en una nueva solución presentada por Roy Hashimoto en una discusión de GitHub
- Un enfoque que permite que solo una pestaña acceda a SQLite a la vez, mientras otras pestañas también pueden ejecutar consultas SQLite
- ¿Cómo funciona esta nueva arquitectura?
- En pocas palabras, cada pestaña tiene un Web Worker dedicado que puede escribir en SQLite
- Sin embargo, en la práctica solo una pestaña puede usar realmente su Web Worker
- El SharedWorker se encarga de administrar cuál es la "pestaña activa"
- Si la pestaña activa se cierra, el SharedWorker sabe que debe elegir una nueva pestaña activa
- Para ejecutar consultas SQLite, el hilo principal de cada pestaña envía la consulta al SharedWorker, y este la redirige al Worker dedicado de la pestaña activa
- Las pestañas pueden ejecutar tantas consultas SQLite simultáneamente como quieran, y siempre se enrutan a una sola pestaña activa
- Cada Web Worker accede a la base de datos SQLite usando una implementación OPFS SyncAccessHandle Pool VFS que funciona en todos los navegadores principales
Por qué no funcionó un enfoque más simple
- Antes de construir la arquitectura descrita arriba, intentaron ejecutar WASM SQLite de una forma más simple: dar a cada pestaña su propio Web Worker dedicado y hacer que cada Web Worker escribiera en la base de datos SQLite
- Sin embargo, ninguna opción era suficiente tal cual para los requisitos de Notion
Obstáculo #1: aislamiento de origen cruzado
- Para usar OPFS vía
sqlite3_vfs, el sitio debe estar en estado de "cross-origin isolation"
- Para agregar aislamiento de origen cruzado a una página, hay que configurar varios encabezados de seguridad que restringen qué scripts se pueden cargar
- Configurar esos encabezados puede implicar bastante trabajo
- Notion depende de muchos scripts de terceros para operar distintas funciones de su infraestructura web, así que lograr un aislamiento de origen cruzado completo habría requerido pedir a cada proveedor que configurara nuevos encabezados y cambiara cómo funcionan sus iframes, algo difícil de hacer en la práctica
- En pruebas, pudieron obtener datos importantes de rendimiento ofreciendo esta variante a un subconjunto de usuarios mediante Origin Trials para
SharedArrayBuffer, disponibles en Chrome y Edge
- Esos Origin Trials permitieron evitar temporalmente el requisito de aislamiento de origen cruzado
Obstáculo #2: problemas de corrupción
- Cuando ofrecieron OPFS vía
sqlite3_vfs a un pequeño grupo de usuarios, comenzaron a aparecer bugs graves para algunos de ellos
- Esos usuarios veían datos incorrectos en la página
- Por ejemplo, comentarios asignados al compañero equivocado o enlaces a páginas nuevas cuya vista previa correspondía a una página completamente distinta
- Al revisar los archivos de base de datos de los usuarios afectados, encontraron patrones que indicaban que la base de datos SQLite se había corrompido de alguna manera
- Seleccionar filas de ciertas tablas producía errores y, al inspeccionar las filas, aparecían problemas de consistencia de datos, como múltiples filas con el mismo ID pero contenidos distintos
- Sobre cómo la base de datos SQLite llegó a ese estado, supusieron que se debía a problemas de concurrencia
- Había varias pestañas abiertas y cada una tenía un Web Worker dedicado con una conexión activa a la base de datos SQLite
- La aplicación de Notion escribe con frecuencia en la caché cada vez que recibe actualizaciones del servidor, es decir, cuando varias pestañas podían estar escribiendo al mismo archivo al mismo tiempo
- Ya usaban un enfoque con transacciones para agrupar consultas SQLite, pero sospecharon fuertemente que la corrupción se debía a la falta de manejo de concurrencia del lado de la API de OPFS
- Por eso comenzaron a registrar los errores de corrupción e intentaron algunos parches, como agregar Web Locks y hacer que solo la pestaña enfocada escribiera en SQLite
- Esos ajustes redujeron la tasa de corrupción, pero no lo suficiente como para volver a activar la función en tráfico de producción
- Aun así, pudieron confirmar que los problemas de concurrencia contribuían de forma importante a la corrupción
- Este problema no ocurría en la app de escritorio de Notion
- En esa plataforma, solo un proceso padre escribe en SQLite
- Se pueden abrir todas las pestañas que se quiera en la app, pero siempre hay un solo hilo accediendo al archivo de base de datos
Obstáculo #3: la alternativa solo puede ejecutarse en una pestaña a la vez
- También evaluaron la variante OPFS SyncAccessHandle Pool VFS
- Esta variante no requiere
SharedArrayBuffer, por lo que puede usarse en Safari, Firefox y otros navegadores sin Origin Trial para SharedArrayBuffer
- La desventaja de esta variante es que solo puede ejecutarse en una pestaña a la vez
- Si una pestaña posterior intenta abrir la base de datos SQLite, simplemente falla con un error
- Por un lado, eso significa que OPFS SyncAccessHandle Pool VFS no tiene los problemas de concurrencia de la variante OPFS vía
sqlite3_vfs
- Lo confirmaron al ofrecerla a un pequeño grupo de usuarios y no detectar problemas de corrupción
- Por otro lado, no podían lanzar esta variante tal cual porque querían que todas las pestañas de los usuarios se beneficiaran del caché
Resolución del problema
- El hecho de que ninguna variante pudiera usarse tal cual fue lo que los llevó a construir la arquitectura con SharedWorker descrita arriba
- Esta arquitectura es compatible con cualquiera de estas variantes de SQLite
- Al usar la variante OPFS vía
sqlite3_vfs, como solo una pestaña escribe a la vez, se evita el problema de corrupción
- Al usar la variante OPFS SyncAccessHandle Pool VFS, el SharedWorker permite que el caché esté disponible en todas las pestañas
- Después de confirmar que esta arquitectura funcionaba con ambas variantes, que la mejora de rendimiento era visible en las métricas y que no había problemas de corrupción, llegó el momento de elegir cuál variante ofrecer definitivamente
- Eligieron OPFS SyncAccessHandle Pool VFS porque no requiere aislamiento de origen cruzado, así que no bloquea el despliegue en navegadores distintos de Chrome y Edge
Mitigación de regresiones de rendimiento
- Cuando comenzaron a ofrecer esta mejora a los usuarios, detectaron algunas regresiones de rendimiento que tuvieron que corregir, como tiempos de carga más lentos
La carga de página se volvió más lenta
- El primer hallazgo fue que navegar entre páginas de Notion se volvió más rápido, pero la carga inicial de página se hizo más lenta
- El profiling mostró que la carga de página normalmente no está limitada por la obtención de datos
- El código de arranque de la app de Notion ejecuta otras tareas mientras espera que termine la llamada a la API, como parseo de JS, configuración de la app, etc., por lo que no se beneficia tanto del caché SQLite como la navegación
- La razón de la lentitud era que los usuarios tenían que descargar y procesar la biblioteca WASM SQLite
- Eso bloqueaba el proceso de carga de página, impidiendo que otras tareas de carga ocurrieran al mismo tiempo
- Como esta biblioteca pesa varios cientos de kilobytes, el tiempo adicional se notaba claramente en las métricas
- Para resolverlo, modificaron un poco la forma de cargar la biblioteca
- Cargaron WASM SQLite de forma completamente asíncrona para no bloquear la carga de página
- Eso significaba que era poco probable que los datos iniciales de la página se cargaran desde SQLite
- Les pareció aceptable, porque objetivamente concluyeron que la mejora de velocidad por cargar la página inicial desde SQLite no compensaba la pérdida causada por descargar la biblioteca
- Después de aplicar el cambio, las métricas de carga inicial de página quedaron iguales entre el grupo de prueba y el grupo de control del experimento
Los dispositivos lentos no se benefician del caché
- Otro fenómeno que vieron en las métricas fue que el tiempo mediano para navegar de una página de Notion a otra era más rápido, pero el tiempo del percentil 95 era más lento
- Ciertos dispositivos, como teléfonos móviles con un navegador apuntando a Notion, no se beneficiaban del caché e incluso empeoraban
- Encontraron la respuesta a este enigma en una investigación previa realizada por el equipo móvil
- Cuando implementaron este caché en la app móvil nativa, algunos dispositivos, como teléfonos Android antiguos, leían muy lentamente desde disco
- Por lo tanto, no se podía asumir que cargar datos desde la caché en disco sería más rápido que cargarlos desde la API
- Esa investigación móvil ya había llevado a una lógica donde dos solicitudes asíncronas, SQLite y API, "compiten" durante la carga de página
- Simplemente reimplementaron esa lógica en la ruta de código para los clics de navegación
- Eso igualó el percentil 95 de los tiempos de navegación entre los dos grupos del experimento
Conclusión
- Llevar las mejoras de rendimiento de SQLite a Notion en el navegador tuvo sus propias dificultades
- En particular, se enfrentaron a una serie de incógnitas relacionadas con tecnologías nuevas y sacaron varias lecciones en el proceso:
- OPFS no maneja la concurrencia de forma elegante por defecto. Los desarrolladores deben ser conscientes de ello y diseñar en consecuencia
- Web Workers y SharedWorkers (y su primo no mencionado aquí, Service Workers) tienen capacidades distintas, y puede ser útil combinarlos cuando sea necesario
- A primavera de 2024, no es fácil implementar completamente el aislamiento de origen cruzado en una aplicación web sofisticada, especialmente si usa scripts de terceros
- Como resultado de almacenar datos en caché con SQLite en el navegador para los usuarios, vieron la mejora de 20% en el tiempo de navegación mencionada antes, sin observar deterioro en otras métricas
- Lo importante es que no observaron problemas causados por corrupción de SQLite
- Consideran que el éxito y la estabilidad de este enfoque final se deben al equipo a cargo de la implementación oficial de SQLite en WASM, así como a Roy Hashimoto por ofrecer al público un enfoque experimental
6 comentarios
Por eso, los servicios que tienen que colaborar con terceros deberían salir desde su primer lanzamiento con el aislamiento entre orígenes activado...
Oh, cometkim, qué gusto verte.
En Firefox, cuando abro una página de Notion, se queda congelada y no puedo usarla. ¿Será por esto?.. (La app de Notion sí funciona bien, así que por ahora la estoy usando)
Probablemente sea así. Enda también solo admitía escritura de archivos locales en Chrome y Edge.
Me pasó algo así en una laptop Linux vieja; parece que abrirlo en modo privado funcionaba.