← back to section

When backend code reads a file, queries a database, or calls an external HTTP service, it waits for a response. The question is what happens during that wait. In TypeScript on Node this works differently from most threaded languages, and understanding the mechanism is the foundation of all server-side code.

Why async exists at all

Imagine a server that processes requests one at a time and freezes for 50 milliseconds on each database call. While it waits, it does nothing — and during that time hundreds of other requests could be served. Waiting for I/O (disk, network, database) is almost always not CPU work; it is simply waiting for someone else's answer.

Async solves exactly this: while one operation is waiting, the program takes care of other things. Short formula: don't block the thread on waiting — instead, ask to be notified when the result is ready.

Single-threaded and non-blocking I/O

Node executes your JavaScript/TypeScript code in a single thread. At any given moment exactly one piece of your code is running — no two functions at once. That sounds like a limitation, but in practice it simplifies life: no race conditions over shared variables, as you'd have in multi-threaded languages.

The secret is that I/O does not block this thread. When you ask to read a file, Node hands the operation to the operating system (or its internal thread pool under the hood) and immediately frees the main thread for other work. When the file is ready, Node returns to your code to deliver the result.

console.log("1");

setTimeout(() => {
  console.log("3"); // runs later, when the thread is free
}, 0);

console.log("2");

// Output: 1, 2, 3 — even with a 0 ms delay

Even with a zero delay, 3 prints last: the callback does not run "immediately" — it waits until the current code finishes.

The event loop in plain terms

The event loop is the mechanism behind all this. Think of it as a manager with a task queue:

  1. Execute all synchronous code that is currently available.
  2. When the thread is free — take the next ready result from the queue (file read, network response, timer fired) and execute the associated callback.
  3. Repeat indefinitely.
diagram

An important consequence: if your synchronous code runs for a long time (a heavy loop with a million iterations), the event loop is blocked and cannot process other requests. The single-threaded model forgives waiting for I/O, but not heavy computation on the main thread.

One more detail: Node has microtasks (Promise callbacks) and macrotasks (timers, I/O). Microtasks have priority — they run before the next timer. In practice this rarely causes trouble, but it explains why a Promise result arrives "almost immediately" while setTimeout is noticeably later.

Callbacks: the old way

Historically, async was expressed with callbacks — a function passed to be called later:

import { readFile } from "node:fs";

readFile("config.json", "utf-8", (err, data) => {
  if (err) {
    console.error("Failed to read file", err);
    return;
  }
  console.log("Contents:", data);
});

The problem arises when there are multiple dependent operations. Callbacks nest inside each other, creating a staircase of indentation known as callback hell:

readFile("a.txt", "utf-8", (err1, a) => {
  readFile("b.txt", "utf-8", (err2, b) => {
    readFile("c.txt", "utf-8", (err3, c) => {
      // error handling is scattered, hard to read
      console.log(a, b, c);
    });
  });
});

Promise: a promise of a result

A Promise is an object that says: "a result will be here when the operation finishes." A Promise has three states: pending, fulfilled, and rejected. The result is received via .then and errors via .catch:

import { readFile } from "node:fs/promises";

readFile("config.json", "utf-8")
  .then((data) => console.log("Contents:", data))
  .catch((err) => console.error("Error:", err));

The main advantage is chaining. Dependent operations line up in a sequence instead of a staircase:

fetchUser(1)
  .then((user) => fetchOrders(user.id)) // returns a new Promise
  .then((orders) => console.log("Orders:", orders.length))
  .catch((err) => console.error("Something failed:", err));

A single .catch at the end catches an error from any link in the chain — already much cleaner than callbacks.

async/await: async code that reads like regular code

async/await is syntactic sugar over Promise. A function marked async always returns a Promise, and await pauses it until the result is ready — without blocking the thread:

import { readFile } from "node:fs/promises";

async function loadConfig(): Promise<string> {
  const data = await readFile("config.json", "utf-8");
  return data; // the function returns Promise<string>
}

Dependent operations now look like ordinary sequential code:

async function showOrders(userId: number): Promise<void> {
  const user = await fetchUser(userId);
  const orders = await fetchOrders(user.id);
  console.log(`${user.name} has ${orders.length} orders`);
}

Under the hood it is still the same Promises and event loop — but such code is significantly easier to read and maintain. Today this is the primary async style in TypeScript on the backend.

Error handling in async code

In async/await, errors are caught with a regular try/catchawait "unwraps" a rejected Promise into a normal exception:

async function loadConfig(): Promise<string> {
  try {
    return await readFile("config.json", "utf-8");
  } catch (err) {
    console.error("Failed to read config:", err);
    return "{}"; // fallback value
  }
}

If you are working with Promises directly, .catch handles the error. The key rule: every async operation must have error handling somewhere along the way. A Promise without .catch (or an async function without try/catch and without handling by the caller) leads to an "unhandled rejection" — Node complains in the logs, and in newer versions may terminate the process.

// Dangerous: nobody catches the error
async function risky() {
  await fetchUser(999); // if it throws — unhandled rejection
}

// Good: the caller catches the error
risky().catch((err) => console.error(err));

Parallel operations: Promise.all

A common mistake is running independent operations sequentially when they could be launched together:

// Slow: waiting one by one, ~600 ms total
const a = await fetchA(); // 200 ms
const b = await fetchB(); // 200 ms
const c = await fetchC(); // 200 ms

If a, b, and c do not depend on each other, run them in parallel with Promise.all — it waits for all of them to complete and returns an array of results:

// Fast: launched together, wait for the slowest — ~200 ms
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);

Important detail: Promise.all rejects as soon as any of the passed Promises fails. If you need the results of all operations regardless of which ones failed, use Promise.allSettled — it returns the status of each:

const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
for (const r of results) {
  if (r.status === "fulfilled") {
    console.log("ok:", r.value);
  } else {
    console.error("failed:", r.reason);
  }
}

Common pitfalls

  • Forgetting await. const data = readFile(...) without await puts not the result but the Promise itself into data. TypeScript often hints at this with the type Promise<...>.
  • Heavy computation on the main thread. A long synchronous loop blocks the event loop and freezes the entire server. For CPU-bound work, move it to worker threads.
  • Sequential instead of parallel. A chain of await for independent operations wastes time — use Promise.all.
  • forEach with async inside. array.forEach(async ...) does not wait for the callbacks to finish. For sequential processing use a regular for...of with await; for parallel use Promise.all(array.map(...)).
  • Promise without error handling. Any Promise that can fail must have a .catch or be inside a try/catch.

In short

  • Node executes your code in a single thread, but I/O does not block that thread.
  • The event loop is a queue manager: it finishes synchronous code, then executes callbacks for completed operations.
  • Style evolution: callbacks (callback hell) → Promise (.then/.catch, chains) → async/await (reads like regular code).
  • Errors: in async/await use try/catch; with Promises use .catch; an unhandled rejection is a problem.
  • Run independent operations in parallel with Promise.all (or Promise.allSettled if you need all results).
  • Heavy computation on the main thread blocks the entire server — that is the main constraint of the single-threaded model.
  • JavaScript basics for TypeScript — variables, functions, and closures that underpin callbacks and Promises.
  • Modules and npm — how to import node:fs/promises and third-party async libraries.
  • Tooling — project setup, running, and debugging async code.