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.
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).
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:
- O timer é registrado no host.
setTimeoutretorna na hora. A pilha continua executando normalmente.- Passados os 100ms, o host coloca
fnnuma fila. - Quando a pilha esvazia, o event loop tira
fnda fila e executa.
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 deawaite qualquer coisa agendada comqueueMicrotask.
A regra que o event loop segue é esta:
- Executa uma task da task queue.
- Esvazia a microtask queue inteira — todas as microtasks, incluindo as que forem agendadas durante o processo.
- Renderiza, se necessário (nos navegadores).
- 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:
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:
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.
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:
Ordem de execução:
1: sync— executa direto na call stack.6: sync— ainda na call stack.- A stack esvazia. A microtask queue é drenada:
3: promise,5: microtaske, por fim,4: nested microtask(agendada durante a drenagem, mas ainda assim entra na mesma rodada). - 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
forpesado 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
.thenque parece rodar "na hora" depois de uma promise já resolvida ainda espera o código síncrono atual terminar. - Um
awaitdentro 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.