JavaScript por baixo dos panos · parte 2 de 3
Event Loop no JavaScript: Web APIs, Task Queue e Microtask Queue
Entenda como o JavaScript lida com assincronismo no navegador usando Call Stack, Web APIs, Task Queue, Microtask Queue, Promises e async/await.
JavaScript executa uma coisa por vez. Mesmo assim, fetch, setTimeout, eventos de clique e async/await continuam funcionando sem travar a aplicação. Para entender por quê, precisamos olhar para Call Stack, Web APIs, Task Queue, Microtask Queue e Event Loop.
Se você quiser revisar a base antes, vale ler Como o JavaScript executa código.
A resposta passa por um conjunto de peças que trabalham juntas: Call Stack, ambiente host, Web APIs, Task Queue, Microtask Queue e Event Loop.
E aqui vale um cuidado importante: muita gente tenta explicar assincronismo falando só de Event Loop, mas o Event Loop sozinho não conta a história inteira.
Neste artigo, a ideia é montar esse raciocínio por inteiro, passo a passo.
O ponto de partida
Antes de falar de assincronismo, vale recuperar a base.
Quando o JavaScript executa uma função, ele cria um novo Execution Context e coloca esse contexto na Call Stack.
Se essa função chamar outra função, um novo contexto entra na stack. Quando a execução termina, esse contexto sai da stack.
Isso significa que, no fluxo síncrono normal, o JavaScript executa um trecho de código por vez.
function tarefaDemorada() {
for (let i = 0; i < 1_000_000_000; i++) {
// trabalho pesado
}
}
function tarefaImportante() {
console.log("rodando a tarefa importante")
}
tarefaDemorada()
tarefaImportante()Nesse caso, tarefaImportante precisa esperar tarefaDemorada terminar.
Enquanto a primeira função ainda estiver ocupando a Call Stack, o restante não anda.
Esse comportamento é importante porque mostra uma regra central do JavaScript:
- cada execução síncrona roda até o fim
- nada interrompe uma execução síncrona no meio
- a próxima coisa só começa quando a stack fica livre
Então a pergunta fica ainda mais interessante: como fetch, timers e eventos não congelam tudo?
Nem tudo que usamos vem da linguagem JavaScript
Quando escrevemos código no navegador, não estamos lidando só com a linguagem JavaScript.
Na prática, estamos lidando com JavaScript mais o ambiente em que esse código roda.
Esse ambiente, no navegador, oferece vários recursos extras. É desse ambiente que vêm coisas como:
documentfetchsetTimeoutaddEventListenernavigator.geolocation
Esses recursos não fazem parte do núcleo da linguagem JavaScript. Eles são fornecidos pelo ambiente host, que no nosso caso é o navegador.
É por isso que faz sentido falar em Web APIs: elas funcionam como uma ponte entre o seu código JavaScript e capacidades do navegador.
O que realmente acontece quando usamos uma API assíncrona
Quando chamamos uma API assíncrona, a operação longa não fica ocupando a Call Stack até terminar.
Esse é um dos pontos que mais confundem no começo.
Por exemplo:
fetch("https://api.exemplo.com/posts")A chamada de fetch entra na stack, inicia a operação no ambiente do navegador, retorna uma Promise e depois sai da stack.
Ou seja: a stack não fica parada esperando a resposta do servidor voltar.
Isso vale para várias operações assíncronas no navegador. A função JavaScript entra em cena para iniciar a operação e registrar o que deve acontecer depois. A operação longa em si é tratada fora da stack principal.
É isso que permite que a aplicação continue responsiva.
O primeiro caso: APIs baseadas em callback
Um jeito clássico de ver isso é com setTimeout.
console.log("início")
setTimeout(() => {
console.log("timeout")
}, 0)
console.log("fim")Muita gente olha esse código e imagina que timeout deveria aparecer logo após a chamada do setTimeout, principalmente porque o atraso foi 0.
Mas a saída observada é esta:
início
fim
timeoutVamos acompanhar o raciocínio com calma:
console.log("início")entra na stack, executa e sai.setTimeout(...)entra na stack.- O papel do
setTimeoutnesse momento não é executar o callback. Ele só registra o timer no ambiente do navegador. - Depois disso,
setTimeoutsai da stack. console.log("fim")entra na stack, executa e sai.- Quando o tempo mínimo do timer expira, o callback não vai direto para a stack. Ele entra em uma fila de tarefas pendentes.
Essa fila é a Task Queue.
Task Queue
A Task Queue guarda tarefas que ficaram prontas para executar no futuro.
No contexto do navegador, ela é usada para vários tipos de trabalho assíncrono baseado em callbacks e eventos.
Aqui vale uma observação importante: para fins didáticos, estou falando em Task Queue no singular. No navegador real, o modelo é mais detalhado e envolve múltiplas filas e diferentes fontes de tasks. Para construir o modelo mental correto da ordem de execução, pensar em "uma fila de tasks" já é suficiente.
No caso do setTimeout, quando o tempo mínimo termina, o callback entra nessa fila.
O detalhe importante é este:
- o tempo do
setTimeoutnão significa "executar exatamente nesse instante" - ele significa "depois desse tempo mínimo, essa função pode entrar na fila"
Se a Call Stack ainda estiver ocupada, o callback vai continuar esperando na Task Queue.
Então este código:
setTimeout(() => {
console.log("timeout")
}, 0)não quer dizer "executa agora".
Na prática, quer dizer algo mais próximo de: "assim que o tempo mínimo expirar e o ambiente puder enfileirar essa tarefa, ela fica aguardando a sua vez".
Onde entra o Event Loop
Agora sim faz sentido falar do Event Loop.
O Event Loop não é a linguagem JavaScript inteira, e também não é a única peça do assincronismo.
O papel dele é coordenar o momento em que uma nova task pode sair da fila e ir para a Call Stack.
Por baixo dos panos, a spec descreve o Event Loop como um loop contínuo que processa uma task por vez. A cada iteração, ele verifica se há trabalho pendente nas filas e, se a stack estiver livre, move a próxima task para execução.
De forma simplificada:
- a cada iteração do loop, ele seleciona a próxima task disponível
- aguarda a stack estar livre para executá-la
- ao final da task, processa o que estiver pendente nas filas de prioridade maior
Então, no exemplo do setTimeout, o callback só vai para a Call Stack quando a stack estiver livre.
É por isso que o Event Loop é importante: ele organiza a passagem entre trabalho pendente e execução real.
Só que existe outra fila: Microtask Queue
Até aqui, vimos o caso de callbacks como setTimeout.
Mas boa parte do JavaScript moderno usa Promise, e Promise segue outra fila.
Essa fila é a Microtask Queue.
Ela é usada principalmente para:
- handlers de
Promisecomthen,catchefinally - continuação de funções
asyncdepois de umawait - callbacks agendados com
queueMicrotask - callbacks de
MutationObserver
O ponto central aqui é que Microtask Queue tem prioridade sobre a Task Queue.
Um exemplo que deixa isso evidente
console.log("início")
setTimeout(() => {
console.log("timeout")
}, 0)
Promise.resolve().then(() => {
console.log("promise")
})
console.log("fim")A saída será:
início
fim
promise
timeoutSe a gente acompanhar com calma, a ordem faz sentido:
console.log("início")executa.setTimeoutregistra o timer e sai da stack.Promise.resolve().then(...)registra o handler da promise.console.log("fim")executa.- A execução do script termina e a stack fica vazia.
- Antes de processar a próxima task, o runtime drena as microtasks pendentes.
- O callback do
thenroda primeiro. - Só depois disso a task do
setTimeoutpode rodar.
É por isso que promise aparece antes de timeout.
O que o fetch realmente tem a ver com isso
fetch costuma aparecer nessas explicações, mas aqui vale separar bem as coisas.
O fetch não vai para a Microtask Queue só por ser fetch.
O que acontece é:
fetchinicia uma operação assíncrona no ambiente do navegador- essa operação segue fora da stack
fetchretorna umaPromise- quando essa promise é resolvida, os handlers associados a ela entram na
Microtask Queue
Exemplo:
console.log("antes do fetch")
fetch("https://api.exemplo.com/posts")
.then((response) => response.json())
.then((data) => {
console.log("dados recebidos", data)
})
console.log("depois do fetch")Mesmo sem saber quando a resposta chegará, o restante do código continua.
Isso acontece porque a requisição não fica parada na stack esperando resposta.
Quando os dados voltam e a promise é resolvida, aí sim os handlers entram na Microtask Queue.
O que o .then(...) faz enquanto a promise ainda está pendente
Esse detalhe é importante para preparar o próximo passo da série.
Quando o JavaScript encontra isto:
fetch("https://api.exemplo.com/posts")
.then((response) => response.json())o callback do .then(...) não é executado na hora, e também não vai imediatamente para a Microtask Queue.
Primeiro, ele fica registrado na própria Promise como uma reação pendente à futura resolução daquela operação.
Só quando a Promise realmente é resolvida é que essa reação é transformada em microtask e entra na Microtask Queue.
Esse ponto ajuda a separar duas etapas que muita gente mistura:
- registrar o que deve acontecer quando a
Promiseresolver - agendar a execução desse callback quando a resolução realmente acontecer
Então a Microtask Queue não recebe "qualquer .then(...) no momento em que ele aparece". Ela recebe a reação associada à Promise quando essa Promise assenta.
Como a prioridade realmente funciona
Uma forma boa de guardar isso é esta:
O checkpoint de microtasks acontece sempre que o execution context stack fica vazio — não só ao final de uma task, mas também ao final da avaliação de um script, por exemplo. Na prática, o comportamento mais relevante para o dia a dia é este:
- uma task é executada até o fim
- quando a stack esvazia, o runtime drena toda a
Microtask Queue— incluindo microtasks que forem agendadas durante essa drenagem - só então ele seleciona a próxima task disponível
Isso é importante porque microtasks não são tratadas como "mais uma task qualquer". Elas têm prioridade maior e a fila é sempre completamente esvaziada antes da próxima task rodar.
E existe um detalhe importante aqui: microtasks podem agendar outras microtasks.
queueMicrotask(() => {
console.log("microtask 1")
queueMicrotask(() => {
console.log("microtask 2")
})
})Como a fila de microtasks é drenada até o fim antes da próxima task, abusar disso pode atrasar bastante o restante do sistema.
Em cenários extremos, isso pode impedir o avanço das próximas tasks e deixar a aplicação sem resposta.
E o async/await?
async/await melhora muito a leitura do código, mas, por baixo dos panos, continua dependendo de Promise.
Exemplo:
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
async function executar() {
console.log("antes do await")
await delay(1000)
console.log("depois do await")
}
executar()
console.log("fora da função")A ordem observada é:
antes do await
fora da função
depois do awaitO que acontece aqui é:
executar()começa normalmente- ao encontrar
await, a função é pausada naquele ponto - o restante do programa continua rodando
- quando a promise é resolvida, a continuação da função é agendada como microtask
Isso significa que await não bloqueia a thread principal.
Ele pausa aquela função assíncrona específica, não o programa inteiro.
E isso já aponta para o próximo artigo: entender que async/await não cria um tipo novo de assincronismo. Ele reorganiza, em uma forma mais legível, o mesmo fluxo baseado em Promise e microtasks.
O que costuma confundir nesse assunto
Tem alguns erros de modelo mental que aparecem sempre:
1. Achar que setTimeout(fn, 0) executa imediatamente
Não executa.
O callback só entra em uma fila depois que o tempo mínimo expira, e ainda precisa esperar a stack estar livre.
2. Achar que fetch fica ocupando a stack até a resposta chegar
Também não.
O fetch participa do início da operação e retorna uma promise. A espera pela resposta acontece fora da stack principal.
3. Achar que Event Loop é "o JavaScript inteiro"
Não é.
O Event Loop é uma peça do modelo de execução do runtime. Ele coordena a passagem entre filas e a stack, mas o quadro completo envolve mais coisas.
4. Colocar callback e promise no mesmo saco
Nem todo trabalho assíncrono vai para a mesma fila.
Tasks e microtasks têm comportamentos e prioridades diferentes.
5. Assumir que toda Web API é assíncrona
Não é verdade.
Por exemplo:
document.getElementById("app")
localStorage.setItem("theme", "dark")Essas operações são síncronas.
Então o ponto não é "Web API = assíncrono".
O ponto correto é: algumas Web APIs permitem iniciar operações assíncronas.
Fechando a ideia
Se a gente resumir o modelo inteiro, ele fica assim:
- a
Call Stackorganiza a execução de um trecho síncrono por vez - o navegador oferece
Web APIsque permitem iniciar operações fora da stack principal - callbacks baseados em tasks esperam na
Task Queue - handlers de promises e continuações após
awaitesperam naMicrotask Queue - quando a stack fica livre, microtasks têm prioridade sobre tasks — e a fila é sempre completamente drenada antes da próxima task rodar
- o
Event Loopé um loop contínuo que coordena essa passagem entre filas e execução
Entender isso muda bastante a forma como lemos código assíncrono.
setTimeout, fetch, then, catch, await e eventos deixam de parecer "mágica" e passam a fazer parte de um fluxo previsível.
E esse é justamente o ponto mais importante: quando o modelo mental fica certo, depurar e projetar código assíncrono fica muito mais fácil.
Depois dessa base, o próximo passo natural é aprofundar o funcionamento de Promise, async/await e alguns padrões que surgem no código real: Promises e async/await no JavaScript.