JS Runtime: Call Stack, Microtasks e Macrotasks

A necessidade de entender a base e o que realmente acontece quando misturamos chamadas sincronas e filas no JavaScript (Microtask e Macrotask)

  • javascript
  • runtime
  • event-loop
  • call-stack
  • microtask
  • macrotask

2025-12-15

Contexto

Com 3 ou 4 anos de experiência prática, atuando diretamente e diariamente no mundo do front-end, em empresas dos EUA, eu fui chamado para fazer uma entrevista para uma vaga de nível pleno (mid-level), para um projeto em Next.js. Eu estava fazendo exatamente isso no momento, na empresa que eu estava trabalhando, então decidi dar a oportunidade. Afinal de contas, eu não estava morto: o mercado dava sinais de voltar à vida, e eu sempre tive a filosofia de continuar visível no mercado.

Pois bem, passei nas entrevistas normais e, na primeira técnica, respondi tudo de React sem grandes dificuldades. No entanto, 4 perguntas puramente sobre JavaScript me deixaram completamente perdido, sem ter confiança para responder o output certo nem mesmo em explicar como chegar na resposta. Uma dessas perguntas foi similar ao exemplo abaixo:

console.log("A")

setTimeout(() => {
  console.log("setTimeout A")
}, 0)

async function runA() {
  console.log("PRE await A - runA")
  await null
  console.log("POS await A - runA")
}
runA()

for (let i = 0; i < 2; i++) {
  console.log(`For loop A - ${i}`)
}
O que esse código imprime?
A
PRE await A - run A
For loop A - 0
For loop A - 1
POS await A - runA
setTimeout A

Meu primeiro pensamento pra essa pergunta foi:

Por que estão me perguntando isso? Em todos os meus anos de Front-End eu nunca precisei me preocupar com parar para pensar na exata ordem dos logs quando existe um await no meio de uma função e outros cenários parecidos. Eu apenas escrevia o código, testava e continuava.

Por um lado, isso pode estar certo nos cenários mais básicos. Talvez isso esteja até certo para mais de 90% dos casos, mas ainda assim, apesar da raiva, o resultado foi claro: não passei, pois o meu resultado no teste não foi satisfatório.

Não quero entrar nos méritos da metodologia de teste, se isso é de fato benéfico ou não. O que importa é que a vaga não era minha e o jogo deve seguir. Então, nesse dia, eu abri o curso do Filipe Deschamps, o Curso.dev e, em uma das aulas, ele, falando sobre autenticação, ao invés de começar já mostrando o código, deu uma volta consideravelmente maior para explicar toda a base: as formas diferentes que possuímos de senha, os trade-offs que podemos ter, o que de fato é a encriptação e, ao final, apresentou um código bem simples que, depois de eu entender toda a base, fez todo o sentido.

Cada peça se encaixou de forma linda e direta e, com o quebra-cabeça montado, eu consegui entender o porquê o curso escolheu tal metodologia pro projeto e também trouxe, de imediato, para outros use-cases de projetos que eu já tinha feito.

Essa aula acendeu uma centelha em mim, uma espécie de semente de indignação. Ela me fez lembrar da entrevista mais cedo e pensar: como alguém que já atua no mercado, fazendo entregas boas e elogiadas nas equipes de front-end em que está ou por que já passou, não sabe o básico de JavaScript? Talvez seja porque eu vim direto de Python e decidi pular a base de JavaScript? Talvez. Pouco importa. Decidi estudar.

Nesse post eu vou falar um pouco mais sobre como as coisas não fizeram sentido pra mim no começo e como o mapa começou a ser montado. Eu elaborei um playground justamente para poder visualizar o movimento dos blocos/linhas de código, e você poderá brincar com ele no final.

Mas o que diabos é isso?

Em todo lugar que eu procurei, as explicações começam da seguinte maneira:

O código JavaScript é uma linguagem que executa código em uma única Call Stack, de maneira single-thread, por contexto, e é responsabilidade do Event Loop coordenar as chamadas asincronas com as Micro e Macrotasks.

De cara, uma das primeiras perguntas que eu fiz ao me deparar com as explicações foi:

E como o Call Stack sabe o que rodar? Quem tem prioridade aos olhos do Event Loop? O que significa síncrono e assíncrono?

Bom, vamos por partes.

Antes de tudo: os "personagens" dessa historia

Para o texto não virar uma confusão de termos soltos, vamos separar, aqui, logo no inicio uma mini legenda.

  • Motor JavaScript ou Engine: Executa o código JavaScript, empilhando e desempilhando frames na Call Stack.
  • Ambiente (Node ou Browser): Fornece APIs como por exemplo o setTimeout, I/O, fetch, etc. Apesar do nome ser diferente entre eles, chamaremos essas APIs de Host APIs para simplificar.
  • Event Loop: O coordenador que decide quando callbacks entram para execução e garante as regras de ordem e prioridade entre as filas.

Sync & Async

De modo muito simplificado, síncrono é aquilo que pode ser resolvido agora na Call Stack, com dados que já estão em memória, e não necessita que a engine volte nela outra vez. Operações, no entanto, que agenda uma continuação pro futuro, seja por tempo (setTimeout), seja porque foram colocadas pra rodar somente quando o usuário executar uma ação especifica (ex.: callback de um onClick de botão), são chamadas de assíncronas.

Para ter objetividade, vamos classificar alguns grupos e objetos:

FunctionSíncronoMicrotaskMacrotask
console.log('.')
Promise.resolve().then()
setTimeout()
for loop
await 1

"O await, mesmo em valores simples como null, empurra a continuação da função para a microtask queue"

O Call Stack sabe de alguma coisa?

Por ouvir e ler repetidas vezes sobre a Call Stack, eu me perguntei se ela sabia alguma coisa e quais mecanismos ela tinha para diferenciar chamadas e como identificar quem roda quando e qual a prioridade de execução entre micro e macrotasks.

É importante frisar que a Call Stack não sabe de absolutamente nada. Ela é, por si só, burra. A pilha é aquilo que ela se propõe a ser: apenas uma pilha de execuções (LIFO2).

Quem define as regras de ordem e quando callbacks podem rodar é o Event Loop (dentro do modelo de execução do ambiente). A Call Stack funciona como uma espécie de pista premium: Enquanto existir código síncrono rodando, nada que está fora dela é executado, não até que ela esvazie..

Event Loop, o cérebro

the simplest little piece in this whole equation, and it has one very simple job. (...) The event loops job is to look at the stack and look at the task queue. If the stack is empty it takes the first thing on the queue and pushes it on to the stack which effectively run it. — JS Conference - Phillip Roberts: https://www.youtube.com/watch?v=8aGhZQkoFbQ

O Event Loop é o mecanismo central do JavaScript. Quando o código é interpretado pela primeira vez, passamos pelo processo de criação de contexto, por onde são montados os contextos globais e locais de função.

Aqui são identificados e guardados let, const, var, function em seus respectivos lugares, com hoisting, colocação no TDZ (Temporal Dead Zone), e são criadas as referências para outros escopos. Na segunda parte, entramos na execução de fato.

O JavaScript executa linha a linha, função a função, na ordem em que elas são chamadas, empilhando e desempilhando a Call Stack. Quando essa etapa termina, ou seja, a Call Stack não tem mais nenhuma outra chamada para executar e fica vazia, o Event Loop passa a ser o mestre de cerimônia.

O Event Loop irá pegar e drenar absolutamente tudo que estiver na Microtask Queue primeiro, sem tocar na Macrotask Queue. A prioridade é sempre essa,

Call Stack -> Microtask Queue -> Macrotask Queue.

Por que saber disso é importante? Bom, isso explica, por exemplo, quando Promises passam na frente de setTimeout, mesmo que eles tenham delays muito curtos, até mesmo de 0ms. setTimeout(...,0) não significa imediatamente mas que precisa rodar imediatamente assim que puder, quando chegarmos nas macrotasks pendentes.

Onde entram setTimeout e Promise.then(...) ?

Linha a linha, o motor do JS/Engine executa o script e constrói as pilhas conforme suas regras.

Quando chegamos, por exemplo, em um setTimeout, o motor do JavaScript não ira executar a callback imediatamente, como faria de maneira sincrona. Ele vai registrar essa chamada na Web API pelo tempo solicitado, por exemplo, 1000 ms, e quando esse tempo vencer, essa chamada será jogada na Macrotask Queue.

Da mesma forma, quando passamos por um Promise.resolve().then(), o Promise.resolve() é resolvido imediatamente, e a callback do .then(...) é agendada na Microtask Queue. Ou seja: o resolve() acontece agora; o .then() roda depois, como microtask, quando o stack esvaziar.

Então aqui vimos um cenário onde temos o motor do JavaScript jogando chamadas na Call Stack, na fila de Microtasks e na fila de Macrotasks.

Onde entra a sabedoria e decisão do Event Loop?

Assim que a Call Stack esvazia, ele então vai para sua segunda lista de prioridades: a Microtask Queue. Cada microtask é então empilhada na Call Stack e rodada, resolvendo o que quer que esteja dentro de cada callback. Essa drenagem das microtasks, enviando para a Call Stack é feita pelo Event Loop, bem como, posteriormente, a drenagem da Macrotask Queue. Vale reforçar que o Event Loop não executa o código, ele só decide quando cada callback pode entrar na Call Stack, e quem efetivamente roda algo é a engine/motor Js. Eu demorei para entender essa separação, entre Event Loop e a Engine.

Exemplo

Abaixo vamos ver um exemplo no sandbox que eu criei. Aqui você não poderá editar as funções, somente a velocidade das animações. Ao final do texto você terá um sandbox completo para brincar, manipular as funções e testar quaisquer ordenamentos e quantidades de funções para entender o passo a passo do JavaScript na rodada das suas funções básicas.

Step 1/343%

Estado inicial

Timeline do Código
Call Stack
Microtask Queue
Macrotask Queue
Host APIs
Motor JS
Aguardando...
Event Loop
Console
Nenhum log ainda.

Afinal, eu preciso saber disso?

Saber desses detalhes, sobre a ordem de execução do javascript pode se tornar muito importante em projetos, por exemplo, onde a performance passa a ser um fator muito determinante. Entender que a UI pode estar travando pois temos um volume muito grande de microtasks, fazendo com que cada macrotask entre em uma espera enorme ate serem executadas. Ou por que um .then() "fura a fila" e aparece antes do setTimeout(0)? Por que muitas Promises encadeadas dão/podem dar a sensação de travamento na UI?

Esse tipo de conhecimento sobre a base, vai implicitamente te mostrar que voce precisa conhecer esses detalhes, mesmo que não seja a coisa mais direta possivel e que va aparecer na sua frente como uma pergunta padrao de entrevista.

Agora, antes de irmos para o Playground, tente, mentalmente responder essa ordem de chamadas abaixo:

setTimeout(() => console.log("T"), 0)
Promise.resolve().then(() => console.log("P"))
console.log("S")
Resposta
S
P
T

Playground

Call Stack
Microtask Queue
Macrotask Queue
Host APIs
Motor JS
Aguardando...
Event Loop
Console
Nenhum log ainda.

Footnotes

  1. O await aqui está sendo considerado no sentido “o que acontece quando, por exemplo, dentro de uma função, temos um await e existem logs síncronos antes e depois”. Mesmo quando você dá await em um valor simples (null), o JavaScript trata como await Promise.resolve(null). Na prática, isso empurra a continuação da função para a fila de microtasks.

    Exemplo:

    async function runA() {
      console.log("PRE await A - runA")
      await null
      console.log("POS await A - runA")
    }
    runA()
    
  2. LIFO: Last In, First Out -> Último a entrar, primeiro a sair.