SEO Programático · Capítulo 21 de 21 · 33 min
Testar o template no pior caso e achar o teto
O template se mede com o registro mais pesado do acervo, e o limite de arquivos da plataforma decide a arquitetura antes do framework.
Este capítulo faz parte do curso gratuito SEO Programático. Para marcar como concluído e salvar o progresso, abra este capítulo na página do curso.
Um acervo lento perde posição e visita em milhares de páginas ao mesmo tempo, e uma plataforma subdimensionada obriga a reescrever o gerador depois do lançamento. A cena é sempre a mesma. O Lighthouse roda na home, sai 98, o deploy sobe com 5.000 páginas de município e, seis semanas depois, o relatório de campo piora. Ninguém quebrou nada. O que aconteceu é que a medição e o relatório falam de coisas diferentes: você mediu uma URL escolhida a dedo, e o campo agrega por origem ou por grupos de URLs semelhantes. As 5.000 páginas do template entram no mesmo balde, e a fatia pior delas puxa o agregado para baixo. Medir uma página de um site programático informa quase nada sobre o site.
Se a figura ultrapassar a área visível, deslize para os lados. Pelo teclado, foque a figura e use as setas.
Os limiares de Core Web Vitals são avaliados no percentil 75 de carregamentos de página, com mobile e desktop segmentados ([web.dev/articles/vitals](https://web.dev/articles/vitals)):
- LCP: bom até 2,5s, ruim acima de 4,0s.
- INP: bom até 200ms, ruim acima de 500ms.
- CLS: bom até 0,1, ruim acima de 0,25.
Como a régua é percentil e não média, a unidade de medida em site programático deixa de ser a página e passa a ser o template alimentado pelos dados de percentil extremo do seu próprio inventário. No Radar, isso significa três eixos concretos: o município com o nome mais longo (título e breadcrumb mais largos), a série histórica mais cheia (mais pontos para renderizar no gráfico) e a lista de combustíveis completa (o maior número de blocos repetidos na página). Medir Cotia com duas semanas de série é medir a versão da página que nunca dá problema.
Quase todo material descreve o INP como percentil 75 e para por aí. A definição tem dois níveis. Dentro de uma sessão de página, o INP reportado é o percentil 98 das interações daquela sessão, descartando a interação mais alta a cada 50 interações para conter outliers. O percentil 75 aparece depois, na agregação entre páginas. Confundir os dois leva a diagnóstico errado: você acha que uma única interação lenta define a nota da sessão, quando na verdade ela pode ser justamente a descartada.
Se a figura ultrapassar a área visível, deslize para os lados. Pelo teclado, foque a figura e use as setas.
As três fases importam porque cada uma tem uma intervenção diferente, e atacar a fase errada não move o número
- Input delay é o tempo até os handlers começarem a rodar. Se ele domina, a thread principal estava ocupada com outra coisa no momento do clique, tipicamente hidratação ou script de terceiro.
- Processing duration é a execução dos seus callbacks. Se ele domina, o problema é o seu código: componente pesado, lista grande virando estado, cálculo síncrono no handler.
- Presentation delay vai do fim dos handlers até o próximo quadro aparecer. Se ele domina, o custo está em layout e pintura, geralmente porque a interação muda a geometria de muita coisa ao mesmo tempo.
Quatro mecanismos quebram especificamente em página de template repetido. São mecanismos derivados, não números medidos, e cada um tem uma verificação própria:
- Componente pesado replicado em N páginas. Um import de barril arrasta uma biblioteca grande para dentro do template, e o custo de hidratação passa a ser pago em todas as páginas geradas. Este repositório carrega
three,@react-three/fiber,@react-three/drei,framer-motion,recharts,chart.jselottie-react. A mitigação já presente éoptimizePackageImportsnonext.config.ts, que corta o barril; a verificação é olhar o bundle da rota do template, não o da home. - Hidratação de lista longa. Cada item de "municípios vizinhos" que vira Client Component soma processing duration na primeira interação. A lista cresce com o dado, então ela é curta na página que você testou e longa na que o usuário abriu.
- CLS por conteúdo de tamanho variável. O mesmo template recebe títulos e descrições de comprimentos diferentes. Sem altura reservada, a variação de dado vira layout shift, e ele aparece só em parte do conjunto, o que é exatamente o padrão mais difícil de reproduzir.
- Amostragem enganosa no campo, que é o problema de abertura deste módulo.
Duas flags deste repositório entram na conta e costumam passar despercebidas. optimizeCss: true está ligado no next.config.ts e é flag experimental que mexe no caminho crítico de CSS, portanto pesa direto no LCP: qualquer regressão de LCP no template precisa ser testada com ela ligada e desligada antes de culpar o código da página. E optimizePackageImports só protege contra barril nas bibliotecas que ele conhece, então um import direto de subpacote pesado passa reto. O harness de medição já existe aqui: lighthouserc.json, o script lhci, scripts/ci/axe-check.mjs e Playwright configurado. Falta apontar tudo isso para o template com o pior caso, e é o que os dois arquivos abaixo fazem.
// scripts/perf/piores-casos.mjs
// Lê o inventário já ingerido do Radar e escolhe as URLs de percentil extremo
// nos três eixos que fazem o template inchar. Execução: node scripts/perf/piores-casos.mjs
import { readFile, writeFile, mkdir } from "node:fs/promises";
const registros = JSON.parse(await readFile("data/radar-anp.json", "utf8"));
const metricas = registros.map((r) => ({
slug: r.municipioSlug + "/" + r.combustivelSlug,
// eixo 1: o título mais largo que o template pode receber
tamanhoTitulo: ("Preço da " + r.combustivelNome + " em " + r.municipioNome + " (" + r.uf + ")").length,
// eixo 2: a série histórica mais cheia (mais pontos no gráfico)
semanas: r.serie.length,
// eixo 3: o município com mais combustíveis (mais blocos repetidos)
combustiveis: r.combustiveisNoMunicipio,
}));
const topo = (campo, n = 3) =>
[...metricas].sort((a, b) => b[campo] - a[campo]).slice(0, n);
const alvos = new Set([
...topo("tamanhoTitulo").map((m) => m.slug),
...topo("semanas").map((m) => m.slug),
...topo("combustiveis").map((m) => m.slug),
]);
await mkdir("scripts/perf", { recursive: true });
await writeFile(
"scripts/perf/urls-pior-caso.json",
JSON.stringify([...alvos].map((s) => "http://localhost:3000/preco/" + s), null, 2),
);
console.log(alvos.size + " URLs de pior caso gravadas em scripts/perf/urls-pior-caso.json");// lighthouserc.cjs
// Config em JS (e não em JSON) porque a lista de URLs é gerada pelo script anterior.
// Execução: npx lhci autorun --config=lighthouserc.cjs
const urls = require("./scripts/perf/urls-pior-caso.json");
module.exports = {
ci: {
collect: {
url: urls,
numberOfRuns: 3, // laboratório tem variância; uma execução por URL é ruído
startServerCommand: "npm run start",
startServerReadyPattern: "Ready in",
},
assert: {
assertions: {
// LCP e CLS têm equivalente direto em laboratório
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
// INP é métrica de campo: não existe em laboratório sem interação real.
// TBT é o proxy aceito para o custo de thread principal que alimenta o
// input delay. Trate como sinal de alerta, nunca como o INP em si.
"total-blocking-time": ["warn", { maxNumericValue: 200 }],
},
},
upload: { target: "filesystem", outputDir: "./.lighthouseci" },
},
};Com o template medido no pior caso, a pergunta seguinte muda de natureza. Ela deixa de ser sobre velocidade e passa a ser sobre quanto o catálogo cabe. Esse número entra como restrição de projeto, no mesmo nível da escolha de fonte de dados, porque ele decide se as páginas nascem no build ou em tempo de execução. Decidir isso depois de escrever o gerador custa reescrever o gerador.
Se a figura ultrapassar a área visível, deslize para os lados. Pelo teclado, foque a figura e use as setas.
Os números oficiais do Cloudflare Workers ([developers.cloudflare.com/workers/platform/limits](https://developers.cloudflare.com/workers/platform/limits/)):
- Script gzipped: 3 MB no gratuito, 10 MB no pago.
- CPU por request: 10ms no gratuito, até 5 minutos no pago com default de 30s.
- Memória por isolate: 128 MB.
- Subrequests por invocação: 50 no gratuito, 10.000 no pago.
- Static Assets: 20.000 arquivos por versão do Worker no gratuito, 100.000 no pago, com 25 MiB por arquivo.
O último é o que decide a arquitetura. Cada página pré-renderizada gera mais de um arquivo (HTML, payload RSC, metadados), então o teto efetivo de páginas totalmente estáticas é uma fração desses números. Acima disso, a saída obrigatoriamente vira renderização incremental com backend de cache. A boa notícia dessa conta: requests a static assets são gratuitas e ilimitadas, e não há custo de armazenamento, então o único limite é o de contagem.
# Antes de qualquer deploy, veja o bundle e a contagem de assets sem publicar nada.
# O --dry-run monta a saída em bundled/ e reporta o tamanho do script.
npx wrangler deploy --outdir bundled/ --dry-run
# E conte quantos arquivos a build realmente produziu, que é o número que
# vale contra o teto de 20.000 ou 100.000 por versão do Worker.
find .open-next/assets -type f | wc -l// scripts/perf/dimensionar.mjs
// Exemplo resolvido do Radar. Troque as três primeiras constantes pelos
// valores medidos no SEU inventário e na SUA build antes de decidir qualquer coisa.
const CATALOGO = 12400; // páginas com inventário, medidas na ingestão
const ARQUIVOS_POR_PAGINA = 3; // conte na sua build: find .open-next/assets -type f | wc -l
const SEGUNDOS_POR_REVALIDACAO = 1.2; // meça, não chute: tempo de render de uma página do template
const TETO_FREE = 20000;
const TETO_PAGO = 100000;
const CONCORRENCIA_FILA = 10 * 5; // 10 Durable Objects x 5 requests em paralelo
const arquivos = CATALOGO * ARQUIVOS_POR_PAGINA;
const lotes = Math.ceil(CATALOGO / CONCORRENCIA_FILA);
const minutos = (lotes * SEGUNDOS_POR_REVALIDACAO) / 60;
console.log("arquivos exigidos:", arquivos);
console.log("cabe 100% estático no gratuito?", arquivos <= TETO_FREE);
console.log("cabe 100% estático no pago?", arquivos <= TETO_PAGO);
console.log("teto de páginas estáticas no pago:", Math.floor(TETO_PAGO / ARQUIVOS_POR_PAGINA));
console.log("revalidar o catálogo inteiro:", lotes, "lotes,", minutos.toFixed(1), "min");
// Saída para os valores acima:
// arquivos exigidos: 37200
// cabe 100% estático no gratuito? false
// cabe 100% estático no pago? true
// teto de páginas estáticas no pago: 33333
// revalidar o catálogo inteiro: 248 lotes, 5.0 min
//
// Leitura: o plano gratuito está fora de questão para este catálogo. No pago,
// build-time ainda é viável, com folga até cerca de 33 mil páginas. E como o
// preço da ANP muda uma vez por semana, cinco minutos para atualizar o catálogo
// inteiro é irrelevante. A conta vira crítica quando o dado muda de hora em hora.Se o catálogo passar do teto, o OpenNext no Cloudflare oferece três backends de cache incremental, e eles não são equivalentes. R2 é o custo-efetivo para volume. Workers KV é rápido com Tiered Cache, mas a própria documentação desaconselha por consistência eventual, o que em site programático significa servir preço velho depois de revalidar. Workers Static Assets é somente leitura e não aceita revalidação, então serve apenas para conteúdo que nunca muda. Para revalidação sob demanda, o tag cache exige D1 ou Durable Objects com SqliteStorage: D1NextTagCache para carga baixa, DOShardedTagCache recomendado quando o tráfego é significativo, segundo a página de caching do OpenNext.
Duas ressalvas honestas sobre esse adapter, porque documentação de adapter merece desconfiança por construção. A página de overview do OpenNext Cloudflare lista PPR entre os recursos compatíveis, enquanto a página de caching afirma que a interceptação de cache não funciona com PPR. Como no Next 16 o PPR virou o comportamento padrão de cacheComponents, essa contradição alcança justamente o modo de renderização que você provavelmente vai usar. O curso não afirma compatibilidade, e você também não deveria, antes de provar em deploy. A validação é direta: suba uma rota do template com cacheComponents ligado, force uma revalidação e confira se o HTML servido mudou. A segunda ressalva é menor e mais objetiva: Node Middleware ainda não funciona com esse adapter.
Falta a fronteira que mais custa caro na migração, e que não está explicada em documentação nenhuma: Node contra edge. Neste repositório, src/app/sitemap.ts usa readdirSync, existsSync, statSync e readFileSync sobre process.cwd() em tempo de execução, para descobrir rotas e ler o mtime de cada page.tsx. Outros 14 arquivos em src/app importam fs ou path. Isso funciona em Node serverless com file tracing configurado, e é incompatível com Workers, onde não existe filesystem de projeto em runtime. O wrangler.jsonc tem nodejs_compat com compatibility_date 2025-09-27, e essa flag cobre as APIs de Node; ela não cria o diretório do projeto em disco. Além do problema de plataforma, existe um problema conceitual maior: descoberta por varredura de filesystem é intrinsecamente incompatível com geração a partir de dados, porque não existe um page.tsx por página quando as páginas vêm de um banco.
Se a figura ultrapassar a área visível, deslize para os lados. Pelo teclado, foque a figura e use as setas.
// ANTIPADRÃO, presente hoje em src/app/sitemap.ts deste repositório.
// Funciona em Node serverless, quebra em Workers, e não enxerga uma única
// página do Radar, porque nenhuma delas tem arquivo próprio em disco.
import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
const raiz = join(process.cwd(), "src/app");
const rotas = readdirSync(raiz).filter((d) => statSync(join(raiz, d)).isDirectory());
// SUBSTITUIÇÃO: a mesma fonte que gera as rotas gera o sitemap.
// Zero filesystem, roda igual em Node e em Workers, e o lastmod vem do dado.
import type { MetadataRoute } from "next";
import { listarPaginasComInventario } from "@/lib/radar/inventario";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const paginas = await listarPaginasComInventario();
return paginas.map((p) => ({
url: "https://exemplo.com.br/preco/" + p.municipioSlug + "/" + p.combustivelSlug,
// data da última coleta da ANP para AQUELE município, não a data do build
lastModified: p.coletadoEm,
}));
}Aplique a conta ao seu acervo antes de assinar o plano da plataforma. (1) Se a home mede 2,1s de LCP e o relatório de campo mostra LCP ruim, quais eixos do inventário escolhem as URLs a medir? (2) Qual conclusão errada alguém tira ao confundir o percentil 98 dentro da sessão com o percentil 75 entre páginas no INP? (3) Um catálogo de 40.000 páginas com 3 arquivos por página cabe estático no plano pago, e quanto tempo leva revalidá-lo a 50 revalidações simultâneas de 1,2s? (4) Por que um sitemap que varre src/app com readdirSync falharia no Radar mesmo em Node serverless?
O guia abaixo é para quem aprova o orçamento de infraestrutura e cobra o time técnico. Ele troca a nota da home por quatro medições do acervo inteiro e destrava a decisão de plano e de plataforma antes que o gerador seja escrito em cima de um limite errado.
Guia de implementação: dimensionar desempenho e plataforma antes de contratar
Quatro entregas que viram números na ata, sem ferramenta paga.
Passo 1: Peça a lista de URLs de pior caso
O título mais longo, a série histórica mais cheia e a página com mais blocos repetidos do inventário.
Deu certo quando: Existe um arquivo com nove a quinze URLs escolhidas pelo dado, e não pela vitrine.
Erro comum: Aceitar o Lighthouse da home como prova de desempenho do acervo.
Passo 2: Exija o teste automático nessas URLs
LCP até 2,5s e CLS até 0,1 no laboratório, com três execuções por URL.
Deu certo quando: O teste roda a cada publicação e reprova quando o pior caso passa do limite.
Erro comum: Tratar o tempo de bloqueio do laboratório como se fosse o INP medido no campo.
Passo 3: Receba a conta de arquivos contra o teto do plano
Páginas vezes arquivos por página, comparado ao limite de arquivos por versão da plataforma.
Deu certo quando: A conta mostra a folga, ou o ponto em que o acervo precisa renderizar sob demanda.
Erro comum: Escolher o plano pelo preço e descobrir o teto depois do gerador pronto.
Passo 4: Cobre a prova de revalidação em deploy
Uma rota real do template, uma revalidação forçada e o HTML servido comparado antes e depois.
Deu certo quando: O HTML muda depois da revalidação, com data registrada.
Erro comum: Confiar na lista de recursos do adaptador sem testar a combinação usada.
No fim você tem: Um plano de plataforma escolhido por conta escrita, com limite de desempenho testado a cada publicação.
Quem faz, quanto custa, como conferir
| Etapa | Quem faz | Prazo e esforço | Como conferir |
|---|---|---|---|
| URLs de pior caso | Engenheiro de dados | 2 a 4 horas; sem custo de licença | Arquivo com as URLs e o eixo que escolheu cada uma |
| Teste automático | Desenvolvedor | 1 a 2 dias; Lighthouse CI é gratuito | Relatório com LCP e CLS de cada URL de pior caso |
| Conta contra o teto | Líder técnico | 2 horas | Planilha com páginas, arquivos por página e limite do plano |
| Prova de revalidação | Desenvolvedor | 4 horas; plano já contratado | HTML antes e depois, com data |
Peça a conta de arquivos e o relatório de pior caso antes de assinar ou renovar o plano da plataforma. Confira em 30 dias, no relatório de Core Web Vitals do Search Console, se o grupo de URLs do template aparece como bom para LCP, INP e CLS no celular.
Perguntas frequentes deste capítulo
Posso simplesmente rodar o Lighthouse em todas as páginas do catálogo em vez de escolher o pior caso?
Meu Lighthouse não reporta INP. Como eu meço INP antes de publicar?
Se eu ficar abaixo do teto de arquivos, posso ignorar a fila de revalidação?
Vale a pena migrar para Cloudflare Workers antes de saber o tamanho final do catálogo?
O optimizeCss é experimental. Devo desligar por segurança?
Seu caderno neste capítulo
Abrir o caderno completoSelecione um trecho do capítulo para destacar ou anotar. Nos vídeos e áudios, use Anotar este momento. No teclado, selecione com Shift e as setas e use Alt+Shift+D para destacar ou Alt+Shift+N para anotar.
Salvo neste navegador. Entre na sua conta para levar o caderno a outros aparelhos.
Entre na sua conta para compartilhar o que aprendeu e convidar alguém para estudar com você.
Conexões deste capítulo
Explore os conceitos e compare abordagens em outros cursos. As conexões indicam assuntos relacionados; a sequência de estudo continua no índice do curso.
Conceitos deste capítulo
O mesmo assunto em outros cursos
- Frontends com VibecodingValidar desempenho, autenticação e segurançaExaminar conexões de Validar desempenho, autenticação e segurança
- MCP com Chrome: Automação com IADescobrir o que a IA não vê na página que você vêExaminar conexões de Descobrir o que a IA não vê na página que você vê
- API Design: REST, GraphQL e Boas PráticasPerformance e CachingExaminar conexões de Performance e Caching
Voltar ao capítulo anterior: Anunciar o acervo com sitemap particionado e frescor honesto
Capítulos vizinhos em SEO Programático
- 17Blindar o acervo contra risco jurídico e de marca
- 18Decidir em comitê entre escalar, podar ou parar
- 19Escolher quando cada página nasce no Next.js 16
- 20Anunciar o acervo com sitemap particionado e frescor honesto
- 21Testar o template no pior caso e achar o teto