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.
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:
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:
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:
<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:
<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:
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>:
<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>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:
<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>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
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">:
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:
DOM completo, ordem das tags respeitada, DOMContentLoaded no fim. É defer
com outro nome. E type="module" async volta ao comportamento do async:
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:
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:
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:
É 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:
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:
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.
Perguntas frequentes
Ainda vale colocar o script no fim do body?
Posso usar defer e async na mesma tag?
defer funciona em script inline, sem src?
Por que meu script com async às vezes funciona e às vezes não?
O defer atrasa o primeiro desenho da página?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
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
- MDN — O elemento <script> — developer.mozilla.org
- HTML Standard — Scripting: the script element — html.spec.whatwg.org
- MDN — PerformanceResourceTiming — developer.mozilla.org


