Engenharia

Otimizando Latência Percebida: Uma Arquitetura Local-First

Introdução: Latência como Troca de Contexto

No desenvolvimento de software, a latência não é apenas uma métrica técnica; ela se manifesta como uma troca de contexto, interrompendo o fluxo de trabalho de um desenvolvedor. Pequenos atrasos se acumulam e afetam mais intensamente nos momentos em que estamos tentando manter a concentração. O problema não é que uma ferramenta como o GitHub Issues (mencionado no texto original) fosse inerentemente "lenta", mas que muitas navegações ainda arcavam com o custo de buscas de dados redundantes, quebrando o fluxo repetidamente.

No início deste ano, na AITY, decidimos resolver essa questão não buscando ganhos marginais no backend, mas mudando como as páginas de issues eram carregadas de ponta a ponta. Nossa abordagem foi transferir o trabalho para o cliente e otimizar a latência percebida: renderizar instantaneamente a partir de dados disponíveis localmente e, em seguida, revalidar em segundo plano. Para isso, construímos uma camada de cache no lado do cliente com IndexedDB, adicionamos uma estratégia de preaquecimento para melhorar as taxas de acerto de cache sem sobrecarregar as requisições, e introduzimos um service worker para que os dados armazenados em cache permaneçam utilizáveis mesmo em navegações completas (hard navigations).

Neste artigo, detalharemos como o sistema funciona na prática, cobrindo as métricas otimizadas, a arquitetura de cache e preaquecimento, como o service worker acelera caminhos de navegação que antes eram lentos e os resultados em uso real. Também abordaremos os trade-offs, pois essa abordagem não é isenta de custos, e o que ainda precisa ser feito para tornar a "rapidez" o padrão em todos os caminhos. Se você está construindo um aplicativo web intensivo em dados, esses padrões são diretamente transferíveis, permitindo reduzir a latência percebida em seu próprio sistema sem esperar por uma reescrita completa.

A Velocidade do Pensamento: Desempenho Web em 2026

Em 2026, "rápido o suficiente" já não é um patamar competitivo. Para ferramentas de desenvolvimento, a latência é sinônimo de qualidade do produto. Quando um usuário está triando múltiplos issues, revisando uma solicitação de recurso ou reportando um bug, cada espera evitável quebra o fluxo.

Ferramentas modernas com foco local e clientes agressivamente otimizados elevaram o padrão de "carrega em um segundo" para "parece instantâneo". Neste cenário, os usuários não nos comparam com aplicativos web antigos, mas sim com a experiência mais rápida que eles têm diariamente. A escala de uso de ferramentas como o GitHub Issues, onde milhões de pessoas dependem semanalmente para manter seu código funcionando, torna a performance percebida ainda mais crítica, especialmente com a integração de trabalho assistido por IA. Uma lentidão no ciclo entre intenção e feedback pode fazer com que todo o sistema pareça lento.

Identificamos que a origem dos problemas reportados por equipes internas e pela comunidade não era a profundidade ou correção das funcionalidades, mas sim a arquitetura e o ciclo de vida das requisições. Muitos caminhos comuns ainda pagavam o custo total da renderização no servidor, buscas de rede e inicialização do cliente, mesmo quando os dados já haviam sido vistos antes. Nosso objetivo, como equipe de performance da AITY, era reprojetar o fluxo de dados e o comportamento de navegação para que o produto se sentisse instantâneo por padrão.

Definindo e Medindo "Rápido": O HPC

Antes de alterar a arquitetura, era fundamental alinhar o que "rápido" significava em termos de usuário e como medi-lo. Métricas genéricas de página são úteis, mas insuficientes para uma superfície de produto complexa.

Utilizamos o HPC (Highest Priority Content), uma métrica interna da AITY alinhada ao LCP (Largest Contentful Paint) do Web Vitals, para medir quando o conteúdo principal da página (aquele que realmente importa ao usuário) é renderizado pela primeira vez. Como o LCP, ele é ancorado a um único elemento HTML, que em páginas de issues geralmente é o título ou o corpo do issue. Se esse elemento é renderizado rapidamente, a experiência parece responsiva, mesmo que regiões não críticas da página ainda estejam carregando.

Operacionalmente, categorizamos as navegações usando os seguintes limiares de HPC:

Esses limiares fornecem um modelo prático para a velocidade percebida pelo usuário, e não apenas a latência bruta do backend. A categoria < 200 ms mapeia para interações que se sentem imediatas em fluxos de trabalho reais, enquanto a categoria < 1000 ms captura experiências que ainda são aceitáveis, mas já são notadas pelos usuários.

Nossa filosofia de medição também evoluiu. Historicamente, dedicávamos esforços significativos ao rastreamento do p90 e p99 do HPC para minimizar o pior da distribuição. Embora isso continue sendo importante, não garante que o produto pareça rápido para a maioria dos usuários. É possível melhorar o p99 sem que a experiência mediana deixe de ser lenta. Para esta iniciativa, focamos na qualidade da distribuição: quantas navegações se enquadram em nossos buckets "rápido" e "instantâneo" em toda a população? O objetivo não era apenas ter menos outliers terríveis, mas tornar a velocidade o caminho padrão para a maioria das sessões.

Análise da Linha de Base: O Mix de Navegação Original

Antes de implementar as otimizações, precisávamos de um modelo claro de como os usuários acessavam a visualização de issues (issues#show). Tratar todas as navegações como uma única classe de tráfego ocultaria os gargalos reais. Identificamos três tipos primários de navegação:

Nossa distribuição inicial revelou que o caminho dominante era também o mais lento. Qualquer estratégia focada apenas em soft navigations React melhoraria uma parte da experiência, mas não moveria a performance percebida geral o suficiente por si só. Esta linha de base moldou nossas decisões arquitetônicas subsequentes: melhorar os caminhos rápidos e reduzir a penalidade das hard navigations, pois era onde a maioria dos usuários experimentava a maior latência.

Uma observação importante: a AITY está em processo de migração de páginas renderizadas em Rails para um frontend React. Durante essa transição, muitas jornadas de usuário cruzam a fronteira Rails/React, exigindo uma hard navigation completa. Essa transição de fronteira foi uma das principais razões para a grande parcela de hard navigations em nossa linha de base. Esperamos que essa parcela diminua à medida que mais superfícies se tornem nativas em React, mas não poderíamos esperar apenas pela migração da plataforma. Começamos otimizando as soft navigations React, onde tínhamos alavancagem arquitetônica imediata e podíamos entregar melhorias rapidamente.

Estratégia: Modelo de Aplicação Local-First com Stale-While-Revalidate

Uma vez alinhado o objetivo, nossa estratégia ficou clara: construir um modelo de aplicação local-first com stale-while-revalidate. Isso significa renderizar imediatamente a partir de dados disponíveis localmente para minimizar a latência visível ao usuário e, em seguida, revalidar assincronamente com o servidor, reconciliando a interface do usuário se houver dados mais recentes.

Passo 1: Cache Lado do Cliente com IndexedDB

Começamos onde tínhamos mais alavancagem e para onde desejamos mover a maior parte do tráfego no futuro: as soft navigations React. Neste caminho, o runtime já está ativo, então o custo dominante geralmente é a latência de busca de dados, não a inicialização da aplicação. Se pudéssemos eliminar a rede em visitas repetidas, conseguiríamos mover uma grande fatia do tráfego para o bucket "instantâneo".

Nossa análise prévia mostrou um forte padrão de acesso repetido: os usuários reabrem os mesmos issues frequentemente durante o triagem e ciclos de colaboração. Com base nesse comportamento, estimamos uma taxa de acerto de cache potencial de aproximadamente 30% para issues#show, usando-o como limiar inicial de viabilidade.

A implementação estendeu nosso armazenamento em memória atual com um cache persistente no lado do cliente usando IndexedDB.

Razões para escolher o IndexedDB para esta camada:

Sobre essa camada de armazenamento, implementamos a semântica de stale-while-revalidate:

O ponto arquitetônico é que esta não é uma escolha entre "cache ou correção". É uma renderização com foco em latência e verificações de consistência assíncronas na mesma navegação.

Os resultados iniciais de produção validaram o modelo. Após o lançamento amplo para todos os usuários, aproximadamente 22% das navegações React tornaram-se instantâneas (um aumento de 4% pré-lançamento), representando cerca de 15% do volume total de requisições. A taxa de acerto de cache observada atingiu cerca de um terço (~33%), consistente com a análise anterior de revisitas. O principal trade-off é a staleness controlada. Medimos uma divergência servidor/cache de cerca de 4,7% e tratamos isso como um envelope operacional explícito: aceitável para os ganhos de velocidade percebida em soft navigations, com salvaguardas para limitar a inconsistência visível ao usuário.

Aumentando as Taxas de Cache-Hit com Preaquecimento

Um cache é tão bom quanto sua taxa de acerto. A camada SWR (Stale-While-Revalidate) com IndexedDB foi um excelente primeiro passo, mas uma taxa de acerto de um terço também expôs a próxima limitação: a maioria das navegações ainda chegava antes dos dados.

A resposta ingênua seria óbvia: pré-buscar todos os próximos issues prováveis o mais cedo possível. Exploramos essa direção e rapidamente nos deparamos com a verdadeira restrição, que não era a complexidade da implementação, mas sim a capacidade. Em superfícies com alta fan-out, como listas de issues, dashboards e projetos, o prefetching ansioso amplifica o volume de requisições, cria padrões de acesso estilo N+1 e empurra computação desnecessária para o sistema para páginas que um usuário talvez nunca abra.

Então mudamos o objetivo. Em vez de tentar garantir que os dados pré-buscados estivessem sempre frescos, otimizamos para uma condição mais barata e escalável: garantir que alguns dados utilizáveis já estivessem locais quando o usuário clicasse.

Isso é preaquecimento (preheating). O preaquecimento caminha proativamente por referências de issues de alta intenção e prepara entradas de cache antes da navegação, mas só atinge a rede quando o issue não está presente no cache do cliente. Se dados utilizáveis já existirem, o preaquecimento é interrompido. Isso o torna fundamentalmente diferente do preloading tradicional. É uma lógica de população de cache, não de aplicação de frescura.

Este é um trade-off explícito entre frescura e uso da capacidade. Estamos dispostos a servir dados que podem estar ligeiramente defasados se isso permitir que a própria navegação seja quase instantânea, porque, uma vez que o usuário abre o issue, ainda podemos revalidar em segundo plano e convergir para o estado mais recente do servidor.

Para suportar esse modelo de forma eficiente, introduzimos uma versão de cache em memória na frente do IndexedDB. O IndexedDB oferece persistência entre abas e sessões, mas ainda é assíncrono e, portanto, não é gratuito no caminho crítico. A camada em memória fica entre o armazenamento ativo em memória e o armazenamento persistente, permitindo que payloads de issues "quentes" sejam servidos de forma síncrona sem incorrer no custo de leitura do IndexedDB. Na prática, isso remove outra fronteira assíncrona da soft navigation e aumenta materialmente a probabilidade de renderização diretamente da memória.

Operacionalmente, o preaquecimento é acionado a partir de superfícies de alta intenção, como listas de issues, dashboards, projetos e visualizações de dependência. As requisições são executadas em workers de baixa prioridade, são estritamente rate-limited e são protegidas por circuit breakers, garantindo que o mecanismo recue sob pressão. O trabalho iniciado pelo usuário sempre tem precedência sobre as buscas especulativas, permitindo-nos evitar o problema do "vizinho barulhento" e manter o sistema estável enquanto ainda melhoramos as taxas de acerto de cache para navegações reais do usuário.

O resultado foi uma grande mudança na distribuição. Após o lançamento amplo do preaquecimento, as navegações instantâneas para issues#show aumentaram para aproximadamente 30% no geral. Para navegações React especificamente, até ~70% se tornaram instantâneas. A taxa de acerto de cache subiu para aproximadamente 96%. Esse trade-off foi aceitável: gastamos uma pequena quantidade de capacidade controlada em segundo plano para mover uma grande porcentagem de navegações reais do usuário para fora do caminho limitado pela rede.

Expandindo o Caminho Rápido: Otimizando Navegações Turbo e Hard

Estávamos satisfeitos com os ganhos nas navegações React, mas as soft navigations não são toda a história. Mesmo com a migração contínua de mais partes do sistema AITY de Rails para React, as hard navigations sempre existirão — atualizações de página, novas abas, URLs diretas e links de entrada. Esses "cold starts" ainda importam, então queríamos que os dados em cache ajudassem também nesses cenários.

O mecanismo que escolhemos foi um service worker. Um service worker é um script gerenciado pelo navegador que é executado fora da própria página e pode interceptar requisições de rede antes que elas cheguem ao servidor. Conceitualmente, ele se posiciona entre o navegador e a origem como um intermediário programável. Isso o torna um dos poucos primitivos da plataforma web capazes de influenciar hard navigations sem que o runtime JavaScript da página já esteja ativo.

Para issues#show, nosso service worker estende o mesmo modelo local-first que construímos para as navegações React. Quando o navegador inicia uma requisição de navegação para uma página de issue, o service worker a intercepta e verifica se os dados do issue já estão disponíveis no cache local. Se estiverem, o worker anota a requisição de saída com um cabeçalho específico que informa ao servidor que ele pode pular uma quantidade substancial de trabalho.

Quando o service worker detecta um acerto de cache, ele sinaliza ao servidor via um cabeçalho de requisição. A partir daí, a navegação se divide em dois caminhos:

Essa é uma otimização estrita: se o cache estiver frio, obsoleto ou o service worker não estiver disponível, o comportamento volta para o caminho padrão de renderização no servidor.

Isso teve um efeito especialmente forte nas Turbo navigations, pois os caminhos Turbo ainda são fortemente restringidos pelo tempo de resposta do servidor. Uma vez que o service worker pode sinalizar que os dados do issue já estão presentes, o servidor gasta muito menos tempo calculando o fragmento da aplicação, e o Turbo se beneficia quase imediatamente dessa redução no trabalho de backend.

Os ganhos nas hard navigations são reais, mas menos imediatamente visíveis do que os ganhos do Turbo: em hard navigations com acerto de cache, trocamos o tempo de SSR pela renderização no lado do cliente. O caminho crítico agora se torna o download e a execução do JavaScript. Para reduzir esse custo, dividimos o código por rota usando React.lazy e dynamic route preloading, de modo que apenas o código necessário para a rota atual seja buscado antecipadamente. Aplicamos o mesmo princípio no nível do componente, carregando apenas o que é necessário para a visualização inicial e adiando módulos não críticos. Por exemplo, só buscamos o bundle do editor de issues quando um usuário entra no modo de edição, e usamos o prefetching baseado em intenção (como passar o mouse) para ocultar essa latência sem inchar o bundle inicial.

Os Resultados: Impacto Cumulativo e Mudança na Distribuição

Após implementar essas mudanças, analisamos o impacto cumulativo. Acompanhamos a métrica HPC durante todo o período de implementação — desde o cache inicial do IndexedDB, passando pelo preaquecimento, a camada em memória e o service worker — e a tendência é clara e sustentada: a distribuição está se movendo em direção à rapidez.

Em vez de selecionar apenas uma boa semana, analisamos a janela completa para compartilhar alguns ganhos concretos dos últimos meses. Abaixo estão os percentis de HPC em todo o tráfego de issues#show:

O padrão que se destaca é a melhoria desproporcional nos percentis mais baixos. P10 e P25 se comprimiram drasticamente porque as navegações em cache e pré-aquecidas agora dominam essa parte da distribuição. A mediana melhorou significativamente, mas ainda é moldada pelo tráfego de "cold start". E a cauda superior, embora melhor, reflete os caminhos de hard navigation onde a inicialização do JavaScript e a renderização do cliente agora são o gargalo — exatamente a área que estamos almejando em seguida.

Conclusão: O Trabalho Futuro e o Impacto Contínuo

O desempenho de nossas aplicações na AITY está melhor hoje do que nunca. Através de soft navigations, caminhos pré-aquecidos e fluxos acelerados por service worker, alteramos materialmente a distribuição da latência percebida pelo usuário e movemos uma parcela muito maior do tráfego para o bucket "instantâneo".

Ao mesmo tempo, nosso trabalho não terminou. Os "cold starts" que dependem de SSR (Server-Side Rendering) ainda representam um obstáculo real, especialmente quando a inicialização do cliente e a execução do JavaScript se tornam o custo dominante após a redução do trabalho do servidor.

A próxima fase envolve abordar desafios maiores. Estamos planejando reescritas direcionadas de partes de nosso backend stack, otimizadas explicitamente para entrega de baixa latência, e investindo em uma camada moderna de entrega de UI mais próxima da borda (edge) para reduzir as viagens de ida e volta e melhorar ainda mais o tempo de resposta.

Na AITY, a performance continua sendo um investimento contínuo em sistemas, e não um projeto pontual. A arquitetura está melhorando, os gargalos estão mudando, e continuaremos a iterar até que a rapidez seja a experiência padrão em todos os caminhos de navegação. Nosso compromisso é com um fluxo de trabalho do desenvolvedor cada vez mais fluido e produtivo.

Comentários

Interações
Seu Perfil

Aguardando Login...