De 76 a 100: Alcançando uma Pontuação Perfeita no Lighthouse
Resumo: Um site pessoal de portfólio saiu de uma pontuação de 76 no Lighthouse mobile com 0,493 de CLS para um perfeito 100/100/100/100 em todas as categorias. A jornada revelou problemas sutis de carregamento de CSS, bugs de regex e uma substituição de variável CSS particularmente traiçoeira que causava mudanças de layout no mobile.
O Ponto de Partida
O site era um portfólio com FastAPI + Jinja2, usando HTMX e Alpine.js para interatividade. Auditoria inicial do Lighthouse mobile:
| Métrica | Pontuação |
|---|---|
| Performance | 76 |
| Acessibilidade | 91 |
| Boas Práticas | 100 |
| SEO | 100 |
| CLS | 0,493 |
Esse número de CLS é brutal. O Google considera qualquer valor acima de 0,1 como “ruim”. Com 0,493, a página visivelmente pulava durante o carregamento.
Fase 1: Vitórias Rápidas em Acessibilidade
Antes de atacar a performance, corrigi problemas de acessibilidade para estabelecer uma base:
Labels de Formulário
Troquei spans decorativos por elementos <label> adequados:
<!-- Before: span with aria-describedby -->
<span class="contact-form__label">Email</span>
<input id="email" aria-describedby="...">
<!-- After: proper label association -->
<label for="email" class="contact-form__label">Email</label>
<input id="email">
Taxas de Contraste
Os títulos do rodapé e labels de formulário usavam --color-text-tertiary (branco com 40% de opacidade). Ajustei para --color-text-secondary (65% de opacidade) para atender ao requisito de contraste WCAG AA de 4,5:1. Acertar o contraste faz parte de um sistema de cores mais amplo — a uniformidade perceptual importa para a acessibilidade, não só para a estética.
Texto Alt Redundante
Ícones de redes sociais tinham texto alt como “LinkedIn icon” quando já estavam dentro de links com aria-labels. Alterei para alt="" com aria-hidden="true" para evitar repetição em leitores de tela.
Texto de Link Idêntico
Múltiplos links “View Case Study” eram indistinguíveis para leitores de tela. Adicionei atributos aria-label com os nomes dos projetos:
<a href="/work/introl" aria-label="View Introl Branding case study">
View Case Study
</a>
Resultado: Acessibilidade saltou de 91 para 100.
Fase 2: O Problema do CSS Bloqueando a Renderização
O Lighthouse mostrava um “Element render delay” de 2.460ms no detalhamento do LCP. O culpado: um arquivo CSS síncrono bloqueando a primeira pintura.
<!-- The problem: render-blocking stylesheet -->
<link rel="stylesheet" href="/static/css/styles.min.css">
O navegador precisava baixar e processar toda a folha de estilos de 25KB antes de pintar qualquer coisa.
A Solução: CSS Crítico + Carregamento Assíncrono
Passo 1: Extrair o CSS crítico (acima da dobra) em um arquivo separado que é injetado inline no <head>.
Passo 2: Carregar a folha de estilos completa de forma assíncrona usando o truque do media="print":
<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>
<!-- Full CSS loaded async (doesn't block render) -->
<link rel="stylesheet" href="/static/css/styles.min.css"
media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="/static/css/styles.min.css">
</noscript>
O atributo media="print" diz ao navegador “essa folha de estilos não é necessária para renderização em tela”, então ela não bloqueia. Uma vez carregada, o handler onload muda para media="all".
O Script de Extração de CSS Crítico
Escrevi um script Python para extrair automaticamente o CSS crítico dos componentes acima da dobra:
CRITICAL_PREFIXES = [
".container",
".header",
".nav",
".hero",
".personal-photos",
]
def extract_critical_css(css_content: str) -> str:
# Strip comments to prevent regex pollution
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
# Extract :root variables
# Extract base reset rules
# Extract component rules by prefix
# Extract media queries containing critical selectors
...
Resultado: O atraso de renderização do elemento LCP caiu de 2.460ms para ~300ms.
Fase 3: O Pesadelo do CLS Começa
Após implementar o CSS assíncrono, o CLS na verdade piorou — saltando para 0,119. O Lighthouse mostrava o <main> como o elemento que se deslocava.
Bug #1: Comentários CSS Poluindo o Regex
O script de extração usava um regex para encontrar seletores:
rule_pattern = r"([.#\w][^{]+)\{([^}]+)\}"
Problema: os comentários CSS não estavam sendo removidos antes da extração. Um comentário como:
/* Hero Section - Editorial */
.hero { ... }
Fazia o regex casar de “Hero Section” até a chave de abertura, criando seletores malformados que falhavam na verificação de prefixo. Estilos críticos do .hero estavam sendo silenciosamente descartados.
Correção: Remover comentários antes de qualquer operação com regex:
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
Bug #2: Regras de Media Query Extraídas como Independentes
O regex estava encontrando regras dentro de media queries e extraindo-as como regras independentes:
/* Full CSS structure */
.hero__title { font-size: 5rem; } /* Desktop */
@media (max-width: 768px) {
.hero__title { font-size: 1.875rem; } /* Mobile */
}
O script extraía ambas as regras no nível raiz:
/* Broken critical CSS */
.hero__title { font-size: 5rem; }
.hero__title { font-size: 1.875rem; } /* Overrides desktop! */
No mobile, o título renderizava no tamanho mobile a partir do CSS crítico, e então… permanecia assim porque o CSS completo tinha as mesmas regras. Mas no desktop, a substituição mobile estava sendo aplicada incorretamente.
Correção: Rastrear as posições das media queries e pular regras dentro delas:
# Find all media query ranges
media_ranges = []
for match in re.finditer(media_pattern, css_no_comments):
media_ranges.append((match.start(), match.end()))
def is_inside_media_query(pos: int) -> bool:
return any(start <= pos < end for start, end in media_ranges)
# Skip rules inside media queries during extraction
for match in re.finditer(rule_pattern, css_no_comments):
if is_inside_media_query(match.start()):
continue # Will be included with full media query block
# ... extract rule
Fase 4: A Hipótese do 100vh
O CLS ainda estava em 0,116. Teoria: se nada abaixo da dobra é visível na pintura inicial, não pode contribuir para o CLS.
Alterei o hero de 85vh para 100vh:
.hero {
/* 100vh ensures nothing below fold is visible on initial paint */
min-height: 100vh;
}
Resultado: Nenhuma mudança. CLS ainda em 0,116. O deslocamento estava acontecendo dentro da viewport.
Fase 5: A Prova Definitiva
O filmstrip do Lighthouse mostrava o texto do hero visivelmente mudando de posição entre frames. Não aparecendo gradualmente — se movendo horizontalmente.
Investigação profunda sobre quais estilos afetam o posicionamento horizontal:
- .hero__content usa padding: 0 var(--gutter)
- --gutter é definido como 48px em :root
Então encontrei:
/* In full CSS, but NOT in critical CSS */
@media (max-width: 768px) {
:root {
--gutter: var(--spacing-md); /* 24px */
}
}
A sequência no mobile:
- CSS crítico carrega:
--gutter: 48px - Hero renderiza com 48px de padding lateral
- CSS completo carrega de forma assíncrona
- Media query define
--gutter: 24px - Padding do hero encolhe de 48px para 24px
- Texto reflui e se desloca = CLS 0,116
A Correção
Extrair media queries do :root como parte do CSS crítico:
# Extract :root media queries (variable overrides are critical)
root_media_pattern = r"@media[^{]+\{\s*:root\s*\{[^}]+\}\s*\}"
for match in re.finditer(root_media_pattern, css_no_comments):
critical_rules.append(match.group())
Agora o CSS crítico inclui:
:root { --gutter: 48px; /* ... */ }
@media (max-width: 768px) {
:root { --gutter: var(--spacing-md); }
}
O mobile renderiza com o gutter correto de 24px desde o início. Quando o CSS completo carrega, --gutter já é 24px — sem mudança, sem deslocamento.
Fase 6: CSP e Alpine.js
Mais um problema: Alpine.js estava lançando erros no console sobre violações de Content Security Policy. Alpine usa avaliação dinâmica de expressões internamente.
# In security headers middleware
CSP_DIRECTIVES = {
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
# ...
}
A diretiva 'unsafe-eval' é necessária para o parsing de expressões do Alpine.js. A alternativa é usar o build compatível com CSP do Alpine, que tem algumas limitações.
Resultados Finais
| Métrica | Antes | Depois |
|---|---|---|
| Performance | 76 | 100 |
| Acessibilidade | 91 | 100 |
| Boas Práticas | 100 | 100 |
| SEO | 100 | 100 |
| CLS | 0,493 | 0,033 |
| LCP | 2,6s | 0,8s |
A Prova
Auditoria desktop mostrando pontuações perfeitas em todas as quatro categorias:

Auditoria mobile — a que mais importa — também atingindo notas perfeitas:

Principais Aprendizados
1. Variáveis CSS Podem Causar CLS
Substituições de propriedades CSS customizadas em media queries são invisíveis no painel de estilos computados do DevTools — você só vê o valor final. Se seu CSS crítico não inclui as substituições de variáveis, o layout vai se deslocar quando a folha de estilos completa carregar. Isso é especialmente perigoso em sistemas tipográficos, onde variáveis de tamanho de fonte e altura de linha se propagam em cascata por cada elemento de texto.
A ciência das cores que sustenta o sistema de cores perceptual do site também teve seu papel: as substituições de variáveis CSS que causaram o CLS incluíam variáveis de cor ao lado das de espaçamento, e a correção valeu para as duas.
2. Extração de CSS Crítico é Complicada
Abordagens simples com regex falham em:
- Comentários CSS (podem poluir a correspondência de seletores)
- Regras dentro de media queries (devem permanecer dentro de seus blocos)
- Media queries do :root (mudanças de variáveis afetam tudo)
3. O Truque do media="print" Funciona
Carregar CSS não-crítico com media="print" onload="this.media='all'" é uma forma legítima de adiar o carregamento de folhas de estilos sem complexidade de JavaScript.
4. Depurar CLS Requer o Filmstrip
A visualização em filmstrip do Lighthouse mostra exatamente quando os deslocamentos ocorrem. Sem ela, você está adivinhando. O elemento destacado como “deslocando” pode ser apenas um sintoma — procure o que está causando o deslocamento.
5. Seções Hero com 100vh Têm Benefícios Além da Estética
Se seu hero preenche a viewport, conteúdo abaixo da dobra não pode contribuir para o CLS porque não é medido até o usuário rolar a página. Isso conversa com os princípios de design editorial — heros que ocupam a viewport inteira criam impacto visual e vantagem de performance ao mesmo tempo.
A Stack
- Backend: FastAPI + Jinja2
- Frontend: HTMX + Alpine.js (auto-hospedado, sem CDN)
- CSS: CSS puro com propriedades customizadas, sem pré-processadores
- Otimização: Script Python customizado para extração de CSS crítico
- Hospedagem: Railway com compressão GZip e cache imutável
A stack inteira roda sem ferramentas de build — sem webpack, sem Vite, sem bundler. O Manifesto No-Build documenta as métricas completas e os trade-offs dessa abordagem. Essa simplicidade também deixou o design system mais fácil de auditar: há menos camadas entre o CSS que você escreve e o que o navegador recebe. Este post faz parte do hub de design engineering que reúne, em todo o site, o encontro entre ofício e implementação.
Perguntas frequentes
O que é uma boa pontuação no Lighthouse?
O Google considera boa uma pontuação de 90 ou mais. Marcar 100 nas quatro categorias (performance, acessibilidade, práticas recomendadas e SEO) é raro no mobile: as condições do mundo real — velocidade de rede variável, limitação de CPU, scripts de terceiros — tornam difícil controlar cada métrica. A maioria dos sites em produção fica entre 50 e 80 na performance mobile.
O que causa o Cumulative Layout Shift (CLS)?
O CLS mede o quanto o conteúdo visível se desloca depois da renderização inicial. Entre as causas mais comuns estão imagens sem dimensões explícitas, conteúdo injetado dinamicamente, fontes web que trocam de lugar com as fontes de fallback (FOIT/FOUT) e — como este post documenta — propriedades CSS customizadas que mudam de valor quando uma folha de estilos adiada carrega. O Google considera “ruim” um CLS acima de 0,1 e “bom” abaixo de 0,1.
Como o CSS crítico melhora a performance no Lighthouse?
O CSS crítico extrai os estilos necessários para o conteúdo acima da dobra e os coloca inline no <head> do HTML. Isso elimina a ida e volta que bloqueia a renderização, em que o navegador baixa e faz o parsing de uma folha de estilos inteira antes de desenhar qualquer coisa. O CSS restante carrega de forma assíncrona pela técnica media="print", então o navegador o trata como não bloqueante. Neste caso, o atraso de renderização do elemento LCP caiu de 2.460 ms para cerca de 300 ms.
É possível ter pontuação perfeita no Lighthouse sem ferramenta de build?
Sim. Este site usa CSS puro, sem pré-processador, sem bundler e sem framework — apenas o FastAPI servindo templates Jinja2. Um script Python cuida da extração do CSS crítico no momento do deploy. A ausência de ferramental de build até simplifica a auditoria de performance, porque existem menos camadas de abstração entre o CSS de origem e o que o navegador recebe.
Por que a pontuação mobile do Lighthouse importa mais que a de desktop?
O Google usa indexação mobile-first, ou seja, é a versão mobile do site que determina o ranqueamento na busca. As auditorias mobile do Lighthouse aplicam limitação de CPU (4x mais lenta) e de rede (4G lento simulado), o que expõe problemas de performance invisíveis no desktop. Um site que marca 100 no desktop costuma ficar de 20 a 30 pontos abaixo no mobile sob essas restrições.
Ferramentas Utilizadas
- Lighthouse (Chrome DevTools)
- Visualização em filmstrip do Lighthouse para depuração de CLS
greperegex101.compara análise de CSS- Python para o script de extração de CSS crítico
- iTerm 2
- Claude Code CLI com Opus 4.5 e muito ultrathink