Uma pontuação de 100 no PageSpeed começa no servidor, não no navegador. É por aí que entramos neste caso: uma loja WooCommerce de decoração de casa que abria em 4,8s de LCP, com INP de 450ms e CLS de 0,25, e cujo TTFB sozinho já comia a maior parte do orçamento de 2,5s que a métrica de LCP permite.
A conta é trivial e é ela que decide tudo o resto. Se o servidor demora 600ms a devolver o primeiro byte, sobram menos de 1,9s para descarregar o HTML, descobrir a imagem principal, pedi-la, descodificá-la e pintá-la. Nenhum truque de CSS crítico compensa esse atraso inicial. Por isso a primeira semana do trabalho não teve uma única linha de front-end.
Medir o TTFB antes de tocar em qualquer coisa
O primeiro comando de todo o projeto foi este, repetido a partir de três máquinas em países diferentes:
curl -o /dev/null -s -w "dns:%{time_namelookup} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n" https://exemplo.pt/Vale a pena separar as parcelas. Um TTFB alto pode ser resolução de DNS lenta, handshake TLS a cada pedido por falta de keep-alive, ou geração de página em PHP. São três problemas sem nada em comum e cada um tem uma correção diferente. Medir o agregado e adivinhar a causa é como este tipo de trabalho costuma descarrilar.
Nesta loja, o time_appconnect era saudável e o time_starttransfer disparava. O problema estava dentro do PHP. O Query Monitor, com SAVEQUERIES ativo num ambiente de testes, mostrou o padrão clássico do WooCommerce maduro: várias centenas de consultas por pedido, uma boa parte delas repetidas dentro do mesmo carregamento, e uma tabela wp_options com autoload inflado por anos de plugins instalados e desinstalados sem limpeza.
SELECT SUM(LENGTH(option_value)) AS bytes, COUNT(*) AS n
FROM wp_options WHERE autoload = 'yes';Quando esse número passa de alguns megabytes, cada pedido, incluindo os de admin-ajax.php, carrega tudo isso para memória antes de fazer seja o que for. Limpar autoload não é glamoroso e é das intervenções com melhor relação entre esforço e milissegundos poupados.
Cache de objetos: onde o Redis ajuda e onde não
Instalámos Redis no mesmo host, com o drop-in object-cache.php do plugin Redis Object Cache, e validámos com wp redis status antes de acreditar em qualquer coisa. O detalhe que engana muita gente: o cache de objetos do WordPress é, por omissão, não persistente. Sem drop-in, os dados guardados com wp_cache_set() morrem no fim do pedido. Com Redis, sobrevivem entre pedidos, e é aí que está o ganho.
O mecanismo é simples. Menus, taxonomias, termos, opções, meta de produtos e resultados de WP_Query passam a ser lidos da memória em vez do MySQL. O tempo de geração da página deixa de crescer linearmente com o número de consultas.
O que o Redis não resolve, e convém dizê-lo:
- Código lento continua lento. Um loop que faz
get_post_meta()dentro de umforeachsobre 300 produtos continua a fazer 300 chamadas, mesmo que cada uma seja rápida. O cache reduz o custo unitário, não o número de chamadas. - Invalidação em massa. Uma importação de stock que toca em milhares de produtos limpa grupos inteiros de cache. Nos minutos seguintes, o site fica mais lento do que estava, porque a cache está fria e o tráfego ainda chega. Agendar importações fora dos picos é parte da otimização, não um detalhe operacional.
- Memória finita. Sem
maxmemorye uma política de despejo definida, o Redis enche e começa a recusar escritas. Comallkeys-lrudefinido, despeja as chaves menos usadas e continua a servir. Definir isto antes do primeiro pico é mais barato do que diagnosticá-lo durante.
Ao mesmo tempo, desligámos o wp-cron.php disparado por visita (define('DISABLE_WP_CRON', true);) e passámos a chamá-lo por cron de sistema a cada minuto. Numa loja com tráfego, o cron por visita significa que um visitante aleatório paga, no TTFB dele, o custo de gerar relatórios de vendas ou de enviar emails pendentes.
Edge cache de HTML, e o momento em que a personalização parte tudo
Redis reduz o tempo de geração. O edge cache elimina-o para a maioria dos pedidos, porque o HTML já está num ponto de presença perto do visitante e o pedido nunca chega ao origem.
Para páginas de catálogo, categorias e conteúdo editorial, isto funciona bem. A regra que usámos guarda o HTML no edge com s-maxage alto e permite servir uma cópia ligeiramente antiga enquanto revalida em segundo plano:
Cache-Control: public, max-age=0, s-maxage=86400, stale-while-revalidate=600O max-age=0 mantém o navegador honesto (pede sempre), o s-maxage deixa o edge servir de memória, e o stale-while-revalidate evita que a primeira visita depois de uma expiração pague o custo total de geração.
E é aqui que uma loja é diferente de um blogue. O HTML do WooCommerce não é o mesmo para toda a gente. O mini-carrinho, o “Olá, Ana”, os preços com IVA por país, os produtos vistos recentemente: tudo isso torna uma cópia partilhada ou errada ou perigosa. Servir a um visitante o HTML em cache de outro, com o carrinho dele lá dentro, é o pior resultado possível deste trabalho, pior do que um site lento.
As regras que impedem isso, e que devem ser escritas antes de ligar o cache e não depois:
- Bypass por cookie. Qualquer pedido com
wordpress_logged_in_*,woocommerce_items_in_cart,woocommerce_cart_hashouwp_woocommerce_session_*vai direto ao origem e não é guardado. - Bypass por caminho.
/carrinho/,/finalizar-compra/,/minha-conta/e tudo sobwp-adminnunca são guardados, mesmo sem cookie. - Bypass por query string. URLs com
add-to-cart=,wc-ajax=ou parâmetros de pagamento não são cacheáveis.
A parte dinâmica que sobra tem de voltar por outro caminho. O WooCommerce já traz um: os fragmentos AJAX (wc-ajax=get_refreshed_fragments), que pedem ao servidor apenas o pedaço de HTML do mini-carrinho depois de a página estar pintada. Funciona, mas tem custo. Esse pedido não é cacheável por definição, acontece em todas as páginas, e em lojas com muitos plugins arrasta meio WooCommerce na inicialização. Nesta loja, limitámos os fragmentos às páginas onde o mini-carrinho existe de facto e reconstruímos o contador a partir de estado guardado no cliente nas restantes. O compromisso é assumido: em caso de divergência, o contador pode ficar desatualizado alguns segundos, e a página do carrinho, que nunca é servida de cache, é a fonte de verdade.
A alternativa mais limpa é montar o recorte no edge, com um Worker a injetar o fragmento personalizado no HTML em cache antes de o devolver. É a ideia antiga do ESI, com ferramentas melhores. Ganha-se um TTFB de página estática com conteúdo por utilizador. Paga-se em complexidade de depuração: passa a existir lógica em três sítios (PHP, edge, navegador) e um bug de carrinho obriga a percorrer os três.
Uma nota sobre invalidação, porque é o erro mais comum depois de tudo isto estar a funcionar. Se uma publicação de produto não limpa a cópia no edge, o site fica rápido e errado, e ninguém repara durante dias porque a métrica que se anda a observar continua verde. Ligar o purge à ação real do WordPress, e não a um intervalo de tempo, é o que faz a diferença entre um cache que ajuda e um que cria incidentes.
Afinação de PHP, OPcache e base de dados
Entre o cache de objetos e o edge há uma camada que raramente aparece em artigos de performance porque não é visível no PageSpeed: a configuração do próprio runtime.
O OPcache guarda o bytecode compilado dos ficheiros PHP em memória partilhada. Se estiver desligado, ou com memória insuficiente, cada pedido recompila milhares de ficheiros do WordPress, do WooCommerce e dos plugins. Os valores que verificámos primeiro foram opcache.memory_consumption e opcache.max_accelerated_files. O segundo é o que costuma estar errado: uma loja madura passa facilmente os dez mil ficheiros PHP e o valor por omissão fica abaixo disso, pelo que parte do código é recompilado a cada pedido enquanto o painel mostra o OPcache como ativo. Em servidores onde o deploy é atómico, opcache.validate_timestamps=0 remove ainda a verificação de data em cada ficheiro, com a condição de que o deploy passe a limpar o OPcache explicitamente. Sem essa condição cumprida, o site serve código antigo depois de uma atualização, e esse sintoma leva horas a diagnosticar porque tudo o resto parece correto.
Do lado do PHP-FPM, o número de processos filho define quantos pedidos não cacheados a loja aguenta em simultâneo. Demasiado baixo e os pedidos ficam em fila com o CPU a dormir. Demasiado alto e o servidor entra em swap, altura em que o TTFB deixa de ser medido em milissegundos. O cálculo de partida é memória disponível a dividir pelo consumo médio de um processo, medido em produção com ps durante um pico, nunca estimado.
Na base de dados, as duas alavancas foram o tamanho do buffer pool do InnoDB, que deve comportar o conjunto de dados quente, e os índices em wp_postmeta, onde as consultas de atributos e variações do WooCommerce se concentram. Query Monitor identifica as consultas lentas, e EXPLAIN diz se estão a fazer varrimento de tabela. Nenhuma camada de cache compensa uma consulta que varre um milhão de linhas sempre que a cache expira.
Camada do navegador: LCP, CLS e INP sobre uma base já rápida
Com o TTFB medido em dezenas de milissegundos em vez de centenas, o resto do trabalho passou a ter efeito visível.
LCP. O elemento maior era um slider que carregava alguns megabytes de JavaScript antes de mostrar a primeira imagem. Substituímo-lo por um bloco estático em CSS Grid, com a imagem principal em AVIF, servida com fetchpriority="high" e sem loading="lazy". Este par de atributos é a correção mais mal aplicada em WordPress: colocar loading="lazy" na imagem de topo atrasa deliberadamente aquilo que a métrica está a cronometrar. A conversão de PNG para AVIF reduziu a imagem de cabeçalho de 800KB para 45KB, com fallback WebP para os agentes que ainda não suportam AVIF.
CLS. Duas causas, uma correção cada. As fontes deslocavam o texto quando substituíam a fonte de sistema, e ficou resolvido com <link rel="preload"> na fonte primária mais font-display: optional, que aceita ficar com a fonte de sistema quando a personalizada não chega a tempo. É uma troca explícita: perde-se consistência tipográfica numa minoria de visitas, ganha-se zero deslocamento em todas. As imagens em lazy loading empurravam o conteúdo por falta de espaço reservado, e a correção foi aspect-ratio nos contentores, que funciona em layouts responsivos onde width e height fixos não funcionam.
INP. O conflito estava na thread principal: widget de chat, Pixel, gestor de tags e ferramentas de mapa de calor a competir com o WooCommerce. Movemos os scripts de terceiros para um Web Worker com Partytown. Convém saber o que isso implica antes de o prometer a um cliente: o Partytown faz proxy dos pedidos por um endpoint no mesmo domínio, precisa de ficheiros de biblioteca servidos com os cabeçalhos certos, e nem todos os scripts sobrevivem à mudança. Os que tocam diretamente no DOM ou dependem de temporização síncrona partem-se, e a forma de descobrir quais é testar um a um, não confiar na lista de compatibilidade.
Speculation rules, e os sítios onde não a deve ligar
A última camada é percetual. Com as Speculation Rules, o navegador pode ir buscar ou pré-renderizar a página seguinte antes do clique:
<script type="speculationrules">
{
"prerender": [{
"where": { "href_matches": "/produto/*" },
"eagerness": "moderate"
}]
}
</script>moderate dispara com a passagem do rato ou o início do toque, o que é suficiente para eliminar a espera percebida sem pré-renderizar tudo o que aparece no ecrã. eager parece melhor em demonstração e desperdiça largura de banda e ciclos de CPU em dispositivos móveis, que é precisamente onde a pontuação estava pior.
Três armadilhas reais, todas verificáveis em minutos:
- Uma pré-renderização executa JavaScript. Analytics, testes A/B e contadores de vista disparam numa página que o utilizador pode nunca abrir. Quem não trata disso passa a ter métricas de negócio infladas por causa de uma otimização de velocidade. A defesa está no próprio DOM: verificar
document.prerenderinge adiar o envio até ao eventoprerenderingchange. - Nunca pré-renderizar ações com efeitos. Ligações com
add-to-cart=, logout ou qualquer coisa sob/finalizar-compra/ficam de fora por regra, não por bom senso do programador que escrever o próximo link. - Interação com o edge cache. Pré-renderizar páginas personalizadas pede ao origem exatamente aquilo que as regras de bypass acima protegem. As duas configurações têm de concordar sobre o que é público.
O que não fizemos, e porquê
Ficou de fora a reescrita da montra em frontend desacoplado. Teria dado números de laboratório melhores e teria custado a integração de checkout, o SEO das páginas de categoria e meses de trabalho para resolver o que a camada de cache já resolvia. Ficou também de fora a substituição do tema, porque o problema não estava no tema, estava no que corria antes dele.
E fica um aviso sobre a própria pontuação. 100 no Lighthouse é uma medição de laboratório, feita numa máquina, com uma rede simulada, sem extensões e com a cache fria. Os dados que contam para a avaliação de experiência de página são de campo, do CrUX, recolhidos em dispositivos reais em 28 dias. Uma loja pode ter 100 em laboratório e ficar fora do limiar em campo, porque metade dos visitantes usa telemóveis antigos em rede móvel. Por isso instrumentámos a biblioteca web-vitals com a build de atribuição, que devolve não só o valor de INP mas também o seletor do elemento que o causou. Sem isso, otimizar INP é adivinhar.
Conclusão
Velocidade não é dívida técnica, é arquitetura. A ordem importa: servidor, cache de objetos, cache no edge, regras de personalização, e só depois o navegador. Feita ao contrário, cada correção de front-end é medida sobre uma base que se move, e o resultado desfaz-se no primeiro pico de tráfego.
O seu site WooCommerce está lento? A WPPoland trabalha da infraestrutura para cima, e diz-lhe onde está o limite antes de começar.







