A medida que el Azion Console crecía, empezamos a notar un patrón recurrente en la experiencia de navegación: estados de carga reapareciendo durante las transiciones entre pantallas, listas recargándose innecesariamente y datos desapareciendo después de un refresh.
Al principio parecía un problema de rendimiento, pero la API ya era rápida. El verdadero problema era otro: el Console no estaba preservando suficiente contexto entre interacciones y seguía reconstruyendo estado que ya estaba disponible.
Este artículo documenta cómo optimizamos la navegación del Azion Console mediante cambios en la capa de datos: las decisiones técnicas, casos límite y patrones arquitectónicos que surgieron durante el proceso y que resultaron útiles más allá de nuestra propia implementación.
Cómo evolucionó la navegación a medida que crecía el Console
El Azion Console gestiona aplicaciones, políticas de seguridad, serverless functions, storage, databases, certificados y otros recursos de infraestructura distribuida. Es utilizado por equipos que suelen trabajar con múltiples pestañas y sesiones concurrentes.
Como ocurre con muchas SPAs que evolucionan con el tiempo, la capa de datos original seguía un patrón simple:
export const listEdgeNodes = async (params) => { const response = await AxiosHttpClientAdapter.request({ url: `/api/v3/edge_nodes?${qs(params)}`, method: 'GET' })
return response.data.results.map(adapt)}Cada vista obtenía sus propios datos durante el mount. Durante un tiempo funcionó bien.
El cambio apareció cuando el Console empezó a crecer: más recursos significaban más transiciones entre pantallas, más estados concurrentes y más situaciones donde distintas partes de la interfaz dependían de la misma información.
Los síntomas empezaron a ser visibles:
- Cada navegación disparaba nuevas solicitudes, incluso para datos obtenidos segundos antes.
- Las pantallas de edición volvían a consultar información ya visible en la vista anterior.
- Distintos componentes solicitaban el mismo recurso en paralelo.
- Las mutations requerían coordinación manual de refetch.
- Las pestañas abiertas simultáneamente empezaban a divergir.
El cuello de botella dejó de ser la velocidad de las solicitudes. El problema pasó a ser coordinar el comportamiento del cache en toda la aplicación.
Arquitectura de servicios centralizada
El primer impulso fue reemplazar llamadas de fetch por hooks de TanStack Query.
Eso resolvía deduplicación y cache local, pero dejaba intacto el problema principal: cada vista seguía definiendo sus propias reglas de carga, invalidación y sincronización.
Mientras esas reglas permanecieran distribuidas, mantener comportamiento consistente seguiría siendo difícil.
La solución fue introducir una abstracción BaseService responsable de centralizar ese contrato:
export class BaseService { http = httpService queryClient = queryClient queryKeys = queryKeys
useQuery(queryKey, queryFn, options = {}) {}
useMutation(mutationFn, options = {}) {}
async usePrefetchQuery(queryKey, queryFn, options = {}) {}
getFromCache({ queryKey, id }) { // Retorna sincronamente del cache en memoria }}Cada servicio de dominio extiende esta base. Las vistas dejaron de decidir cómo cargar y actualizar datos y pasaron a consumir servicios.
Cache, invalidación, persistencia y sincronización dejaron de ser decisiones locales y se convirtieron en capacidades compartidas.
Con los servicios centralizados, el problema dejó de ser cómo obtener datos y pasó a ser cómo invalidar de forma consistente”.
Query keys jerárquicos
La primera decisión arquitectónica fue la estructura de los query keys. Antes de la migración eran strings definidos localmente.
Después se convirtieron en una estructura jerárquica centralizada:
export const queryKeys = { application: { all: ['application'],
detail: (id) => [ ...queryKeys.application.all, id ],
list: (params) => [ ...queryKeys.application.all, 'list', normalizeParams(params) ] }}Esta jerarquía permitió invalidación masiva predecible. Invalidar queryKeys.application.all invalida automáticamente listas, detalles y recursos relacionados sin necesidad de rastrear cada forma individual de query.
Esa propiedad se volvió esencial cuando la invalidación impulsada por SSE y la sincronización entre pestañas entraron en escena.
El problema de identidad de parámetros
Uno de los primeros problemas apareció temprano durante la migración. Algunas consultas ignoraban el cache incluso cuando los datos ya existían.
El problema no era la red ni la invalidación. Era identidad.
Estas dos llamadas, por ejemplo, necesitaban representar exactamente la misma consulta:
useQuery(queryKeys.application.list({}))
useQuery( queryKeys.application.list({ fields: [] }))Sin normalización, terminaban generando llaves diferentes y haciendo solicitudes independientes para recuperar el mismo recurso. La solución fue introducir normalización de parámetros:
const normalizeParams = (params) => { if (!params || typeof params !== 'object') { return params }
const normalized = { ...params }
for (const key of Object.keys(normalized)) { if ( Array.isArray(normalized[key]) && normalized[key].length === 0 ) { delete normalized[key] } }
return normalized}El aprendizaje aquí tuvo menos relación con cache y más con identidad.
Si consultas equivalentes generan llaves distintas, el cache deja de producir el comportamiento esperado independientemente de cualquier otra sofisticación construida encima.
Cache hydration en flujos de lista a edición
La normalización de parámetros resolvió cache misses, pero no abordó un problema mucho más visible para quienes usaban el Console: la transición de lista a edición seguía empezando desde cero.
Incluso cuando la vista anterior acababa de cargar exactamente ese recurso, la aplicación ignoraba datos ya disponibles en memoria y esperaba nuevamente por la red.
La solución fue introducir cache hydration síncrono:
<script setup>
const cachedEdgeNode = edgeNodeService .getEdgeNodeFromCache( edgeNodeId.value )
if (cachedEdgeNode?.name) { edgeNode.value = cachedEdgeNode}
</script>Con esto, la pantalla de edición empezó a renderizar inmediatamente usando datos ya presentes en cache. La solicitud completa de detalles sigue ejecutándose en paralelo para complementar campos adicionales que no vienen en las respuestas de lista, pero deja de bloquear la interfaz.
Los títulos aparecen al instante, los breadcrumbs se completan y el formulario ya está disponible. Este patrón funciona especialmente bien en flujos de lista a detalle.
En muchos casos, los datos ya existen en memoria. La aplicación simplemente todavía no está preparada para reutilizarlos..
Persistencia más allá de la sesión
El cache en memoria resolvió gran parte de la redundancia durante la navegación, pero todavía existía un comportamiento que rompía continuidad: los datos desaparecían después de un refresh.
Para una herramienta usada en operaciones de infraestructura, eso significaba perder contexto y reconstruir información que ya había sido cargada.
Para resolverlo agregamos persistencia vía IndexedDB usando idb-keyval. La elección frente a localStorage estuvo motivada principalmente por límites de cuota y porque las operaciones síncronas empiezan a perder eficiencia conforme crece el volumen de datos.
Como este estado incluía nombres de recursos y metadatos de cuenta, el cache pasó a encriptarse en reposo usando AES-GCM vía Web Crypto API:
async function generateKey() { return crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, false, [ 'encrypt', 'decrypt' ] )}El parámetro extractable: false fue una decisión intencional: la llave no puede ser leída por JavaScript de la aplicación y solo puede utilizarse a través de la interfaz criptográfica del navegador.
También agrupamos operaciones de escritura en pequeñas ventanas de tiempo para reducir presión sobre IndexedDB.
Ese diseño introdujo un trade-off. Cerrar la pestaña inmediatamente después de una mutation puede significar perder el último delta persistido.
Aceptamos ese comportamiento porque la persistencia funciona como una capa de optimización y no como fuente de verdad.
Sincronización entre pestañas
Sin sincronización, una mutation realizada en una pestaña deja las demás con estado desactualizado. La primera capa utilizó el plugin broadcastQueryClient de TanStack Query:
broadcastQueryClient({ queryClient, broadcastChannel: 'app-azion-sync'})Esto permite propagar mutations e invalidaciones entre pestañas, pero solo cubre cambios locales dentro de la sesión actual del navegador.
Para reflejar eventos externos de infraestructura o cambios realizados por otros usuarios, agregamos una conexión SSE persistente que recibe actividad desde el backend.
Una estructura simplificada del evento:
{ "type": "activity", "data": { "activity_type": "updated", "resource": { "type": "application", "id": "42" } }}Estos eventos se traducen en invalidaciones:
queryClient.invalidateQueries({ queryKey: key, refetchType: 'none'})La elección de refetchType: 'none' fue deliberada. Recibir un evento SSE no dispara una nueva solicitud inmediatamente. Solo marca la query como stale y permite que se revalide la próxima vez que sea observada.
Sin este comportamiento, despliegues grandes podrían generar tormentas de solicitudes entre sesiones abiertas. Priorizamos consistencia controlada sobre sincronización agresiva.
Selección de pestaña principal
Mantener múltiples conexiones SSE abiertas en pestañas simultáneas rápidamente empezó a generar desperdicio.
La solución fue implementar un sistema de selección de pestaña principal usando BroadcastChannel.
Solo una pestaña mantiene la conexión SSE activa.
Si esa pestaña se cierra, otra es seleccionada automáticamente.
La pestaña principal recibe eventos y propaga el estado de invalidación hacia las pestañas secundarias mediante broadcast.
Cuando la invalidación se volvió el problema difícil
Traer datos terminó siendo relativamente sencillo. Mantener coherencia entre mutations, persistencia, múltiples pestañas, reconexiones y cambios de cuenta fue significativamente más difícil.
Varios casos límite aparecieron durante la migración. Durante cambios de cuenta, una notificación de broadcast anunciaba el nuevo contexto antes de que la limpieza del cache hubiera terminado.
Las pestañas secundarias recibían el nuevo estado mientras seguían reteniendo cache de la cuenta anterior, generando comportamiento inconsistente.
Durante logout, ejecutar queryClient.clear() antes de cancelar queries activas permitía que solicitudes en curso repoblaran caches recién vaciados.
La solución requirió imponer un orden estricto de teardown:
cancelQueries→ invalidateQueries→ removeQueries→ clearLa clase de fallo más recurrente aparecía cuando una mutation actualizaba una sección de la interfaz mientras otra seguía renderizando datos stale porque no todas las child query keys habían sido invalidadas correctamente.
Con el tiempo, la lógica de invalidación evolucionó hacia una capa propia con mapas explícitos de relaciones entre recursos y mutations.
Trade-offs aceptados
Trade-off | Mitigación |
Bundle más grande | Tree-shaking y abstracciones con scope |
Posibilidad de estado stale | Invalidación via SSE y sync explícito de mutation |
Mayor complejidad de invalidación | Contratos de query centralizados y mapeo de invalidación |
Disponibilidad de IndexedDB | Fallback graceful |
Complejidad de coordinación multi-pestaña | Herramientas de debug de broadcast y ownership controlado |
Una decisión específica: deshabilitar el refetch automático en foco de ventana (refetchOnWindowFocus: false). En muchas aplicaciones este patrón tiene sentido, pero en el Console generaba estados de loading inesperados, interrumpía formularios a medio editar y producía race conditions. Priorizamos continuidad sobre frescura agresiva — una elección que solo funciona de forma confiable porque la invalidación via SSE y via mutation brindan una garantía de actualización separada.
Patrones que resultaron replicables
Query keys jerárquicos
Si trabajas con recursos anidados, estructurar query keys como árbol permite invalidación masiva sin rastrear consultas individualmente.
Cache hydration síncrono
En flujos de lista a detalle, los datos ya existentes suelen ser suficientes para renderizar inmediatamente mientras el detalle completo llega en paralelo.
Orden explícito de teardownqueryClient.clear() no cancela solicitudes activas. El orden importa.
Selección de pestaña principal para SSE
Una única conexión coordinada evita desperdicio y reduce complejidad.
Normalización de parámetros
Evitar diferencias como {} y { fields: [] } elimina una categoría completa de inconsistencias difíciles de depurar.
Arquitectura resultante
Vue components │ ▼Domain services(ApplicationService,EdgeFunctionService,...)
│ ▼TanStack Query(cache + request deduplication)
│
├── IndexedDB persistence │ (Web Crypto API)
├── BroadcastChannel │ synchronization
└── SSE-driven invalidation │ └── Infrastructure activity eventsConclusión
La optimización de la navegación siguió una dirección distinta a la que el problema inicial sugería.
Cuando los síntomas eran skeletons reapareciendo y solicitudes repetidas, la solución parecía ser simplemente hacer menos solicitudes.
En la práctica, lo que terminó resolviendo el problema fue tratar la invalidación de cache como una decisión arquitectónica y no como un efecto secundario.
Query keys jerárquicos, servicios centralizados, persistencia encriptada, sincronización entre pestañas y coordinación de SSE no son capacidades aisladas.
Juntas forman un sistema donde los datos cargados una vez permanecen disponibles hasta que algo verificable determina que necesitan actualizarse.
Para equipos que construyen SPAs con múltiples vistas dependiendo de los mismos recursos, la lección principal es simple: el problema rara vez es latencia. Es coherencia de estado.
Y la coherencia de estado se resuelve con arquitectura de invalidación, no con optimizaciones aisladas de fetch.







