Por que o JavaScript assíncrono existe
Computadores fazem muitas coisas de forma lenta: ler arquivos, consultar bancos de dados, esperar a resposta de um servidor. Se o JavaScript parasse e esperasse por cada uma dessas tarefas, a página travaria e os usuários iriam embora. A programação assíncrona é a maneira como a linguagem continua funcionando enquanto tarefas lentas acontecem em outro lugar.
O modelo mental é o de um restaurante. Um único garçom anota o seu pedido, o passa para a cozinha e imediatamente atende outra mesa. Quando a sua comida fica pronta, a cozinha toca um sino e o garçom a traz. O garçom nunca fica parado na cozinha observando a comida cozinhar. O JavaScript é esse garçom, e o event loop é o sino.
A call stack e o event loop
O JavaScript possui apenas uma call stack. Cada chamada de função adiciona um frame; quando ela retorna, o frame é removido. Como existe apenas uma stack, apenas uma coisa é executada por vez.
As APIs assíncronas são fornecidas pelo ambiente host, não pela linguagem. Quando você chama setTimeout, fetch ou a leitura de um arquivo, o navegador ou o Node.js gerencia a espera. Quando o trabalho termina, o callback correspondente é colocado em uma fila. O event loop verifica constantemente: a call stack está vazia? Se sim, ele pega o próximo callback da fila e o executa.
// order.js
console.log("start");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("end");
// start, end, promise, timeout
Dois detalhes são importantes. Primeiro, o código síncrono sempre termina antes de qualquer callback ser executado. Segundo, existem duas filas: as microtasks (callbacks de promises) são executadas imediatamente após a tarefa atual, antes das tasks (timers, I/O). É por isso que promise é registrado no log antes de timeout.
Callbacks
A maneira original de lidar com resultados assíncronos é através de um callback — uma função que você passa para ser chamada posteriormente.
// callback.js
function getUser(id, callback) {
setTimeout(() => {
callback({ id, name: "Ada" });
}, 300);
}
getUser(1, (user) => {
console.log(user.name);
});
Callbacks funcionam, mas a composição deles é ruim. Quando cada etapa depende da anterior, você acaba aninhando funções, e o resultado é o callback hell: código que avança diagonalmente pela tela e torna o tratamento de erros doloroso.
// hell.js
getUser(1, (user) => {
getPosts(user.id, (posts) => {
getComments(posts[0].id, (comments) => {
// three levels deep and counting
});
});
});
Promises
Uma Promise é um objeto que representa um valor que pode ainda não existir. Ela está em um de três estados:
- pending — o trabalho ainda está em execução.
- fulfilled — terminou com sucesso e retornou um valor.
- rejected — falhou por algum motivo.
Uma vez liquidada (settled), a promise nunca muda de estado. Você anexa manipuladores (handlers) com then, catch e finally.
// promise.js
function getUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) resolve({ id, name: "Ada" });
else reject(new Error("Invalid id"));
}, 300);
});
}
getUser(1)
.then((user) => console.log(user.name))
.catch((error) => console.error(error.message))
.finally(() => console.log("done"));
O encadeamento (chaining) é a principal vantagem. Cada then retorna uma nova promise, portanto, em vez de aninhar, você pode linearizar a sequência e tratar todos os erros em um único catch ao final.
// chain.js
getUser(1)
.then((user) => getPosts(user.id))
.then((posts) => getComments(posts[0].id))
.then((comments) => console.log(comments))
.catch((error) => console.error("Something failed:", error));
Combinando promises
Quando várias operações assíncronas estão envolvidas, o construtor Promise fornece combinadores.
// combine.js
const [user, posts, settings] = await Promise.all([
getUser(1),
getPosts(1),
getSettings(),
]);
const results = await Promise.allSettled([getUser(1), getUser(-1)]);
const fastest = await Promise.race([getFromCache(), getFromNetwork()]);
const firstSuccess = await Promise.any([mirrorA(), mirrorB()]);
O Promise.all executa tudo em paralelo e rejeita se qualquer promise for rejeitada. O Promise.allSettled aguarda todas e reporta cada resultado. O Promise.race finaliza com a primeira promise a ser resolvida ou rejeitada, e o Promise.any resolve com a primeira a ter sucesso. Escolher a opção correta altera drasticamente o comportamento de erro da sua aplicação.
async e await
async e await são sintaxes construídas sobre promises. Marcar uma função como async faz com que ela sempre retorne uma promise. Dentro dela, o await pausa a função até que uma promise seja resolvida, continuando então com o valor resolvido — sem bloquear o restante do programa.
// await.js
async function loadDashboard() {
try {
const user = await getUser(1);
const posts = await getPosts(user.id);
return { user, posts };
} catch (error) {
console.error("Failed to load dashboard", error);
throw error;
}
}
Isso parece um código síncrono, que é exatamente o objetivo. Erros são tratados com try/catch comuns, e o finally continua funcionando. A função retorna uma promise, então quem a chama pode usar await ou encadear .then como de costume.
Sequencial vs paralelo
Um erro sutil, mas caro, é aguardar a conclusão de tarefas independentes uma por uma. Cada await espera pela anterior, fazendo com que o tempo total seja a soma de todas. Quando as tarefas não dependem umas das outras, inicie-as simultaneamente.
// parallel.js
// Sequential — slow, ~600ms
const a = await slowTask("a");
const b = await slowTask("b");
// Parallel — fast, ~300ms
const [x, y] = await Promise.all([slowTask("x"), slowTask("y")]);
Use awaits sequenciais apenas quando uma etapa posterior realmente precise de um resultado anterior, como buscar um usuário antes de buscar as postagens desse usuário.
Tratamento de erros em código async
Promises rejeitadas que ninguém trata tornam-se unhandled rejections, o que pode derrubar processos Node.js e lotar o console nos browsers.
// errors.js
try {
const res = await fetch("/api/data");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
} catch (error) {
showMessage("Could not load data. Please try again.");
} finally {
hideSpinner();
}
Prefira try/catch em volta de chamadas com await, lance objetos Error (não strings) e sempre trate ou relance o erro deliberadamente. O guia de Tratamento de Erros aprofunda-se em estratégias e mensagens voltadas para o usuário.
Armadilhas comuns
- Esquecer o
await. A função retorna uma promise, não o valor, então a linha seguinte é executada cedo demais. forEachcom callbacks assíncronos. OforEachignora promises retornadas; usefor...ofouPromise.allcommap.- Usar await em um loop quando a execução paralela seria melhor. Funciona, mas é desnecessariamente lento.
- Não retornar dentro de
then. A ausência de umreturnquebra a cadeia e faz com que o valor seja perdido. - Presumir a ordem de execução. Apenas
awaite a ordenação de microtasks garantem a sequência; tarefas concorrentes terminam quando terminarem. - Ignorar erros com um
catchvazio, escondendo o bug que você precisava encontrar.
Melhores práticas
- Prefira
async/awaitem vez de longas cadeias de.thenpara melhorar a legibilidade. - Execute tarefas independentes em paralelo com
Promise.allouPromise.allSettled. - Sempre trate rejeições, mesmo que seja apenas para registrar o log e relançar o erro.
- Mantenha as funções async pequenas e atribua a elas apenas uma responsabilidade.
- Use
Promise.allSettledquando resultados parciais forem aceitáveis. - Cancele tarefas que não são mais necessárias com um
AbortController. - Exiba estados de carregamento e de erro para que os usuários entendam o que está acontecendo.
Próximos passos
Agora você tem o toolkit assíncrono completo. Coloque-o em prática com a Fetch API, onde promises e await impulsionam cada requisição de rede, e refine-o com os padrões de Tratamento de Erros.