Async Node.js

The event loop in depth, callbacks to Promises to async/await, streams, and EventEmitter.

The event loop, in more depth

Node.js runs your JavaScript on a single main thread — but it can still handle thousands of concurrent operations (file reads, network requests, timers) because slow I/O work is handed off to the operating system (via libuv) rather than blocked on directly. Understanding the pieces involved clears up a lot of confusing async behavior.

The call stack — where synchronous function calls execute, one on top of another, exactly like any other single-threaded language. As long as something is on the call stack, nothing else can run.

The callback queue (a.k.a. the macrotask queue) — where callbacks from things like setTimeout, I/O completions, and DOM events wait to be run once the call stack is empty.

Microtasks — a separate, higher-priority queue for Promise callbacks (.then(), .catch(), async/await continuations) and queueMicrotask(). Node drains the entire microtask queue after every single synchronous operation completes, before moving on to the next macrotask (like the next setTimeout callback).

Javascript
console.log('1: synchronous');

setTimeout(() => console.log('2: macrotask (setTimeout)'), 0);

Promise.resolve().then(() => console.log('3: microtask (Promise)'));

console.log('4: synchronous');

// Output order:
// 1: synchronous
// 4: synchronous
// 3: microtask (Promise)
// 2: macrotask (setTimeout)

Even with a 0ms delay, the setTimeout callback runs after the Promise's .then() — synchronous code always finishes first, then the entire microtask queue is drained, and only then does the next macrotask run. This ordering is a very common source of subtle bugs (and a very common interview question).

From callbacks, to Promises, to async/await

Node's earliest APIs were entirely callback-based, following an "error-first callback" convention — the callback's first argument is either an error or null:

Javascript
import fs from 'fs';

fs.readFile('data.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Failed to read file:', err);
    return;
  }
  console.log(data);
});

Nesting several dependent callback-based operations produces the infamous "callback hell" — code that grows to the right with every additional step, and where error handling has to be repeated at every level:

Javascript
fs.readFile('config.json', 'utf8', (err, config) => {
  if (err) return console.error(err);
  fs.readFile('data.txt', 'utf8', (err, data) => {
    if (err) return console.error(err);
    fs.writeFile('output.txt', data, (err) => {
      if (err) return console.error(err);
      console.log('Done!');
    });
  });
});

Promises flatten this into a chain, and centralize error handling into a single .catch():

Javascript
import fs from 'fs/promises';

fs.readFile('config.json', 'utf8')
  .then(config => fs.readFile('data.txt', 'utf8'))
  .then(data => fs.writeFile('output.txt', data))
  .then(() => console.log('Done!'))
  .catch(err => console.error(err));

async/await is syntax sugar over the exact same Promise mechanics, but reads top-to-bottom like ordinary synchronous code:

Javascript
import fs from 'fs/promises';

async function processFiles() {
  try {
    const config = await fs.readFile('config.json', 'utf8');
    const data = await fs.readFile('data.txt', 'utf8');
    await fs.writeFile('output.txt', data);
    console.log('Done!');
  } catch (err) {
    console.error(err);
  }
}

processFiles();

Each step in this evolution solves the same underlying problem — coordinating asynchronous work — with progressively better readability and error handling, without changing what's actually happening at runtime.

Streams, briefly

A stream processes data in chunks as it arrives, instead of waiting for an entire file (or response body) to be loaded into memory before doing anything with it. This matters enormously for large files: reading a 4GB video file with fs.readFile() attempts to hold the entire file in memory at once; reading it as a stream processes it piece by piece, using a small, constant amount of memory regardless of the file's total size.

Javascript
import fs from 'fs';

// Loads the ENTIRE file into memory before the callback runs
fs.readFile('large-video.mp4', (err, data) => {
  // only safe for files that comfortably fit in memory
});

// Processes the file in small chunks as they're read from disk
const readStream = fs.createReadStream('large-video.mp4');
const writeStream = fs.createWriteStream('copy.mp4');

readStream.pipe(writeStream); // streams data from source to destination, chunk by chunk

Streams are also how Node handles HTTP request/response bodies internally — an incoming request body arrives as a stream of chunks, which is exactly why frameworks like Express need body-parsing middleware to collect and assemble those chunks into a usable req.body before your route handler runs.

EventEmitter

Much of Node's core API (HTTP servers, streams, file watchers) is built on EventEmitter — a pattern where an object emits named events, and any number of listener functions can subscribe to react to them:

Javascript
import { EventEmitter } from 'events';

class OrderProcessor extends EventEmitter {
  process(order) {
    console.log(`Processing order ${order.id}...`);
    this.emit('completed', order);
  }
}

const processor = new OrderProcessor();

processor.on('completed', (order) => {
  console.log(`Order ${order.id} completed — sending confirmation email`);
});

processor.on('completed', (order) => {
  console.log(`Order ${order.id} completed — updating analytics`);
});

processor.process({ id: 501 });

Multiple listeners can subscribe to the same event, and each one runs (synchronously, in registration order) when .emit() is called — a lightweight way to decouple "something happened" from "here's everything that should react to it," without the emitter needing to know anything about its listeners in advance.

Common mistakes

  • Assuming setTimeout(fn, 0) runs "immediately" — it still has to wait for the current synchronous code and the entire microtask queue (including any pending Promises) to finish first.
  • Using fs.readFile() on very large files instead of a stream, causing memory usage to spike with the entire file's size.
  • Forgetting to attach an 'error' listener on an EventEmitter (or a stream, which extends it) — an unhandled 'error' event is treated specially by Node and can crash the entire process.

Interview questions

Q: Why does a Promise.then() callback run before a setTimeout(fn, 0) callback, even though both are scheduled "immediately"? Because Promise callbacks are microtasks, and setTimeout callbacks are macrotasks. Node always fully drains the microtask queue after the current synchronous code finishes, before it picks up the next macrotask — so any pending Promise callbacks run before the next setTimeout, regardless of how small its delay is.

Q: What's the practical difference between blocking and non-blocking I/O in Node? Blocking I/O (like fs.readFileSync) halts the entire single JavaScript thread until the operation completes — nothing else can run in the meantime. Non-blocking I/O (like fs.readFile with a callback, or its Promise-based equivalent) hands the operation off to libuv and immediately continues executing other code, only running the callback once the operation finishes — this is what lets one Node process handle many concurrent operations without dedicating a thread to each one.

Q: Why do streams matter for handling large files? Because reading an entire large file into memory at once (as fs.readFile does) requires enough memory to hold the whole file, which doesn't scale for very large files. A stream processes data in small chunks as they arrive, using a small, roughly constant amount of memory regardless of the total file size — essential for large files, video, or continuously-generated data.

Q: What is EventEmitter, and what Node.js APIs are built on top of it? A class that implements the publish/subscribe pattern — objects extending it can .emit() named events, and any number of listener functions registered with .on() get called when that event fires. Much of Node's core API is built on it, including HTTP servers (which emit a request event per incoming request) and streams (which emit data, end, and error events, among others).