portfólio
← voltar

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.

Matheus Pergoli··9 min de leitura

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.

JS
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:

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:

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:

JS
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.

JS
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:

TXT
início
fim
timeout

Vamos acompanhar o raciocínio com calma:

  1. console.log("início") entra na stack, executa e sai.
  2. setTimeout(...) entra na stack.
  3. O papel do setTimeout nesse momento não é executar o callback. Ele só registra o timer no ambiente do navegador.
  4. Depois disso, setTimeout sai da stack.
  5. console.log("fim") entra na stack, executa e sai.
  6. 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:

Se a Call Stack ainda estiver ocupada, o callback vai continuar esperando na Task Queue.

Então este código:

JS
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:

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:

O ponto central aqui é que Microtask Queue tem prioridade sobre a Task Queue.

Um exemplo que deixa isso evidente

JS
console.log("início")
 
setTimeout(() => {
  console.log("timeout")
}, 0)
 
Promise.resolve().then(() => {
  console.log("promise")
})
 
console.log("fim")

A saída será:

TXT
início
fim
promise
timeout

Se a gente acompanhar com calma, a ordem faz sentido:

  1. console.log("início") executa.
  2. setTimeout registra o timer e sai da stack.
  3. Promise.resolve().then(...) registra o handler da promise.
  4. console.log("fim") executa.
  5. A execução do script termina e a stack fica vazia.
  6. Antes de processar a próxima task, o runtime drena as microtasks pendentes.
  7. O callback do then roda primeiro.
  8. Só depois disso a task do setTimeout pode 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 é:

  1. fetch inicia uma operação assíncrona no ambiente do navegador
  2. essa operação segue fora da stack
  3. fetch retorna uma Promise
  4. quando essa promise é resolvida, os handlers associados a ela entram na Microtask Queue

Exemplo:

JS
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:

JS
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:

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:

  1. uma task é executada até o fim
  2. quando a stack esvazia, o runtime drena toda a Microtask Queue — incluindo microtasks que forem agendadas durante essa drenagem
  3. 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.

JS
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:

JS
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 é:

TXT
antes do await
fora da função
depois do await

O que acontece aqui é:

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:

JS
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:

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.

Artigo anterior

Como o JavaScript executa código: execution context, memória e call stack

Próximo artigo

Promises e async/await no JavaScript: microtasks, encadeamento e fluxo assíncrono

← voltar para o blog