Otimizando a navegação do Azion Console para uma experiência mais responsiva

Entenda como a Azion reconstruiu a camada de dados do Console para melhorar a consistência da navegação e reduzir recarregamentos desnecessários com um deep dive técnico sobre cache hydration, invalidação de queries, persistência com IndexedDB, atualizações via SSE e sincronização de estado em SPAs de larga escala.

Herbert Julio - undefined
Marilia Bafutto Costa - undefined

Conforme o Azion Console cresceu, começamos a perceber um padrão recorrente na experiência de navegação: skeletons reaparecendo em transições de tela, listas sendo recarregadas desnecessariamente e dados desaparecendo após refresh.

À primeira vista parecia um problema de performance, mas a API já era rápida. O problema era outro: o Console não mantinha contexto suficiente entre interações e acabava reconstruindo o estado que já estava disponível.

Este artigo documenta a reconstrução da camada de dados do Azion Console, as decisões técnicas, os edge cases encontrados e os padrões que se mostraram úteis para outras SPAs com requisitos semelhantes de consistência, sincronização e reutilização de estado.

Como a navegação funcionava antes

O Azion Console gerencia aplicações, políticas de segurança, serverless functions, storage, databases, certificados e outros recursos de infraestrutura distribuída. É usado por equipes, frequentemente em múltiplas abas e sessões simultâneas.

Como muitas SPAs que evoluem ao longo do tempo, a camada de dados original seguia um padrão simples:

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 view fazia fetch dos próprios dados durante o mount. Por um tempo, funcionou bem. O problema apareceu quando o Console cresceu: mais recursos significavam mais transições entre telas, mais estados concorrentes e mais situações em que partes diferentes da interface dependiam das mesmas informações.

Os sintomas:

  • Cada navegação disparava novas requests, mesmo para dados buscados segundos antes
  • Páginas de edição refaziam fetch de dados já visíveis na tela anterior
  • Componentes diferentes solicitavam o mesmo recurso em paralelo
  • Mutations exigiam refetch manual, fácil de esquecer e difícil de manter
  • Abas abertas ao mesmo tempo começavam a divergir

O gargalo não era velocidade de request. Era coordenar o comportamento do cache em toda a aplicação.

Centralização do contrato de dados

O primeiro impulso seria substituir chamadas de fetch por hooks do TanStack Query. Isso resolveria deduplicação e cache local, mas deixaria o problema intacto: cada view continuaria definindo suas próprias regras de carregamento, invalidação e sincronização de forma independente.

Enquanto essas regras permanecessem distribuídas, manter comportamento consistente continuaria difícil.

A solução foi introduzir uma abstração BaseService responsável por centralizar esse 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 do cache em memória
}
}

Cada serviço de domínio estende essa base. As telas deixaram de decidir como buscar e atualizar dados, passando a consumir serviços. Cache, invalidação, persistência e sincronização deixaram de ser decisões locais e se tornaram capacidades compartilhadas da aplicação.

Com os serviços centralizados, o problema deixou de ser “como buscar dados” e passou a ser “como invalidar consistentemente”.

Query keys hierárquicas

A primeira decisão arquitetural foi a estrutura de query keys. Antes da migração, eram strings ad-hoc. Depois, passaram a ser uma estrutura hierárquica centralizada:

export const queryKeys = {
application: {
all: ['application'],
detail: (id) => [...queryKeys.application.all, id],
list: (params) => [
...queryKeys.application.all,
'list',
normalizeParams(params)
]
}
}

Essa hierarquia permite invalidação em massa previsível: invalidar queryKeys.application.all automaticamente invalida listas, detalhes e recursos aninhados, sem precisar rastrear cada formato de query individualmente. Essa propriedade se tornou essencial quando invalidação via SSE e sincronização entre abas entraram em cena.

O problema de identidade dos parâmetros

Um problema apareceu cedo: cache misses causados por parâmetros que representavam a mesma query, mas geravam chaves diferentes. Essas duas chamadas precisavam produzir a mesma chave:

useQuery(queryKeys.application.list({}))
useQuery(queryKeys.application.list({ fields: [] }))

Sem normalização, faziam requests separadas. A correção:

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
}

Se queries equivalentes geram chaves diferentes, o cache deixa de produzir o comportamento esperado, independentemente da camada construída em cima dele.

Cache hydration no fluxo lista-para-edição

A normalização de query keys resolveu cache misses, mas não resolveu um problema mais visível para usuários: a transição de lista para edição sempre começava do zero. Mesmo quando a view anterior tinha acabado de buscar exatamente aquele item, a aplicação ignorava dados disponíveis em memória e aguardava uma nova request.

A solução foi hydration síncrono a partir do cache:

<script setup>
const cachedEdgeNode =
edgeNodeService.getEdgeNodeFromCache(edgeNodeId.value)
if (cachedEdgeNode?.name) {
edgeNode.value = cachedEdgeNode
}
</script>

A página de edição renderiza imediatamente com os dados do cache. A request de detalhes completa ainda roda em paralelo para campos adicionais não incluídos nas respostas de lista, mas a interface não é bloqueada por ela. Títulos aparecem instantaneamente, breadcrumbs se preenchem, o formulário já está disponível.

É uma técnica aplicável a qualquer SPA com padrão lista-para-detalhe. Em muitos casos, os dados já existem em memória — a aplicação simplesmente não está conectada para reutilizá-los.

Persistência além da sessão

O cache em memória resolveu a redundância durante a navegação, mas os dados ainda desapareciam após um refresh. Para uma ferramenta usada em operações de infraestrutura, isso significa perder contexto e reconstruir estado que já havia sido carregado.

Adicionamos persistência via IndexedDB usando idb-keyval. A escolha sobre localStorage se deu pelos limites de quota e porque operações síncronas se tornam um problema conforme o volume cresce.

Como parte desse estado incluía nomes de recursos e metadados de conta, o cache passou a ser criptografado em repouso com AES-GCM via Web Crypto API:

async function generateKey() {
return crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
false, // extractable: false — inacessível ao JS da aplicação
['encrypt', 'decrypt']
)
}

O parâmetro extractable: false é intencional: a chave não pode ser lida pelo JavaScript da aplicação, apenas usada pela interface criptográfica do browser.

Para reduzir a pressão sobre o IndexedDB, as escritas são agrupadas em pequenas janelas de tempo. O trade-off é aceitar a possibilidade de perder o último delta caso a aba seja fechada imediatamente após uma mutation — aceitável porque a persistência funciona como camada de otimização, não como fonte de verdade.

Sincronização entre abas

Sem sincronização, uma mutation em uma aba deixa as outras com estado desatualizado. A primeira camada usou o plugin broadcastQueryClient do TanStack Query:

broadcastQueryClient({
queryClient,
broadcastChannel: 'app-azion-sync'
})

Isso propaga mutations e invalidações entre abas, mas cobre apenas mudanças locais dentro da sessão atual do browser. Para eventos de infraestrutura externos ou mudanças feitas por outros usuários, adicionamos uma conexão SSE persistente recebendo atividade do backend:

{
"type": "activity",
"data": {
"activity_type": "updated",
"resource": { "type": "application", "id": "42" }
}
}

Esses eventos são traduzidos em invalidações com refetchType: ‘none’ — a query é marcada como stale e revalidada na próxima vez que é observada, não imediatamente. Sem esse comportamento, deployments grandes poderiam disparar tempestades de requests entre sessões abertas.

Eleição de aba primária

Múltiplas conexões SSE abertas em abas simultâneas se tornaram um problema de desperdício. A solução foi um sistema de eleição de aba primária via BroadcastChannel: apenas uma aba mantém a conexão SSE ativa. Se essa aba for fechada, outra é eleita automaticamente. A aba primária recebe os eventos e propaga as invalidações via broadcast para as secundárias.

Edge cases de invalidação

Buscar dados se mostrou relativamente direto. Manter coerência através de mutations, persistência, múltiplas abas, reconexões e troca de conta foi significativamente mais difícil.

Na troca de conta, uma notificação de broadcast era emitida antes que a limpeza do cache completasse. Abas secundárias recebiam o novo contexto enquanto ainda mantinham cache da conta anterior, causando comportamento inconsistente.

Durante logout, chamar queryClient.clear() antes de cancelar queries ativas permitia que requests em andamento repovoassem caches recém-limpos. A correção exigiu uma ordem de teardown estrita:

cancelQueries → invalidateQueries → removeQueries → clear

A classe de falha mais recorrente envolvia mutations atualizando uma seção da UI enquanto outra continuava renderizando dados stale porque nem todas as child query keys tinham sido invalidadas. Com o tempo, a lógica de invalidação evoluiu para sua própria camada com maps explícitos de relacionamentos entre recursos e mutações.

Trade-offs aceitos

Trade-off

Mitigação

Bundle size maior

Tree-shaking e abstrações com escopo definido

Possibilidade de estado stale

Invalidação via SSE e sincronização explícita nas mutations

Complexidade de invalidação maior

Contratos de query centralizados e mapeamento de invalidação

Disponibilidade do IndexedDB

Fallback gracioso

Complexidade da coordenação multi-aba

Tooling de debug do broadcast e ownership controlado

Uma decisão específica: desabilitar refetch automático no foco da janela (refetchOnWindowFocus: false). Em muitas aplicações esse padrão faz sentido, mas no Console ele criava loading states inesperados, interrompia formulários em edição e gerava race conditions. Priorizamos continuidade sobre freshness agressiva — escolha que só funciona de forma confiável porque a invalidação via SSE e via mutations fornece uma garantia separada de atualização.

Padrões que se provaram replicáveis

Query keys hierárquicas — Se você tem recursos aninhados, estruture query keys em árvore. Isso permite invalidação em massa sem rastrear queries individualmente.

Cache hydration síncrono — Em fluxos lista-para-detalhe, dados da lista frequentemente contêm campos suficientes para renderizar a tela de edição imediatamente. getFromCache síncrono + request de detalhes em paralelo é alto impacto com quase zero código.

Ordem de teardown explícita — queryClient.clear() não cancela queries em andamento. Sem ordem explícita (cancelQueries → invalidateQueries → removeQueries → clear), requests in-flight repovoam cache recém-limpo.

Eleição de aba primária para SSE — Múltiplas conexões SSE entre abas é desperdício. Um sistema de eleição via BroadcastChannel garante que apenas uma aba mantém a conexão ativa.

Normalização de parâmetros — Cache misses causados por {} vs { fields: [] } são fáceis de ignorar e difíceis de debugar. Normalizar parâmetros antes de construir query keys evita uma classe inteira de inconsistências.

Arquitetura resultante

Vue components
Domain services (ApplicationService, EdgeFunctionService, ...)
TanStack Query (cache + request deduplication)
├── IndexedDB persistence (encrypted via Web Crypto API)
├── BroadcastChannel synchronization
└── SSE-driven invalidation
└── Infrastructure activity events

Conclusão

A reconstrução da camada de dados do Console seguiu uma direção contrária ao que o problema inicial sugeria. Quando os sintomas eram “skeletons aparecendo” e “requests repetidas”, a solução óbvia parecia ser “fazer menos requests”. Na prática, o que resolveu foi tratar invalidação de cache como arquitetura — não como side effect.

Query keys hierárquicas, serviços centralizados, persistência criptografada, sincronização entre abas e eleição de SSE primário não são features isoladas. Formam um sistema onde dados carregados uma vez permanecem disponíveis até que algo externamente verificável determine que precisam ser atualizados.

Para desenvolvedores construindo SPAs com múltiplas views dependentes dos mesmos recursos, o aprendizado central é: o problema raramente é latência de request. É coerência de estado. E coerência de estado se resolve com arquitetura de invalidação, não com otimizações pontuais de fetch.

Referências

fique atualizado

Inscreva-se na nossa Newsletter

Receba as últimas atualizações de produtos, destaques de eventos e insights da indústria de tecnologia diretamente no seu e-mail.