Menu

Event Loop no JavaScript: como o código assíncrono roda

O modelo mental por trás do JavaScript assíncrono: call stack, task queue, microtask queue e como o event loop conecta tudo isso.

Uma thread só, mas nada de execução sequencial

O JavaScript roda em uma única thread. Existe uma só call stack e, em qualquer instante, apenas uma função está sendo executada. Duas linhas do seu código nunca rodam em paralelo dentro do mesmo realm.

Isso parece uma limitação — até você lembrar o que o JavaScript faz de verdade no dia a dia: busca dados, espera cliques do usuário, lê arquivos. Quase todo o "trabalho" é, na prática, esperar. É aí que entra o event loop do JavaScript: ele torna essa espera barata. Seu código entrega uma tarefa para o browser ou para o Node, volta a fazer outras coisas e recebe um aviso quando o resultado está pronto.

index.js
Output
Click Run to see the output here.

primeiro e segundo aparecem na ordem esperada. Já o terceiro só sai depois — mesmo com o timeout em 0. Esse "atraso" é o event loop em ação, e entender o porquê é justamente o objetivo desta página.

A call stack do JavaScript

Toda chamada de função empilha um frame na call stack. Quando a função retorna, o frame é desempilhado. A stack é só isso mesmo: uma pilha LIFO (o último que entra é o primeiro que sai).

index.js
Output
Click Run to see the output here.

Quando outer() é executado, o Node empilha outer, depois inner, desempilha inner quando ele retorna "done" e por fim desempilha outer. A pilha fica vazia de novo. E é justamente esse momento de "pilha vazia" que o event loop fica observando.

Código síncrono roda do início ao fim dentro da call stack. Nada assíncrono consegue interrompê-lo. Se você escrever um while (true), a pilha nunca esvazia e a página trava — nada de cliques, timers ou callbacks de promise. O event loop fica de mãos atadas, porque não tem a vez de executar.

Onde o código assíncrono realmente vive

O JavaScript, por si só, não sabe fazer uma requisição de rede nem esperar 100 milissegundos. Essas APIs pertencem ao host — o navegador ou o Node. Quando você chama setTimeout(fn, 100), acontece o seguinte:

  1. O timer é registrado no host.
  2. setTimeout retorna na hora. A pilha continua executando normalmente.
  3. Passados os 100ms, o host coloca fn numa fila.
  4. Quando a pilha esvazia, o event loop tira fn da fila e executa.
index.js
Output
Click Run to see the output here.

O callback do timer só consegue rodar depois que o for e o console.log("fim") terminarem — afinal, a stack ainda não está vazia. Timers representam um atraso mínimo, não uma garantia.

As duas filas: tasks e microtasks

Não existe apenas uma fila. São duas, e essa distinção explica a maior parte das surpresas envolvendo o event loop.

  • Task queue (às vezes chamada de fila de macrotasks): setTimeout, setInterval, callbacks de I/O, eventos de UI.
  • Microtask queue: callbacks de promises (.then, .catch, .finally), continuações de await e qualquer coisa agendada com queueMicrotask.

A regra que o event loop segue é esta:

  1. Executa uma task da task queue.
  2. Esvazia a microtask queue inteira — todas as microtasks, incluindo as que forem agendadas durante o processo.
  3. Renderiza, se necessário (nos navegadores).
  4. Volta para o passo 1.

Microtasks sempre rodam antes da próxima task. É por isso que o exemplo a seguir pega muita gente de surpresa:

index.js
Output
Click Run to see the output here.

A ordem da saída é: sync 1, sync 2, promise, timeout. Primeiro roda o código síncrono. Depois a call stack esvazia. Aí o event loop drena as microtasks (promise). Só então ele vai buscar a task do timer (timeout).

Microtasks podem travar as tasks

Como a microtask queue é drenada por completo antes da próxima task, uma microtask que fica agendando novas microtasks bloqueia a task queue para sempre:

index.js
Output
Click Run to see the output here.

O timer nunca dispararia, porque cada microtask enfileira outra microtask, e o loop nunca deixa a fila esvaziar. Cadeias de promise são seguras porque cada .then agenda apenas uma continuação — mas laços de microtask feitos na mão são uma armadilha que vale a pena conhecer.

await é só açúcar sintático para uma microtask

Quando você dá await em uma promise, a função pausa e o resto dela é agendado como uma microtask, que roda assim que a promise é resolvida. Não tem mágica nenhuma — é .then por baixo dos panos.

index.js
Output
Click Run to see the output here.

Saída: A, C, B. O await devolve o controle pra quem chamou a função. O console.log("C") roda na call stack atual. Depois a microtask queue é drenada e o restante de demo volta a executar, imprimindo B.

Vale guardar isso ao ler código assíncrono: await não bloqueia — ele cede a vez.

Um exemplo completo: entendendo a ordem de execução

Juntando todas as peças:

index.js
Output
Click Run to see the output here.

Ordem de execução:

  1. 1: sync — executa direto na call stack.
  2. 6: sync — ainda na call stack.
  3. A stack esvazia. A microtask queue é drenada: 3: promise, 5: microtask e, por fim, 4: nested microtask (agendada durante a drenagem, mas ainda assim entra na mesma rodada).
  4. Só então o próximo task roda: 2: timeout.

Saída final: 1, 6, 3, 5, 4, 2. Se você conseguiu acompanhar esse traço, entendeu como o event loop do JavaScript funciona.

O event loop do Node tem mais fases

O event loop do Node é um superconjunto do modelo do navegador. Ele é dividido em fases bem definidas — timers, pending I/O callbacks, poll, check, close — e as microtasks são drenadas entre cada uma delas. O setImmediate roda na fase de check, e o process.nextTick roda antes das microtasks comuns (ele tem uma fila própria, com prioridade ainda maior).

Você não precisa decorar o diagrama de fases logo de cara. A ideia central é a mesma do navegador: o código síncrono roda até o fim, depois as microtasks são drenadas e, só então, o loop pega o próximo callback da fila.

Por que isso importa

Quando o modelo mental encaixa na cabeça, muito código assíncrono deixa de ser misterioso:

  • Um for pesado trava a UI porque o event loop nunca consegue a vez dele.
  • setTimeout(fn, 0) é uma forma de adiar trabalho até depois da task atual e das microtasks.
  • Um callback de .then que parece rodar "na hora" depois de uma promise já resolvida ainda espera o código síncrono atual terminar.
  • Um await dentro de um loop serializa o trabalho, porque cada iteração cede a vez para a microtask queue antes de continuar.

Depurar código assíncrono é, na maior parte do tempo, uma questão de perguntar: "o que está na stack agora e o que está na fila?". A resposta é quase sempre o event loop.

A seguir: callbacks

Antes de promises e async/await, a única ferramenta do JavaScript para trabalho assíncrono era o callback — uma função que você entrega para uma API chamar depois. Callbacks continuam em todo lugar (event listeners, APIs nativas do Node), e entendê-los é a base para tudo que vem neste capítulo.

Perguntas frequentes

O que é o event loop no JavaScript?

É o mecanismo que permite ao JavaScript, mesmo sendo single-threaded, executar trabalho assíncrono sem travar a aplicação. O loop fica de olho na call stack — assim que ela esvazia, ele pega o próximo callback da fila e roda. Timers, I/O e continuações de promises acabam todos em filas que o event loop vai drenando, um item de cada vez.

Por que o JavaScript é single-threaded?

A própria spec da linguagem define uma única call stack por realm, então seu código roda numa thread só. A concorrência vem do host (navegador ou Node), que delega trabalho para APIs em segundo plano — timers, rede, I/O de arquivo — e enfileira um callback quando terminam. Você nunca vê dois pedaços de JS rodando ao mesmo tempo no mesmo contexto.

Qual a diferença entre microtasks e macrotasks?

Microtasks vêm de promises (.then, await) e de queueMicrotask. Macrotasks vêm de setTimeout, setInterval, I/O e eventos de UI. Depois que cada macrotask termina, o event loop drena a fila de microtasks inteira antes de rodar a próxima macrotask — é por isso que um Promise.resolve().then(...) sempre executa antes de um setTimeout(..., 0) agendado no mesmo instante.

Por que setTimeout com 0ms não executa na hora?

setTimeout(fn, 0) não significa 'rode agora' — significa 'enfileire fn como macrotask, no mínimo daqui a 0ms'. O código síncrono atual precisa terminar, a fila de microtasks precisa ser drenada, e só aí o event loop vai pegar o seu callback do timer. Ou seja, 0 é um piso, não uma garantia de execução imediata.

Aprenda a programar com o Coddy

COMEÇAR