Javascript Assíncrono
Introdução
Esses dias me surgiu a dúvida de como é que o node.js consegue receber milhares de requisições por segundo sendo um runtime de javascript, que até onde eu sei, é uma linguagem single-threaded.
Bom, essa dúvida me levou a várias pesquisas onde me deparei com termos como Event Loop, callstack, microtasks, callbacks, entre outras.
Fiz esse post no intuito de transpor o que aprendi nessas pesquisas.
Como a Call Stack do javascript funciona
Antes de tudo, é preciso entender como a call stack do javaScript funciona: uma pilha que mantém os contextos de execução das funções ativas.
Veja o exemplo abaixo:
function primeira() {
console.log("Entrou em primeira");
segunda();
console.log("Saiu de primeira");
}
function segunda() {
console.log("Segunda");
}
primeira();
console.log("Final")
Os estados da callstack desse script serão:

Note que a chamada console.log("Final") só ocorre depois de toda a execução da função primeira(), pois ela ocupa a callstack por ser síncrona e impede que o código continue executando antes do seu término.
Cenário hipotético: e se o javascript fosse bloqueante?
Para ilustrar como o javascript lida com chamadas assíncronas, primeiro vou supor um cenário hipotético problemático:
Imagine que o javascript é single-threaded e não consegue executar operações assíncronas de forma não-bloqueante. Isso significaria que toda chamada assíncrona que você fizesse, como um fetch() para buscar dados ou um fs.readFile() para ler arquivos, ocuparia sua call stack até seu término e, consequentemente, a thread única.
Isso seria bizarro pois não permitiria que o javascript fizesse qualquer outra coisa até completar a chamada bloqueante.
- No browser, isso ia fazer com que nenhum outro código javascript rodasse, dando a impressão de UI congelada.
- Em um servidor HTTP node.js, faria seu servidor não conseguir responder nenhuma outra requisição.
Como o node.js lida com assincronismo
Algo assíncrono é algo que não ocorre de maneira síncrona, ou seja, cujo resultado não estará disponível imediatamente.
O node.js, para não travar a thread única do javascript em execução com chamadas assíncronas, não permite que essas chamadas permaneçam ocupando a call stack, permitindo que o programa continue executando outras operações.
E para onde vão essas chamadas assíncronas? O node.js tem a capacidade de delegar operações assíncronas, como as de I/O, para o kernel do seu sistema operacional e para o libuv, uma biblioteca escrita em C que dá suporte a operações I/O assíncronas. Assim que a operação for completa, o responsável vai avisar o node.js, que retomará o seu código de onde ele parou.
O javascript em execução no browser delega essas operações para o próprio browser, através das Web APIs.
O event loop do node.js
Internamente, o node.js possui uma estrutura chamada de Event Loop. Essa estrutura é utilizada pelo node.js para lidar com os retornos das operações delegadas para o kernel.
O event loop é composto por um loop de fases, onde cada fase possui sua própria fila FIFO. O node.js utiliza essas filas para organizar os callbacks que ele deve chamar, como os de retorno de operações de I/O.

Macrotasks vs Microtasks no node.js
O event loop organiza a execução das macrotasks em fases. As microtasks, por sua vez, são executadas imediatamente após o esvaziamento da call stack, antes da próxima fase do event loop.
Você deve estar familiarizado com Promises no javascript. Quando você escreve o .then() após uma Promise, você está registrando uma futura microtask, que será enfileirada em uma FIFO chamada de Microtasks Queue assim que a sua Promise for resolvida.
As microtasks não fazem parte de nenhuma fase do Event Loop, elas apenas são chamadas sempre que a callstack esvaziar ou após cada macrotask
fetch("...").then((res) => console.log(res.ok));
O que acontece por debaixo dos panos nessa chamada acima é:
- O node.js identifica uma chamada assíncrona de I/O e delega a operação de rede para o kernel. A callstack não é permanece ocupada por essa chamada.
- Qualquer outra operação após esse código pode ser executada
- Kernel preenche um socket com dados da operação de rede e avisa ao node.js
- O node.js adiciona um callback na Poll Queue para leitura de dados do socket
- Promise foi resolvida (node.js obteve os dados da requisição)
- A função de callback passada ao .then() (
(res) => console.log(res.ok)) é adicionado a Microtasks Queue - Assim que a call stack esvaziar, todas as microtasks são executadas.
Se você reparar bem no passo-a-passo, vai reparar que existe uma ordem de prioridade: sempre vai ser executado primeiro o código síncrono, depois as microtasks e, por fim, as macrotasks.
queueMicrotask(() => {
console.log("Microtask");
});
// setTimeout agenda um callback na etapa timers do NodeJS event loop
setTimeout(() => {
console.log("Task");
}, 0);
console.log("Síncrono");
No código acima, o console log vai mostrar:
Síncrono
Microtask
Task
Pois é a ordem de precedência de operações que o node.js segue, como descrito acima.
Exemplos do não-paralelismo do javascript
Para demonstrar que o javascript em execução pelo node.js realmente não faz nada em paralelo, trouxe alguns exemplos em um servidor HTTP express.js simples:
import express from "express"
const app = express()
app.listen(3000, () => {
console.log("Server is running on http://localhost:3000")
})
app.get("/", (req, res) => {
res.send("Hello, World!")
})
app.get("/cpu-bound", (req, res) => {
let total = 0;
for (let i = 0; i < 5e12; i++) {
total += i;
}
res.send(`Resultado: ${total}`);
});
app.get("/io-bound", async (req, res) => {
await new Promise(resolve => setTimeout(resolve, 10000))
res.send("I/O-bound task completed after 10 seconds")
})
- Teste 1 - Bloqueando a thread
Ao fazer a requisição para a rota /cpu-bound, o servidor não conseguirá mais responder a nenhuma outra chamada para qualquer rota até que o cálculo termine. Um segundo usuário, mesmo que consulte uma rota que não tenha nenhum processamento sequer, como a /, vai ter o processamento da sua requisição aguardando até a callstack ser liberada.
- Teste 2 - Liberando a thread com chamadas assíncronas
Por outro lado, se você fizer uma chamada a rota /io-bound, que demora 10s para responder, você irá conseguir realizar outras requisições nesse meio tempo, como a de “Hello World” pois a callstack não está bloqueada. O node.js está esperando o callback do timer ser adicionado no Event Loop para finalizar a requisição.
Da mesma maneira, você pode criar exemplos semelhantes no browser, mas você irá notar que o que vai parar de responder será sua UI.
O que você deve ter em mente
Bom, acredito que não seja vantajoso decorar a ordem de precedência das tasks dentro do event loop, ou até mesmo saber quais são todas as FIFOs que existem lá.
A ideia central que quero passar é: De fato, o javascript é single-threaded. Temos que entender a natureza das chamadas assíncronas dele e entender que operações síncronas que demandam muito do processador, como cálculos pesados, criptografia, entre outras coisas podem bloquear a nossa callstack por um tempo alto.
Isso não seria um problema se o javascript fosse multi-thread, pois isso apenas afetaria a pessoa que está fazendo a requisição, por exemplo. Mas por ser single-threaded, a call stack travar significa congelar a thread única, que vai afetar o seu código como um todo
Apesar do javascript ser single-threaded, o Node.js como um runtime consegue realizar coisas em paralelo, como I/O no kernel, utilizar a thread pool do libuv, criar worker threads com o SO.
Não ocupe a callstack com códigos CPU-bound pesados.
- Prefira implementações assíncronas de funções. Ex:
fs.readFile()vsfs.readFileSync(). Apenas o segundo trava a callstack. - Crie Web Workers (Browser) ou Worker Threads (node.js) para rodar seu código de maneira assíncrona.