Performance

De 66 a 97 no Core Web Vitals: O que identificamos para melhorar na vitrine do PN Imóveis

Como ajustei o LCP de 6,8s para 2,0s e a nota de performance do Nuxt 4 De 66 a 97 corrigindo CSS inline, SSR seletivo, imagens e cache.

#Nuxt#Web Performance#Lighthouse#SSR#Core Web Vitals

De 66 a 97 no Core Web Vitals: O que identificamos para melhorar na vitrine do PN Imóveis

A nota de Performance da home do pnimoveis.com.br no Lighthouse foi de 66 para 97. O LCP — o tempo até o maior elemento visível aparecer — caiu de 6,8 s para 2,0 s num celular médio em rede 4G lenta. Acessibilidade subiu de 84 para 96, SEO de 83 para 100, e o site passou a ter prévia de link decente no WhatsApp, coisa que simplesmente não existia.

Lighthouse Score: De 66 para 97

Este texto conta o caminho: o que encontramos, por que cada coisa doía, o que mudamos e como resolvemos cada gargalo.

MétricaAntesDepois (Produção)
Performance6697
Acessibilidade8496
Boas Práticas96100
SEO83100
First Contentful Paint2,5 s1,2 s
Largest Contentful Paint6,8 s2,0 s
Speed Index4,1 s2,9 s
Cumulative Layout Shift0,0040
Total Blocking Time170 ms130 ms
Bytes transferidos na home2,8 MB~0,6 MB

O paciente

O PN Imóveis é a vitrine pública de uma imobiliária de Pernambuco: home com destaques, busca com filtros, página de cada imóvel, e um painel administrativo para os corretores. Stack: Nuxt 4 (Vue) com PrimeVue, API no mesmo processo (Nitro), PostgreSQL via Supabase self-hosted, tudo numa VPS com Coolify. As fotos dos imóveis ficam num bucket do Supabase Storage servido por um subdomínio atrás do Cloudflare.

Uma decisão antiga do projeto importa para a história: o app inteiro era uma SPA — ssr: false. O servidor entregava uma casca vazia de HTML e o navegador montava tudo com JavaScript. Fazia sentido para o painel administrativo; para a vitrine, veremos que não.


O relatório dizia pouco. O curl disse tudo.

O ponto de partida foi o Lighthouse: Performance 66, LCP 6,8 s, e uma lista de auditorias com nomes genéricos — "Use efficient cache lifetimes (1.352 KiB)", "Reduce unused JavaScript (692 KiB)", "Improve image delivery (1.190 KiB)". O PDF é o resumo; não diz quais recursos estavam impactando a página.

Então fizemos o básico: baixamos a página com curl e inspecionamos a resposta bruta.

RecursoTamanhoCacheO problema
O HTML da home501 KBNenhumUma SPA deveria entregar ~10 KB de casca. Vinham 462 KB de CSS embutido.
JS de entrada504 KB (146 KB gzip)1 anoRegistrava globalmente todos os ~90 componentes do PrimeVue.
Imagem do hero359 KB (JPEG 1920px)NenhumEra o LCP — e estava como background-image no CSS.
Foto de card500–565 KB (WebP 2000px)4 horasExibida num card de 400 px.
Fontes e logos17–85 KB cadaNenhumServidos sem Cache-Control.
/robots.txt67 KB—Devolvia o HTML da SPA. O crawler pedia regras e recebia uma página.
Sprite de ícones238 KB SVG—Montado no DOM de toda página pública, dentro do bundle JS.
<link rel="prefetch">158 links—Um por chunk do build, disputando banda com o que a página precisava agora.

Cada linha dessa tabela traduzia uma auditoria genérica em algo concreto. E a primeira linha explicava sozinha boa parte do peso.


Problema 1: meio megabyte de CSS escondido no HTML

O módulo do PrimeVue para Nuxt tem uma opção, ligada por padrão, que pré-injeta no <head> o CSS do tema de cada componente. Sem uma lista explícita dos componentes que o app realmente utiliza, ele injeta todos — autocomplete, cascadeselect, treetable, terminal, dock, organograma… componentes que a vitrine nunca renderizou. Eram 184 blocos <style>, totalizando 462 KB em toda resposta HTML, para uma SPA cujo HTML não tinha conteúdo nenhum a estilizar.

Isso é o pior tipo de peso: não cacheia (é o próprio HTML), precisa ser baixado e processado antes de qualquer renderização, e não aparece de forma clara no Lighthouse — o relatório computava como "JavaScript não usado" e "JavaScript não minificado", pois o mesmo CSS existia como strings dentro do bundle JS.

A correção inicial foi desligar a injeção global (loadStyles: false) e declarar apenas os componentes realmente usados. O HTML caiu de 501 KB para 10 KB.

Mais tarde, quando a vitrine migrou para SSR, a injeção voltou a ser feita de forma seletiva — apenas para os ~12 componentes que a vitrine renderiza na resposta inicial. O painel administrativo continua como SPA e injeta o CSS em runtime conforme necessário.


Problema 2: o LCP era um fundo CSS atrás de três idas e voltas

O maior elemento visível da home é a foto do hero. Ela estava definida como background-image em uma <div>. Para o navegador descobrir que precisava dessa imagem, a cadeia de dependências era longa:

  1. Baixar e parsear o HTML.
  2. Baixar e executar o JS da aplicação.
  3. Aguardar o retorno da API da home (await bloqueante no setup).
  4. Montar a árvore do DOM.
  5. Aplicar as regras de CSS para identificar a URL da imagem.
  6. Iniciar o download de um JPEG de 359 KB.

Em redes 4G lentas com RTT de ~150 ms, o hero só começava a baixar após quase 4 segundos.

As correções:

  • <img> nativo com fetchpriority="high": Permite que o preload scanner do navegador identifique e solicite a imagem imediatamente ao receber o HTML. Adicionamos srcset responsivo (800/1200/1600 px), reduzindo o download inicial em celulares para apenas 34 KB.
  • Conversão para WebP de 1600 px: Redução de 359 KB para 98 KB sem perda de qualidade perceptível.
  • Desbloqueio da API: O hero e a barra de busca tornaram-se estáticos na renderização inicial; a busca por destaques mudou para useLazyFetch com esqueletos de carregamento.

Problema 3: fotos gigantes em cards pequenos e cache curto

No fluxo de cadastro, o upload comprimia as imagens para WebP com 2000 px de largura. Excelente para a galeria detalhada do imóvel, mas excessivo para os cards da home, que exibem a imagem em um container de 400 px. Eram cerca de 500 KB por card, multiplicados por 6 destaques na página inicial.

Além disso, o Supabase Storage retornava os objetos com o cabeçalho Cache-Control: max-age=3600 (4 horas). Ajustamos para um cache imutável de 1 ano (max-age=31536000, immutable) via Cloudflare e headers de origem.

E se precisarmos alterar uma imagem existente? Aplicamos a estratégia padrão de Cache Busting: quando uma foto é substituída ou atualizada, seu nome ou versão na URL é alterado (por exemplo, adicionando uma hash ou número de versão no caminho). Como a URL muda, o navegador reconhece o recurso como um novo arquivo e baixa a versão atualizada imediatamente, contornando o cache de 1 ano sem qualquer atrito.

As dimensões de cada imagem também passaram a ser enviadas no DTO da API, alimentando os atributos width e height da tag <img>, eliminando qualquer risco de layout shift.


Problema 4: o acúmulo de pequenos detalhes

Individualmente, pequenos gargalos parecem inofensivos; acumulados, representavam centenas de kilobytes e dezenas de ms de bloqueio:

  • Arquivos estáticos sem cache: O servidor Nitro não adicionava Cache-Control nos arquivos de public/. Configuramos regras de roteamento: fontes com cache imutável de 1 ano, imagens estáticas e ícones com 30 dias.
  • 158 prefetches desnecessários: O Nuxt gerava <link rel="prefetch"> para todos os chunks assíncronos do build. Removemos esse comportamento via hook no manifest do build. O prefetch passou a ocorrer dinamicamente apenas quando um <NuxtLink> entra na viewport.
  • Sprite SVG de 238 KB: Os ícones da ficha técnica estavam agrupados em um componente Vue gigante com todos os <symbol> inline. O sprite foi extraído para um arquivo SVG estático em public/, otimizado via SVGO (metade do tamanho) e referenciado via <use href="/icons/sprite.svg#nome">.
  • Fonte de ícones com font-display: block: Substituída por hospedagem local do arquivo .woff2 com font-display: swap e <link rel="preload">.
  • Acessibilidade e SEO: Correção de botões sem rótulo acessível (aria-label), ajuste da hierarquia de cabeçalhos (h2 a h4), meta tags de SEO e ajuste de contraste no tom de verde da marca para atingir o mínimo de 4,5:1.
  • Headers de segurança: Adição de HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy e COOP.

A virada: SSR seletivo onde realmente importa

Com o aplicativo ajustado, veio a decisão de arquitetura crucial: adotar Server-Side Rendering (SSR) para a vitrine pública.

O principal motivo não foi apenas o LCP, mas a experiência de compartilhamento. Quando um corretor ou cliente enviava o link de um imóvel no WhatsApp ou redes sociais, o resultado era um card genérico sem foto, preço ou título. Como os bot de prévia não executam JavaScript, a SPA entregava uma página em branco.

Utilizando o routeRules do Nuxt, configuramos a aplicação de forma híbrida:

  • SSR por padrão: Para a home, busca, landing pages e páginas de imóveis.
  • ssr: false (SPA): Para o painel administrativo, telas de autenticação e a página de favoritos (que depende exclusivamente de localStorage).
// sample
export default defineNuxtConfig({
  routeRules: {
    '/dashboard/**': { ssr: false },
    '/checkout': { ssr: false },
    '/catalog/**': { swr: 3600 }
  }
})

Cuidados indispensáveis ao introduzir SSR:

  1. Hydration Mismatches: Garantimos que a leitura de localStorage e operações não determinísticas (como ordenação aleatória) ocorram apenas no hook onMounted, evitando divergências entre o HTML gerado no servidor e o DOM do cliente.
  2. Reatividade no Servidor: Substituição de watch que preenchia listas após fetches por propriedades computed.
  3. Serviços Globais do PrimeVue: Manutenção explícita dos componentes <Toast /> e <ConfirmDialog /> para garantir o registro dos serviços injetados via useToast().

Otimizações de compressão e bundling na entrega

Para garantir que o ganho estivesse consolidado do servidor até o cliente, aplicamos otimizações adicionais no pipeline do Nitro e Rollup:

  • Compressão Brotli no SSR: Adicionamos um plugin no Nitro para comprimir o HTML diretamente com Brotli/Gzip antes de enviar ao proxy. O HTML enviado passou de 196 KB para 24 KB.
  • Consolidação de Chunks (Rollup): Originalmente, o build gerava 101 chunks JS (74 deles com menos de 10 KB). Ajustamos o agrupamento do Rollup para fundir pequenos arquivos sem comprometer o code-splitting. O total de chunks caiu de 101 para 71, e a home passou a solicitar apenas 19 arquivos JS em vez de 44.
  • Preload prioritário do Hero: Injeção do header HTTP Link: rel=preload com suporte a imagesrcset para colocar a imagem principal no topo da fila de rede antes de qualquer outro recurso.

O que aprendemos

  1. Meça o tráfego real com dados brutos: Relatórios automatizados dão o ponto de partida, mas inspeções com curl e análise de headers revelam onde estão os megabytes ocultos.
  2. SSR na vitrine é sobre conversão: Prévia correta no WhatsApp e indexação em buscadores são requisitos de negócio, não apenas métricas técnicas.
  3. Cuidado com a hidratação: Qualquer estado dependente do navegador deve nascer neutro no servidor e ser hidratado após a montagem do componente.
  4. Gerenciamento de dependências de UI: Módulos de componentes de interface devem ser configurados para incluir apenas o CSS e os scripts estritamente utilizados no caminho crítico.

Resumo em uma frase

Transformamos uma SPA que entregava 500 KB de CSS inline em uma aplicação SSR com HTML comprimido em 24 KB, imagem principal pré-carregada e cards com suporte total a previews sociais — elevando a nota no Lighthouse de 66 para 97 e reduzindo o LCP de 6,8 s para 2,0 s.