AV
HomeAboutProjectBlog

© 2026 Ave syah Shina. All rights reserved.

  1. Home
  2. Blog
  3. 05 — Async Programming — How Node.js Does Many Things at Once

05 — Async Programming — How Node.js Does Many Things at Once

August 13, 20267 min read
Download as Markdown

"Just use async/await and it works" was my async strategy, and it worked until it didn't. The model that finally stuck is layered: async in Node.js is a stack of abstractions — callbacks at the bottom, promises above them, and async/await on top — all riding on one engine underneath, the event loop. Understanding the layers separately is what made the whole thing legible [1].

The framing that landed for me is to start at the bottom — the engine — and watch each layer get built on top.

The event loop: the engine underneath

The event loop is the single most important idea in Node.js, because it is the mechanism that makes non-blocking I/O possible on one thread [2][3]. The model: there is one main thread running JavaScript. When code kicks off an async operation (read a file, query a DB), the operation is handed off to the system (or libuv's thread pool), and the main thread immediately moves on to the next line. When the operation finishes, a callback is queued. The event loop's job is to keep cycling through its phases — processing timers, pending I/O callbacks, polling for new events — and running those queued callbacks on the main thread.

timers setTimeout poll I/O events check setImmediate pending callbacks main thread kicks off fs.readFile() libuv / OS (off-thread) reads the file poll phase callback queued & run

The critical implication: the main thread is only blocked while JavaScript is running. As long as my callbacks are short, one thread can juggle thousands of in-flight operations. The moment a callback does heavy synchronous work — a giant loop, a blocking readFileSync, a CPU-bound sort — the loop stalls, and every other pending callback waits. "Don't block the event loop" is the cardinal rule of Node.js, and it is just another way of saying "keep your callbacks short" [3].

Callbacks: the original abstraction

At the bottom layer, async results are delivered via callbacks — functions passed in to be called later, when the operation finishes [4]. Node standardized a convention: the callback receives an error as its first argument, and data (if any) as the rest.

import { readFile } from 'fs';

readFile('/etc/hosts', 'utf8', (err, data) => {
if (err) return console.error(err);
console.log(data);
});

This works, but it composes badly. Two sequential async steps nest two callbacks; five steps nest five deep — the infamous "callback pyramid" or "callback hell." Error handling has to be repeated at every level. Worse, callbacks have no native way to be returned or chained, so patterns like "run these three in parallel, then do X" required libraries (async.js) to express at all.

Promises: objects for future values

A Promise is the next layer up — an object that represents the eventual result of an async operation [5]. It is in exactly one of three states: pending (not finished yet), fulfilled (succeeded, with a value), or rejected (failed, with a reason). Once settled, it cannot change state. Promises chain with .then() (for fulfillment) and .catch() (for rejection), and crucially, they return new promises — so async steps compose linearly instead of nesting.

readFilePromise('/etc/hosts')
.then(data => process(data))
.then(result => save(result))
.catch(err => console.error(err));

Two helpers cover the patterns callbacks made painful: Promise.all([...]) waits for every promise in parallel and resolves with an array of results; Promise.race([...]) resolves or rejects with whichever finishes first. Promises turned "compose async operations" from a control-flow library into the language itself.

Async/await: synchronous-looking code over promises

async/await is the top layer — syntactic sugar that lets me write promise-based code that reads top-to-bottom [6]. Mark a function async and it always returns a promise; inside it, await pauses until a promise settles and yields the value (or throws on rejection).

async function loadAndProcess() {
try {
const data = await readFilePromise('/etc/hosts');
const result = process(data);
return await save(result);
} catch (err) {
console.error(err);
}
}

The try/catch is the part that finally made async code feel like regular code — rejections become throwables, and the same error-handling construct works for both. Underneath, this is identical to the promise chain above; the compiler desugars it. The one trap: await only pauses inside its function — the outer caller still gets a promise, and the event loop keeps running other callbacks while we wait.

Event emitters: a separate async pattern

Worth flagging because it shows up everywhere in Node's core: the EventEmitter. Where a promise models a single future value, an emitter models a stream of events over time — the same callback can fire zero, one, or many times [7]. on('data', ...), on('error', ...), on('close', ...) is the EventEmitter pattern; HTTP requests, streams, and the process object all use it.

import { EventEmitter } from 'events';
const bus = new EventEmitter();
bus.on('ping', () => console.log('pong'));
bus.emit('ping'); // 'pong'

The discipline with emitters: always listen for 'error'. An emitted error with no listener crashes the process — a deliberate design choice that surfaces problems instead of swallowing them.

How I use this

The model I keep is the layering, and it drives a few habits. I write new code with async/await — the readability wins are decisive, and try/catch beats .catch() chains. I reach for Promise.all whenever I have independent async steps that could run in parallel, because awaiting them sequentially wastes wall-clock time. I treat the event loop as a shared resource: any callback that might be slow (a tight loop, a large synchronous transform) is a candidate for a Worker Thread. And I never ignore a promise — every .then gets a .catch, every await sits in a try/catch or has a .catch on the call site. Async code that quietly swallows errors is the hardest class of bug to find later.

References

[1] Mozilla, "Asynchronous JavaScript," MDN Web Docs. [Online]. Available: https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous/Introducing/

[2] OpenJS Foundation, "The Node.js event loop, timers, and process.nextTick()," nodejs.org. [Online]. Available: https://nodejs.org/learn/asynchronous-work/event-loop-timers-and-nexttick

[3] OpenJS Foundation, "Don't block the event loop (and the worker pool!)," nodejs.org. [Online]. Available: https://nodejs.org/learn/asynchronous-work/dont-block-the-event-loop

[4] OpenJS Foundation, "JavaScript asynchronous programming and callbacks," nodejs.org. [Online]. Available: https://nodejs.org/en/learn/asynchronous-work/javascript-asynchronous-programming-and-callbacks

[5] promisejs.org, "Promises." [Online]. Available: https://www.promisejs.org/

[6] Mozilla, "async function," MDN Web Docs. [Online]. Available: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function

[7] OpenJS Foundation, "The Node.js event emitter," nodejs.org. [Online]. Available: https://nodejs.org/en/learn/asynchronous-work/the-nodejs-event-emitter

Knowledge check · Question 1 of 5

What is the event loop's job in Node.js?

Comments

Leave a Comment

You must be signed in to comment

0 Comments

No comments yet. Be the first to comment!