Dev.to WebDev 🛠 Dev 👁 1 📖 9 min read

Event Loop: como JavaScript executa código assíncrono?

Analise o código abaixo: console.log("A"); setTimeout(() => console.log("B"), 0); Promise.resolve().then(() => console.log("C")); console.log("D"); Qual é a ordem? Se você pensou em A, B, C, D, faz sentido

Analise o código abaixo:

console.log("A");

setTimeout(() => console.log("B"), 0);

Promise.resolve().then(() => console.log("C"));

console.log("D");

Qual é a ordem?

Se você pensou em A, B, C, D, faz sentido: é a ordem em que escrevemos o código. Mas não é a ordem em que todas essas funções executam.

Este é o segundo artigo da série “Coisas que usamos todos os dias, mas raramente paramos para entender.”

A ideia é escolher conceitos que aparecem no meu trabalho, estudar com mais profundidade e registrar o que aprendi. Escrever também me ajuda a testar meu entendimento e encontrar dúvidas que passariam despercebidas.

Depois de explorar o DOM e as atualizações de interface, quero entender melhor o que acontece quando misturamos código síncrono, timers e Promises.

Vamos partir desse exemplo e acompanhar sua execução.

1. Antes das filas, precisamos falar do runtime

Quando executamos JavaScript, temos duas partes trabalhando juntas:

  • Engine: interpreta e executa a linguagem. O V8, usado no Chrome e no Node.js, é um exemplo.
  • Ambiente hospedeiro: oferece recursos para interagir com o mundo externo, como timers, rede e eventos.

Chamamos de runtime o ambiente de execução formado por esses mecanismos.

No navegador, temos APIs como DOM e setTimeout. No Node.js, temos recursos como acesso ao sistema de arquivos e APIs de rede.

Essa distinção importa porque nem tudo que usamos num programa JavaScript pertence à linguagem.

Promise faz parte do JavaScript. setTimeout é disponibilizado pelo ambiente.

O funcionamento assíncrono depende da cooperação entre essas partes.

2. Call stack: o que está executando agora?

A call stack, ou pilha de chamadas, acompanha os contextos de execução.

Quando uma função chama outra, a execução da segunda fica no topo da pilha. Ao terminar, ela sai, e a primeira continua.

Considere:

function mostrarMensagem() {
  console.log("Olá!");
}

function iniciar() {
  mostrarMensagem();
}

iniciar();

A sequência é:

  1. O código chama iniciar.
  2. iniciar chama mostrarMensagem.
  3. mostrarMensagem chama console.log.
  4. As chamadas terminam, devolvendo o controle às anteriores.

A pilha segue a regra último a entrar, primeiro a sair.

Na mesma thread de execução, um callback assíncrono não interrompe uma função JavaScript no meio para começar a executar.

Por isso, se uma função fica ocupada com um cálculo longo, os callbacks que dependem dessa thread precisam esperar.

3. Assíncrono não significa executar tudo ao mesmo tempo

Considere:

console.log("Antes");

setTimeout(() => {
  console.log("Timer");
}, 1000);

console.log("Depois");

O programa não fica parado durante um segundo dentro de setTimeout.

Ele registra um timer no ambiente e continua executando:

Antes
Depois
Timer

O callback fica para depois.

O ambiente pode acompanhar operações fora da execução atual do JavaScript. Quando existe trabalho pronto para continuar, os mecanismos de agendamento determinam quando seu callback pode executar.

Isso permite lidar com várias operações em andamento sem executar todos os callbacks simultaneamente na mesma thread.

4. Task queue e microtask queue

No navegador, precisamos distinguir dois tipos de trabalho.

Tasks

Uma task é uma unidade de trabalho agendada pelo ambiente. A execução de um callback de timer é um exemplo.

É comum encontrar o termo macrotask em explicações sobre o assunto, mas aqui vou usar task.

Também falamos em task queue, ou fila de tarefas. É uma simplificação útil, mas o navegador pode ter diferentes filas e fontes de tarefas, não uma única fila universal.

Microtasks

As microtasks são processadas em pontos específicos chamados de microtask checkpoints.

Elas incluem:

  • As reações de Promises, como os callbacks de .then, .catch e .finally, quando podem executar.
  • Funções agendadas com queueMicrotask.
  • A continuação de uma função após um await.

Para o nosso exemplo, a regra importante é:

Quando a execução síncrona atual termina, as microtasks pendentes são processadas antes do callback do timer.

Se uma microtask agenda outra, a nova também pode executar nesse mesmo processamento, antes de passar para a próxima task.

Isso não significa que uma Promise interrompa o código que já está executando.

5. Então o que o Event Loop faz?

O Event Loop coordena o processamento do trabalho agendado pelo ambiente.

Para entender o exemplo inicial no navegador, podemos usar este modelo simplificado:

  1. Executar o trabalho JavaScript atual até terminar.
  2. Processar as microtasks pendentes.
  3. Permitir que o ambiente continue seu ciclo e selecione outra task.

O navegador também precisa encaixar oportunidades de atualização visual nesse ciclo. Não existe a garantia de que ele desenhe a tela depois de toda task.

O Event Loop não torna os callbacks paralelos. Ele ajuda a organizar quando cada trabalho pode executar.

6. Vamos reproduzir o exemplo

Crie um arquivo chamado index.html, cole este código e abra no navegador:

<!doctype html>
<html lang="pt-BR">
  <head>
    <meta charset="UTF-8" />
    <title>Event Loop</title>
  </head>

  <body>
    <h1>Event Loop</h1>
    <p>Abra o console do navegador para acompanhar a execução.</p>

    <script>
      console.log("A");

      setTimeout(() => console.log("B"), 0);

      Promise.resolve().then(() => console.log("C"));

      console.log("D");
    </script>
  </body>
</html>

Abra o DevTools e a aba Console. Se necessário, recarregue a página.

O resultado será:

A
D
C
B

Agora vamos entender cada etapa.

7. Por que a ordem é A, D, C, B?

Primeiro: A

console.log("A");

É uma chamada síncrona. Executa naquele momento.

Saída:

A

Depois: registramos o timer

setTimeout(() => console.log("B"), 0);

O ambiente registra o timer.

O 0 não significa “execute agora”. A execução ainda depende das regras do ambiente e da disponibilidade para processar o callback.

Portanto, B ainda não aparece.

Depois: agendamos a reação da Promise

Promise.resolve().then(() => console.log("C"));

Promise.resolve() retorna, neste caso, uma Promise já cumprida.

Mesmo assim, o callback passado para .then não executa imediatamente. Sua execução é agendada como uma microtask.

C também ainda não aparece.

Depois: D

console.log("D");

Outra chamada síncrona. O código atual ainda está executando, então imprime D.

Saída até aqui:

A
D

A execução síncrona termina: C

Agora a microtask da Promise pode ser processada:

() => console.log("C")

Saída:

A
D
C

O callback do timer executa: B

Quando o callback do timer é processado como uma task, imprime B.

Resultado final:

A
D
C
B

A ordem em que escrevemos o código não determina, sozinha, a ordem dos callbacks.

Trecho Tipo de trabalho Resultado
console.log("A") Síncrono Executa primeiro
setTimeout(...) Registra um timer Seu callback fica para depois
.then(...) Agenda uma reação da Promise Executa como microtask
console.log("D") Síncrono Executa antes dos callbacks

8. Um detalhe: nem tudo dentro de uma Promise é assíncrono

Considere:

console.log("A");

new Promise((resolve) => {
  console.log("B");
  resolve();
}).then(() => {
  console.log("C");
});

console.log("D");

A saída é:

A
B
D
C

A função passada ao construtor de Promise, chamada de executor, executa imediatamente.

Já a reação registrada pelo .then executa depois, como microtask.

Portanto, colocar um cálculo pesado dentro de new Promise não faz esse cálculo sair da thread atual.

Uma Promise representa um resultado que pode estar pendente, ser cumprido ou ser rejeitado. Ela não cria automaticamente uma thread para executar seu código.

9. Onde o async/await entra?

async/await permite escrever código baseado em Promises com uma leitura mais sequencial.

Vamos testar:

console.log("A");

async function executar() {
  console.log("B");

  await Promise.resolve();

  console.log("C");
}

executar();

console.log("D");

Resultado:

A
B
D
C

O que aconteceu?

  1. A é impresso.
  2. Chamamos executar.
  3. A função imprime B de forma síncrona.
  4. Ao chegar no await, a função suspende sua execução.
  5. O código que chamou a função continua e imprime D.
  6. A continuação após o await é processada depois e imprime C.

O await suspende aquela função; ele não bloqueia a thread inteira.

Mesmo esperando uma Promise já cumprida, a continuação após o await acontece de forma assíncrona.

Uma função async também retorna uma Promise:

async function obterNumero() {
  return 10;
}

obterNumero().then((numero) => {
  console.log(numero); // 10
});

Mas isso não torna todo o seu corpo assíncrono. O código anterior ao primeiro await executa durante a chamada da função.

10. setTimeout com zero não é execução imediata

Podemos observar isso com uma espera ocupada:

const start = performance.now();

setTimeout(() => {
  console.log(
    "Timer executou após:",
    Math.round(performance.now() - start),
    "ms"
  );
}, 0);

while (performance.now() - start < 200) {
  // Mantém a thread ocupada por aproximadamente 200 ms.
}

console.log("Código síncrono terminou");

O timer só poderá executar depois que o código síncrono liberar a thread.

Você verá algo parecido com:

Código síncrono terminou
Timer executou após: 201 ms

O tempo exato varia. O ponto é que o timer não interrompeu o while.

Esse loop é apenas uma demonstração. Durante sua execução no navegador, a thread principal fica ocupada, prejudicando interações e atualizações da página.

11. Como isso aparece no trabalho?

No frontend, cálculos longos na thread principal podem atrasar respostas aos cliques e atualizações visuais.

No backend Node.js, cálculos longos na thread do Event Loop podem atrasar o processamento de outras requisições e callbacks.

Uma função marcada como async continua podendo bloquear essa thread:

function executarCalculoPesado() {
  const start = performance.now();

  while (performance.now() - start < 200) {
    // Simula trabalho síncrono.
  }

  return "Concluído";
}

async function calcular() {
  return executarCalculoPesado();
}

Adicionar async não transfere o cálculo para outro lugar. Ao chamar calcular(), o cálculo síncrono executa antes de a chamada retornar sua Promise.

Dependendo do problema, precisamos reduzir o trabalho, dividi-lo em etapas que permitam ao ambiente continuar ou usar mecanismos como Web Workers no navegador e worker threads no Node.js.

Também vale ter cuidado com microtasks que continuam criando outras microtasks. Uma sequência longa pode atrasar tasks e, no navegador, oportunidades de atualização visual.

12. E no Node.js?

O exemplo inicial também produz:

A
D
C
B

Você pode salvar o código em event-loop.cjs e executar:

node event-loop.cjs

Mas não devemos concluir que navegador e Node.js têm exatamente o mesmo agendamento.

O Node.js organiza seu Event Loop em fases e possui mecanismos próprios, como process.nextTick e setImmediate. Esses detalhes podem afetar comparações de ordem em exemplos mais complexos.

Para este artigo, vale guardar o fundamento comum: o código síncrono atual precisa terminar antes de esses callbacks assíncronos executarem.

Vou deixar as fases do Node.js e o papel da libuv para um próximo artigo.

Conclusão

O exemplo de quatro letras ajuda a separar três coisas:

  • O código que executa agora.
  • O trabalho agendado como microtask.
  • O trabalho que depende de uma task futura, como o callback do timer.

É por isso que chegamos a:

A
D
C
B

Entender essa divisão me ajuda a investigar uma atualização que demorou, uma sequência de callbacks inesperada ou um cálculo que bloqueou a aplicação.

async/await simplifica a sintaxe. Ele não elimina o modelo assíncrono que existe por baixo.

Você já encontrou um problema em que a ordem de execução era diferente da que parecia olhando o código?

Referências

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.