Back

Truques de Performance de Frontend que Esquecemos

Truques de Performance de Frontend que Esquecemos

A maneira mais rápida de deixar um frontend moderno lento é presumir que o framework cuida da performance por você. A maioria das técnicas de baixo nível que definiram sites rápidos há uma década — dimensões explícitas de imagens, scripts de terceiros diferidos, dicas de font-display, preconnects manuais — ainda são relevantes, mas o framework agora se posiciona entre você e a plataforma, e a abstração vaza de formas que uma auditoria do Lighthouse sinalizará com prazer. Em 2019, a ButterCMS escreveu que “os navegadores em breve suportarão lazy loading nativamente.” Esse futuro chegou, tornou-se padrão e, em seguida, silenciosamente deixou de ser algo que verificamos. Este artigo percorre os fundamentos de performance de frontend que delegamos e os modos de falha em produção que surgem quando essa delegação quebra.

Principais Conclusões

  • O loading="lazy" nativo é suportado em todos os principais navegadores desde o Safari 15.4 (março de 2022), portanto, qualquer wrapper de Intersection Observer escrito antes dessa data é código morto que aumenta o peso do bundle.
  • O Google Fonts pode servir fontes com font-display: swap, mas blocos @font-face personalizados no seu próprio CSS não herdam esse comportamento — cada um é um potencial flash de texto invisível em conexões lentas.
  • setTimeout(fn, 0) é executado na próxima task e pode ocorrer durante uma interação do usuário; requestIdleCallback aguarda um período genuinamente ocioso, tornando-o o primitivo correto para trabalho não urgente.
  • Use defer para scripts que interagem com o DOM e async apenas para scripts genuinamente independentes, pois scripts async são executados na ordem de chegada pela rede, não na ordem do documento.
  • Desde que o INP substituiu o FID como Core Web Vital em março de 2024, handlers de scroll e resize sem throttling que bloqueiam a thread principal tornaram-se um sinal de ranqueamento, não apenas uma questão de fluidez.

width/height Explícitos em Imagens Ainda Previne Layout Shift

Em aplicações React onde as dimensões das imagens chegam de respostas de API em tempo de execução, o navegador não tem espaço reservado para alocar, então cada imagem que carrega após o primeiro paint é um potencial layout shift — independentemente de o componente de imagem do seu framework lidar corretamente com assets estáticos. O Cumulative Layout Shift é uma das Core Web Vitals definidas pela documentação de CLS do web.dev do Google, e o modo de falha visível é concreto: a página salta quando uma imagem carrega, e o toque do usuário acaba no botão errado.

A abstração que vaza aqui é o componente de imagem do framework. O <Image> do Next.js reserva espaço quando você passa width e height, mas não faz nada para tags <img> brutas em conteúdo MDX, HTML renderizado por CMS, ou qualquer marcação que o componente nunca toca. Navegadores modernos calculam um aspect-ratio implícito a partir dos atributos width e height (o MDN documenta esse comportamento), portanto as dimensões reservam espaço mesmo quando o CSS substitui o tamanho renderizado.

// Antes: as dimensões chegam em tempo de execução, nada reserva espaço
<img src={product.imageUrl} alt={product.name} />

// Depois: os atributos definem uma caixa de aspect-ratio antes da imagem carregar
<img
  src={product.imageUrl}
  alt={product.name}
  width={product.width}
  height={product.height}
  style={{ width: '100%', height: 'auto' }}
/>

Quando a API omite as dimensões, defina um aspect-ratio no container. De qualquer forma, o espaço existe antes de os bytes chegarem.

font-display: swap e Preconnect para Fontes Personalizadas

Cada bloco @font-face personalizado sem font-display: swap é um potencial flash de texto invisível — FOIT — em conexões lentas, onde um parágrafo permanece em branco durante toda a duração do carregamento da fonte. O descriptor font-display controla isso diretamente: swap renderiza o texto de fallback imediatamente e troca para a fonte personalizada assim que ela carrega, produzindo um flash de texto sem estilo (FOUT) em vez disso, conforme descrito na referência de font-display do MDN.

O vazamento é a delegação. O Google Fonts pode injetar font-display: swap no CSS que serve quando a URL da folha de estilos inclui o parâmetro display apropriado, então equipes que usam a folha de estilos hospedada nunca pensam nisso — e depois escrevem seus próprios blocos @font-face para fontes de marca que não herdam esse comportamento. Uma fonte auto-hospedada sem o descriptor envia FOIT para cada visitante com cache frio.

A auto-hospedagem também elimina o preconnect que a folha de estilos do Google implicitamente incentivava. A orientação do web.dev sobre conexões de rede antecipadas recomenda fazer preconnect à origem da fonte para que os handshakes de DNS, TCP e TLS sejam concluídos antes que a URL da fonte seja descoberta no CSS.

@font-face {
  font-family: "BrandSans";
  src: url("/fonts/brand-sans.woff2") format("woff2");
  font-display: swap; /* texto de fallback aparece imediatamente, sem FOIT */
}
<link rel="preconnect" href="https://fonts.cdn.example.com" crossorigin />

Audite cada bloco @font-face que você escreveu manualmente. O hábito do CSS hospedado esconde os que precisam de correção.

preconnect e dns-prefetch para Origens de Terceiros

Bundlers e frameworks lidam com preconnect para suas próprias origens de CDN, mas endpoints de analytics de terceiros, CDNs de imagens e serviços de testes A/B são invisíveis para a etapa de build — suas consultas DNS acontecem no momento da requisição, a menos que você adicione <link rel="preconnect"> manualmente. A ButterCMS descreveu o mecanismo com precisão em 2019: o preconnect instrui o navegador a completar a consulta DNS, a conexão inicial e a negociação TLS “o mais cedo possível, em vez de mais tarde quando a tag de script é descoberta.”

O custo do handshake de DNS e TLS não desapareceu; o framework simplesmente parou de lembrá-lo sobre ele. Um endpoint do Segment, uma origem do Cloudinary ou um gerenciador de tags de terceiros cada um requer uma nova configuração de conexão que bloqueia o recurso por trás dele. Use preconnect para origens que você sabe que vai acessar cedo, e dns-prefetch como uma dica mais leve para origens que você pode acessar, já que preconnect abre uma conexão completa pela qual você paga independentemente de ser usada. O web.dev aborda o trade-off entre as duas dicas.

<!-- Origem crítica de terceiros: abrir a conexão completa agora -->
<link rel="preconnect" href="https://cdn.imagecdn.example" crossorigin />

<!-- Origem provável, mas não certa: apenas resolver o DNS -->
<link rel="dns-prefetch" href="https://analytics.example.com" />

Coloque-os no início do <head>, antes dos scripts e folhas de estilo que disparam as requisições.

O loading="lazy" Nativo Substituiu Seu Wrapper de Intersection Observer

O loading="lazy" nativo é suportado em todos os principais navegadores desde o lançamento do Safari 15.4 em março de 2022 — qualquer wrapper de Intersection Observer escrito antes dessa data é agora código morto que aumenta o peso do bundle e a superfície de manutenção. O Chrome o lançou na versão 77 (agosto de 2019) e o Firefox na versão 75 (abril de 2020), conforme a tabela de compatibilidade de navegadores na referência do elemento img do MDN.

O vazamento aqui é histórico, não específico do framework. Codebases acumularam hooks useLazyImage e componentes <LazyImage> nos anos antes de o atributo atingir o baseline, e esses componentes ainda são entregues — executando um observer por imagem, mantendo refs e re-renderizando na interseção — para fazer o que o navegador agora faz nativamente e fora da thread principal. O mesmo atributo funciona em iframes, o que é importante para mapas incorporados e players de vídeo abaixo da dobra.

// Antes: um observer manual que a plataforma tornou redundante
function LazyImage({ src, alt }) {
  const ref = useRef(null);
  const [visible, setVisible] = useState(false);
  useEffect(() => {
    const io = new IntersectionObserver(([e]) => {
      if (e.isIntersecting) setVisible(true);
    });
    io.observe(ref.current);
    return () => io.disconnect();
  }, []);
  return <img ref={ref} src={visible ? src : undefined} alt={alt} />;
}

// Depois: o navegador cuida disso, fora da thread principal
<img src={src} alt={alt} loading="lazy" width={800} height={600} />;

Mantenha as dimensões explícitas — imagens com lazy loading causam layout shift tão facilmente quanto as carregadas de forma eager.

defer vs async em Scripts de Terceiros

A regra prática de decisão: use defer para qualquer script que leia ou escreva no DOM, e async apenas para scripts que são genuinamente independentes — porque scripts async são executados na ordem de chegada pela rede, não na ordem do documento, e dois scripts async com uma dependência entre eles vão competir. A definição do elemento script no HTML Living Standard especifica que scripts defer são executados após a conclusão do parsing, na ordem do documento, enquanto scripts async são executados assim que terminam de ser baixados.

O vazamento é social, não técnico: alguém cola um snippet de analytics de um fornecedor no <head> exatamente como as instruções de copiar e colar do fornecedor mostram, sem atributo, e um <script> simples bloqueia o parsing até ser baixado e executado. O modo de falha visível é o atraso na interação. Quando scripts de terceiros bloqueiam a interação, as gravações de sessão mostram toques repetidos no mesmo controle — um padrão clássico de rage-click.

AtributoTiming de execuçãoGarantia de ordemUsar para
nenhumBloqueia o parser imediatamenteOrdem do documentoQuase nunca
asyncAssim que baixadoOrdem de chegada pela redeAnalytics independente
deferApós o parsing completarOrdem do documentoQualquer coisa que toque o DOM
<!-- Antes: bloqueia o parser, atrasa o primeiro paint e a interação -->
<script src="https://vendor.example/analytics.js"></script>

<!-- Depois: script independente, nunca bloqueia o parser -->
<script src="https://vendor.example/analytics.js" async></script>

requestIdleCallback em Vez de setTimeout(fn, 0)

setTimeout(fn, 0) agenda trabalho no próximo slot da fila de tasks, que pode cair exatamente no meio de uma interação do usuário; requestIdleCallback aguarda um período genuinamente ocioso, tornando-o o primitivo correto para inicialização de analytics, hidratação de prefetch e batching de telemetria. A distinção está documentada na referência de requestIdleCallback do MDN: o callback é disparado durante os períodos ociosos do navegador e recebe um deadline que você pode verificar antes de fazer mais trabalho.

Este é o primitivo que a maioria das equipes nunca adotou — setTimeout(fn, 0) tornou-se o idioma reflexivo de “fazer isso depois”, e ele não cede o controle ao usuário de fato. Desde que o INP substituiu o FID como Core Web Vital em março de 2024 (conforme o anúncio de INP do web.dev), trabalho na thread principal que ocorre durante uma interação não é mais apenas uma questão de fluidez — é um sinal de ranqueamento. requestIdleCallback é suportado no Chrome e Firefox, mas não no Safari, então faça a detecção de funcionalidade e forneça um fallback.

function whenIdle(fn) {
  if ("requestIdleCallback" in window) {
    requestIdleCallback(fn, { timeout: 2000 });
  } else {
    setTimeout(fn, 0); // fallback para Safari
  }
}

// Adiar trabalho não urgente para fora do caminho de interação
whenIdle(() => initAnalytics());

A opção timeout garante que o trabalho eventualmente seja executado, mesmo que o navegador nunca fique ocioso.

Debounce e Throttle em Scroll, Resize e Input

Handlers de scroll, resize e input sem throttling que bloqueiam a thread principal são agora um sinal de ranqueamento, não apenas uma questão de fluidez — cada frame que eles atrasam é uma potencial violação de INP. O padrão quebrou porque o useEffect torna trivial a adição de um listener bruto: três linhas, sem limitação de taxa, e um handler que dispara em cada frame de scroll.

Debounce executa uma função após a atividade parar — correto para inputs de busca e trabalho de fim de resize. Throttle limita a frequência — correto para rastreamento de posição de scroll que deve atualizar durante o gesto. A referência do evento scroll do MDN observa que eventos de scroll podem disparar em alta frequência e recomenda o throttling de handlers custosos.

useEffect(() => {
  let ticking = false;
  function onScroll() {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => {
      updateScrollPosition(window.scrollY);
      ticking = false;
    });
  }
  window.addEventListener("scroll", onScroll, { passive: true });
  return () => window.removeEventListener("scroll", onScroll);
}, []);

O gate de requestAnimationFrame limita a uma atualização por frame, e { passive: true } informa ao navegador que o handler não chamará preventDefault, permitindo que ele role sem aguardar seu JavaScript.

O Padrão Composto

Cada técnica neste artigo é conhecimento de plataforma que delegamos a um padrão de framework e paramos de verificar. Nenhuma delas é nova — esse é o ponto. Individualmente, um font-display ausente ou uma tag sem defer custa milissegundos; juntos, eles são a diferença entre um app que parece rápido e um que parece pesado apesar das ferramentas modernas. A próxima ação concreta: abra o DevTools, audite seus blocos @font-face escritos manualmente, suas tags <script> de terceiros e seus listeners em useEffect em relação às regras acima, e delete o wrapper de Intersection Observer que o navegador tornou redundante.

Perguntas Frequentes

Use debounce quando você só se importa com o estado final após a atividade parar, como disparar uma requisição de busca após o usuário parar de digitar ou recalcular o layout após o fim de um resize. Use throttle quando precisar de atualizações durante um gesto contínuo em uma taxa limitada, como rastrear a posição de scroll. Debounce aguarda uma pausa; throttle limita a frequência enquanto o evento continua disparando.

Sim. O atributo loading se aplica tanto a elementos img quanto a iframe, portanto mapas incorporados, players de vídeo e widgets de terceiros abaixo da dobra podem adiar o carregamento nativamente sem um wrapper de Intersection Observer. O suporte dos navegadores acompanha de perto o lançamento para imagens, atingindo o baseline no Chrome, Firefox e Safari na mesma época. Mantenha width e height explícitos para evitar layout shift, já que elementos com lazy loading causam deslocamento tão facilmente quanto os carregados de forma eager.

Eles competem e podem ser executados na ordem errada. Scripts async são executados assim que cada um termina de ser baixado, na ordem de chegada pela rede em vez da ordem do documento, portanto um script que depende de outro script async pode ser executado primeiro e falhar. A solução é usar defer para ambos os scripts, o que garante a execução após o parsing completar e na ordem do documento, ou carregar a dependência antes do script dependente em um único bundle.

setTimeout com delay zero agenda trabalho no próximo slot da fila de tasks, que o navegador pode executar imediatamente, inclusive no meio de uma interação do usuário, portanto ele não cede o controle ao usuário de fato. requestIdleCallback aguarda um período genuinamente ocioso e passa um deadline que você pode verificar antes de continuar. Desde que o INP se tornou um Core Web Vital em março de 2024, essa distinção importa porque trabalho que ocorre durante uma interação é agora um sinal de ranqueamento.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay