Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
ConceitoAvançadocódigo testado

script defer e async: como o HTML bloqueia o carregamento

Por que um script no meio do HTML congela a página, o que defer e async mudam na ordem de execução e o que o navegador baixa por conta própria.

Rodolfo Mori14 min de leitura

Uma tag <script src="..."> sem atributo nenhum é uma ordem de parada. O navegador congela a leitura do HTML naquela linha, baixa o arquivo, executa o arquivo inteiro e só então volta a montar o resto da página. defer e async desligam esse congelamento — mas de jeitos tão diferentes que trocar um pelo outro quebra o site em silêncio.

Para não falar disso no chute, montei uma bancada. Um servidor em Node segura cada arquivo por um tempo combinado, e uma página da Livraria Aurora — um catálogo de doze livros — é carregada num Chrome de verdade. Cada script avisa em que instante executou e quantos livros consegue enxergar no DOM naquele momento. Todos os números deste artigo saíram desse log.

Imagine uma equipe montando uma vitrine

Pense no parser do HTML como a pessoa que monta uma vitrine de cima para baixo. Um script clássico é alguém que entra no caminho, pede que a montagem pare e só libera a passagem quando termina o próprio trabalho. Com defer, a tarefa é anotada e espera a vitrine ficar pronta. Com async, ela começa assim que o arquivo chega — mesmo que a montagem ainda esteja no meio.

Essa imagem ajuda a prever a ordem, mas o nome correto importa: parser é o mecanismo que transforma o texto HTML em DOM; download e execução do script são etapas separadas. Antes de olhar os logs, faça uma aposta: qual script verá os doze livros e qual poderá encontrar uma prateleira ainda inexistente? Depois compare sua previsão com o relógio real da bancada.

O parser para porque o script pode reescrever a página

Quando o navegador recebe o seu HTML, ele lê byte a byte e vai construindo a árvore DOM na ordem em que as tags aparecem. Uma tag <p> vira um nó e a leitura segue. Uma tag <script> é diferente: o navegador precisa parar.

O motivo é histórico e continua válido. Um script pode chamar document.write e despejar HTML exatamente naquele ponto do documento. O parser não tem como adivinhar se isso vai acontecer, então a única saída segura é suspender a construção da árvore, executar o script até o fim e só depois continuar de onde parou.

Enquanto isso acontece, nada abaixo daquela linha existe. Nem para o navegador, nem para o DOM que o seu JavaScript enxerga.

A bancada: um servidor que segura cada arquivo de propósito

Rede rápida esconde o problema. Para enxergar o mecanismo, o servidor atrasa cada resposta por um tempo fixo, e registra o instante em que cada pedido chegou. É um servidor HTTP do Node sem framework nenhum, com um setTimeout antes de responder:

js
import { createServer } from 'node:http';

// o corpo de cada .js: avisa o servidor em que instante executou
// e quantos cards de livro já existem no DOM naquele momento
const relatorio = (nome) => `fetch('/relatorio?e=executou ${nome}'
  + '&ms=' + Math.round(performance.now())
  + '&livros=' + document.querySelectorAll('.livro').length);`;

// cada recurso da Livraria Aurora: quanto segurar, tipo e corpo
const ARQUIVOS = {
  '/css/loja.css': [1500, 'text/css', '.livro{border:1px solid #ddd}'],
  '/js/catalogo.js': [300, 'text/javascript', relatorio('catalogo.js')],
  '/js/carrinho.js': [900, 'text/javascript', relatorio('carrinho.js')],
  '/js/avaliacoes.js': [100, 'text/javascript', relatorio('avaliacoes.js')],
};

const t0 = Date.now();
const ms = () => String(Date.now() - t0).padStart(5, ' ');

createServer((req, res) => {
  const caminho = new URL(req.url, 'http://localhost').pathname;
  if (!(caminho in ARQUIVOS)) return res.writeHead(404).end();

  const [atraso, tipo, corpo] = ARQUIVOS[caminho];
  console.log(`${ms()} ms  pediu ${caminho}  (o servidor segura ${atraso} ms)`);
  setTimeout(() => {
    res.writeHead(200, { 'content-type': tipo, 'cache-control': 'no-store' });
    res.end(corpo);
  }, atraso);
}).listen(4599);

Do lado do navegador, cada arquivo .js tem uma linha só — é o que aquele relatorio() gera. Para o catalogo.js, o servidor entrega isto:

js
fetch('/relatorio?e=executou catalogo.js'
  + '&ms=' + Math.round(performance.now())
  + '&livros=' + document.querySelectorAll('.livro').length);

E uma sonda no topo do <head> marca os dois eventos que interessam:

html
<script>
  function avisar(evento) {
    fetch('/relatorio?e=' + encodeURIComponent(evento) +
      '&ms=' + Math.round(performance.now()));
  }
  addEventListener('DOMContentLoaded', () => avisar('DOMContentLoaded'));
  addEventListener('load', () => avisar('load'));
</script>

DOMContentLoaded dispara quando o HTML terminou de virar DOM. load espera também as imagens e o resto dos recursos. O cronômetro é o performance.now() do próprio navegador, que zera no início da navegação.

O script clássico: o catálogo pela metade

A página tem três livros, depois as três tags de script sem atributo nenhum, e depois mais nove livros:

html
<main>
  <article class="livro"><h2>Grande Sertão: Veredas</h2><p>R$ 49,90</p></article>
  <article class="livro"><h2>Vidas Secas</h2><p>R$ 49,90</p></article>
  <article class="livro"><h2>O Cortiço</h2><p>R$ 49,90</p></article>
  <script src="/js/catalogo.js"></script>
  <script src="/js/carrinho.js"></script>
  <script src="/js/avaliacoes.js"></script>
  <article class="livro"><h2>Memórias Póstumas de Brás Cubas</h2>…</article>
  <!-- mais oito livros -->
</main>

O log do servidor:

0 ms GET /classico.html (o cronômetro zera aqui) 30 ms pediu /css/loja.css (o servidor segura 1500 ms) 30 ms pediu /js/catalogo.js (o servidor segura 300 ms) 30 ms pediu /js/carrinho.js (o servidor segura 900 ms) 30 ms pediu /js/avaliacoes.js (o servidor segura 100 ms) 1534 ms executou catalogo.js — enxerga 3 cards .livro no DOM 1535 ms executou carrinho.js — enxerga 3 cards .livro no DOM 1535 ms executou avaliacoes.js — enxerga 3 cards .livro no DOM 1535 ms DOMContentLoaded 1535 ms load

Três cards. Os scripts rodaram com um terço da loja no ar. Os outros nove livros estavam no HTML, já tinham chegado pela rede, e mesmo assim não existiam no DOM — o parser estava parado na linha do primeiro <script>.

É aqui que nasce a maior parte dos bugs de “meu querySelector volta null”. O elemento está no arquivo. Ele só não está na árvore ainda.

defer: o HTML inteiro fica pronto — e o relógio não anda

Agora as três tags sobem para o <head>, com defer, e o catálogo fica inteiro no <body>:

html
<head>
  <link rel="stylesheet" href="/css/loja.css">
  <script defer src="/js/catalogo.js"></script>
  <script defer src="/js/carrinho.js"></script>
  <script defer src="/js/avaliacoes.js"></script>
</head>
0 ms GET /defer.html (o cronômetro zera aqui) 49 ms pediu /css/loja.css (o servidor segura 1500 ms) 49 ms pediu /js/catalogo.js (o servidor segura 300 ms) 49 ms pediu /js/carrinho.js (o servidor segura 900 ms) 49 ms pediu /js/avaliacoes.js (o servidor segura 100 ms) 1562 ms executou catalogo.js — enxerga 12 cards .livro no DOM 1562 ms executou carrinho.js — enxerga 12 cards .livro no DOM 1562 ms executou avaliacoes.js — enxerga 12 cards .livro no DOM 1563 ms DOMContentLoaded 1563 ms load

Duas coisas mudaram, e uma não mudou.

Mudou o DOM: doze cards em vez de três. Mudou a ordem de execução em relação à chegada — avaliacoes.js foi pedido aos 49 ms e o servidor só o segurou 100 ms, ou seja, estava pronto por volta dos 149 ms. Mesmo assim esperou os outros dois, porque defer garante a ordem em que você escreveu as tags.

Não mudou o relógio. DOMContentLoaded ficou em 1563 ms, contra 1535 ms do script clássico. E não adianta comemorar nem lamentar esses 28 ms: repetindo os dois cenários três vezes seguidas, a diferença troca de sinal e some no ruído da medição.

cenário execução 1 execução 2 execução 3
clássico 1559 ms 1567 ms 1546 ms
defer 1554 ms 1563 ms 1544 ms

Ganho nenhum, em nenhuma das execuções — e isso não é acaso. Os dois cenários estão pregados no mesmo lugar: a folha de estilo, que só termina de chegar por volta dos 1530 ms e segura a execução tanto do script clássico quanto do script com defer. É o que a seção sobre a folha de estilo mede, mais adiante.

Essa é a parte que quase todo tutorial erra. defer não é um botão de velocidade: por especificação, o navegador espera todos os scripts com defer executarem antes de disparar DOMContentLoaded. O que defer compra é o parser livre e um DOM completo na hora em que o seu código roda — o que é enorme, mas não é a mesma coisa que uma página mais rápida.

async: cada script entra assim que chega

Trocando o atributo, e mais nada:

html
<head>
  <link rel="stylesheet" href="/css/loja.css">
  <script async src="/js/catalogo.js"></script>
  <script async src="/js/carrinho.js"></script>
  <script async src="/js/avaliacoes.js"></script>
</head>
0 ms GET /async.html (o cronômetro zera aqui) 42 ms pediu /css/loja.css (o servidor segura 1500 ms) 42 ms pediu /js/catalogo.js (o servidor segura 300 ms) 43 ms pediu /js/carrinho.js (o servidor segura 900 ms) 43 ms pediu /js/avaliacoes.js (o servidor segura 100 ms) 49 ms DOMContentLoaded 152 ms executou avaliacoes.js — enxerga 12 cards .livro no DOM 351 ms executou catalogo.js — enxerga 12 cards .livro no DOM 953 ms executou carrinho.js — enxerga 12 cards .livro no DOM 1549 ms load

DOMContentLoaded caiu de 1535 ms para 49 ms. Trinta vezes mais cedo, sem mexer numa linha de JavaScript. É esse o atributo que encurta o relógio, porque o async é o único dos três que o DOMContentLoaded não espera.

E repare na ordem: avaliacoes.js (100 ms de atraso) rodou primeiro, catalogo.js (300 ms) em segundo, carrinho.js (900 ms) por último. A ordem das tags no HTML foi ignorada. Quem chegou antes, entrou antes.

O mecanismo, num desenho

clássico defer async HTML parser parado: baixa e executa o script resto do HTML HTML inteiro, sem pausa espera executa 1, 2, 3 HTML inteiro exec 3 exec 1 exec 2 = DOMContentLoaded o tempo corre para a direita — esquema do mecanismo, fora de escala

O preload scanner já pediu tudo antes de o parser travar

Volte ao log do script clássico e olhe os quatro pedidos: 30 ms, 30 ms, 30 ms, 30 ms. Os três scripts estavam no meio do body, depois de três cards de livro, e o CSS estava no <head>. Mesmo assim saíram todos no mesmo instante — e muito antes de o CSS terminar de chegar, aos 1530 ms.

Isso é o preload scanner: um leitor secundário que corre na frente do parser principal, varre o HTML bruto que já chegou procurando src e href, e dispara os downloads sem esperar nada. Enquanto o parser está congelado, o scanner continua trabalhando.

A consequência prática desmonta um conselho antigo. “Coloque o script no fim do body” não faz o navegador baixar o arquivo mais tarde — o scanner já pediu tudo lá no começo. O que essa posição faz é só evitar que o parser trave com a página ainda pela metade. É meia solução para um problema que defer resolve inteiro, e com a tag num lugar que não depende de ninguém lembrar de mantê-la lá embaixo.

A folha de estilo que segura o seu JavaScript

Ainda falta explicar o número mais estranho de todos. No cenário defer, catalogo.js foi pedido aos 49 ms e ficou 300 ms na fila do servidor: estava pronto por volta dos 349 ms. E só executou aos 1562 ms. Mais de um segundo parado, com o arquivo já na mão.

O culpado é o CSS. Repeti a página com defer, apagando só a tag <link rel="stylesheet">:

0 ms GET /sem-css.html (o cronômetro zera aqui) 35 ms pediu /js/catalogo.js (o servidor segura 300 ms) 35 ms pediu /js/carrinho.js (o servidor segura 900 ms) 36 ms pediu /js/avaliacoes.js (o servidor segura 100 ms) 345 ms executou catalogo.js — enxerga 12 cards .livro no DOM 944 ms executou carrinho.js — enxerga 12 cards .livro no DOM 944 ms executou avaliacoes.js — enxerga 12 cards .livro no DOM 944 ms DOMContentLoaded 945 ms load

catalogo.js executou aos 345 ms, praticamente na hora em que chegou. DOMContentLoaded caiu de 1563 ms para 944 ms — 619 ms a menos, sem tocar em nenhum script.

A regra é essa: enquanto existe uma folha de estilo pendente, o navegador não executa script clássico nem script com defer. Ele faz isso porque o script pode perguntar getComputedStyle(elemento) na primeira linha, e responder isso com o CSS pela metade daria um valor errado.

O async escapa dessa fila — no log do cenário anterior, avaliacoes.js rodou aos 152 ms com o CSS ainda em trânsito. É mais uma razão para tratar as tags do head que ligam recursos à página como parte do problema de performance de JavaScript, e não como assunto separado.

type="module" já vem com defer embutido

Quem usa Vite, React ou qualquer build moderno entrega o código como módulo ES, e a tag sai assim: <script type="module" src="/assets/app.js"></script>. Sem defer escrito em lugar nenhum.

Não precisa: módulo é adiado por padrão. Rodei o mesmo teste com type="module", sem CSS na página:

0 ms GET /modulo.html 27 ms pediu /js/catalogo.js (o servidor segura 300 ms) 27 ms pediu /js/carrinho.js (o servidor segura 900 ms) 28 ms pediu /js/avaliacoes.js (o servidor segura 100 ms) 332 ms executou catalogo.js — enxerga 12 cards .livro no DOM 932 ms executou carrinho.js — enxerga 12 cards .livro no DOM 933 ms executou avaliacoes.js — enxerga 12 cards .livro no DOM 933 ms DOMContentLoaded

DOM completo, ordem das tags respeitada, DOMContentLoaded no fim. É defer com outro nome. E type="module" async volta ao comportamento do async:

0 ms GET /modulo-async.html 44 ms pediu /js/catalogo.js (o servidor segura 300 ms) 44 ms pediu /js/carrinho.js (o servidor segura 900 ms) 44 ms pediu /js/avaliacoes.js (o servidor segura 100 ms) 48 ms DOMContentLoaded 148 ms executou avaliacoes.js — enxerga 12 cards .livro no DOM 350 ms executou catalogo.js — enxerga 12 cards .livro no DOM 950 ms executou carrinho.js — enxerga 12 cards .livro no DOM

Escrever defer numa tag type="module" não faz nada — o atributo é ignorado ali. Escrever async, faz.

O erro que a escolha errada provoca

Na Livraria Aurora existe um botão de finalizar compra no fim do <body>, e um busca.js que registra o clique nele:

js
const botao = document.querySelector('#finalizar-compra');
botao.addEventListener('click', () => {
  console.log('pedido enviado');
});

Com a tag no <head> sem atributo — <script src="/js/busca.js"></script> — o resultado é este, capturado pelo addEventListener('error') da página:

44 ms pediu /js/busca.js 54 ms ERRO: Uncaught TypeError: Cannot read properties of null (reading 'addEventListener') @ http://localhost:4599/js/busca.js:2:7 stack: TypeError: Cannot read properties of null (reading 'addEventListener') at http://localhost:4599/js/busca.js:2:7 55 ms DOMContentLoaded

O script executou aos 54 ms — um milissegundo antes do DOMContentLoaded, com o <body> ainda vazio. document.querySelector não achou o botão, devolveu null, e null.addEventListener explodiu.

Acrescentando defer na mesma tag, sem mudar uma vírgula do JavaScript:

46 ms pediu /js/busca.js 58 ms busca.js chegou ao fim 58 ms DOMContentLoaded

É por isso que tanta gente aprende a embrulhar todo o código dentro de addEventListener('DOMContentLoaded', ...). Funciona, mas trata o sintoma: com defer, o DOM já está pronto quando o arquivo roda, e a ligação do evento de clique pode ficar na primeira linha do arquivo, sem embrulho nenhum.

Qual atributo usar em cada script

A decisão depende de duas perguntas: esse script precisa do DOM montado? E ele depende de outro script?

tipo de script atributo por quê
código da sua página (mexe no DOM, liga eventos) defer precisa do DOM pronto e da ordem das tags respeitada
bundle do Vite, React, Angular type="module" já é defer; não escreva defer junto
analytics, pixel, monitoramento de erro async não toca no DOM, não depende de ninguém, e não deve segurar o DOMContentLoaded
biblioteca que outro script consome defer async embaralha a ordem e quebra de forma intermitente
widget de chat, mapa, player de terceiro async falha isolada; se demorar, a página não espera
script minúsculo que precisa rodar antes da primeira pintura inline no head download nenhum é mais rápido que qualquer atributo

Regra de bolso para quem está começando: defer é o padrão, async é a exceção. Se você não consegue explicar por que aquele script pode rodar fora de ordem, ele não pode.

Como medir isso no seu projeto

Você não precisa da minha bancada. O navegador guarda o tempo de cada recurso, e duas linhas trazem tudo para o console:

js
addEventListener('load', () => setTimeout(() => {
  const n = performance.getEntriesByType('navigation')[0];
  console.log('DOMContentLoaded:', Math.round(n.domContentLoadedEventEnd), 'ms');
  console.log('load:', Math.round(n.loadEventEnd), 'ms');
  for (const r of performance.getEntriesByType('resource')) {
    console.log(r.initiatorType, r.name.replace(location.origin, ''),
      '| pedido em', Math.round(r.startTime),
      'ms | pronto em', Math.round(r.responseEnd), 'ms');
  }
}));

Rodando numa página com defer nos dois primeiros scripts e async no terceiro, o console imprimiu:

DOMContentLoaded: 2239 ms load: 2239 ms link /css/loja.css | pedido em 714 ms | pronto em 2221 ms script /js/avaliacoes.js | pedido em 714 ms | pronto em 820 ms script /js/catalogo.js | pedido em 714 ms | pronto em 1019 ms script /js/carrinho.js | pedido em 714 ms | pronto em 1620 ms

Os quatro recursos pedidos no mesmo milissegundo, de novo: o preload scanner aparece em qualquer medição que você faça. Os 714 ms de partida são o navegador abrindo do zero nesta execução — o que importa aqui são as diferenças entre as linhas, não o valor absoluto.

Dois detalhes que economizam uma hora de confusão. O setTimeout em volta não é enfeite: dentro do próprio evento load, o campo loadEventEnd ainda vale 0, porque o evento não terminou. E startTime é o instante em que o pedido saiu, não o instante em que o script executou — a distância entre responseEnd e a execução real é exatamente a fila que este artigo mostrou.

O que mexer primeiro no seu HTML

Abra o index.html do seu projeto e faça três passadas. Primeira: toda tag <script src> sem atributo ganha defer e sobe para o <head> — o parser para de travar e o null do querySelector some. Segunda: os scripts de terceiro que não tocam no seu DOM viram async, e o DOMContentLoaded encolhe. Terceira: confira quantas folhas de estilo estão na frente deles, porque é o CSS que manda no relógio do JavaScript adiado.

Se alguma tag continuar em dúvida, cole a sonda do performance na página e deixe o próprio navegador responder. E, para arrumar o resto do documento em volta desses scripts, o guia de HTML do zero mostra a ordem em que cada peça da página entra.

  • html
  • script
  • defer
  • async
  • performance

Perguntas frequentes

Ainda vale colocar o script no fim do body?
Funciona, mas resolve só metade do problema. O parser não trava, porém a execução continua sendo a última coisa a acontecer e o script fica preso atrás de qualquer folha de estilo pendente. Um script com defer no head é mais previsível e não depende de ninguém lembrar de manter a tag lá embaixo.
Posso usar defer e async na mesma tag?
Pode escrever os dois, mas o async ganha. A especificação trata defer como reserva para navegadores antigos que não entendem async. Se você quer ordem garantida, apague o async.
defer funciona em script inline, sem src?
Em script clássico, não: os dois atributos são ignorados sem src, porque não existe download para adiar, e o bloco executa na hora em que o parser chega nele. A exceção é type="module" — um módulo inline já é adiado para depois da leitura do HTML, e async nele funciona, soltando o bloco do DOMContentLoaded.
Por que meu script com async às vezes funciona e às vezes não?
Porque a ordem de execução do async depende da rede daquele instante. Com o cache quente o arquivo chega antes e tudo funciona; com a rede lenta ele chega depois de quem dependia dele. É o tipo de bug que só aparece na máquina do usuário.
O defer atrasa o primeiro desenho da página?
Não atrasa a pintura do que já foi lido, mas atrasa o evento DOMContentLoaded, porque o navegador espera todos os scripts com defer antes de disparar esse evento. Quem não pode segurar o DOMContentLoaded precisa de async ou de carregamento sob demanda.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0 e Chrome 151.0.7922.170 (macOS), e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — O elemento <script> — developer.mozilla.org
  2. HTML Standard — Scripting: the script element — html.spec.whatwg.org
  3. MDN — PerformanceResourceTiming — developer.mozilla.org

Continue por aqui