Skip to content

Async JavaScript

Scope: promises, async/await, and the event loop — the mental model everything in Node is built on. All examples were run with Node v24.

Why async exists

Reading a file or querying a database takes time — milliseconds to whole seconds. If Node waited doing nothing, one slow request would stall the entire server. So slow operations return immediately with an IOU, and Node gets on with other work until the result arrives. That IOU is a promise.

Promises

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

const promise = readFile("hello.mjs", "utf8");
console.log("started reading...");

const text = await promise;
console.log("got", text.length, "characters");

A promise is in one of three states: pending (waiting), fulfilled (got a value), or rejected (failed with an error). await pauses your function until it settles — the rest of Node keeps running meanwhile.

async/await: the modern way

Mark a function async to use await inside it:

async function readConfig(path) {
  const text = await readFile(path, "utf8");
  return JSON.parse(text);
}

const config = await readConfig("config.json");

An async function always returns a promise — even if you return a plain value, callers get a promise of that value. So await it (or .then() it) at the call site.

Doing things in parallel

Awaits run one after another by default:

const a = await readFile("a.txt", "utf8");  // waits...
const b = await readFile("b.txt", "utf8");  // ...then waits

For independent work, start both, then await both:

const pa = readFile("a.txt", "utf8");  // starts now
const pb = readFile("b.txt", "utf8");  // starts now too
const [a, b] = await Promise.all([pa, pb]);  // waits for both

Promise.all fails fast: if any promise rejects, the whole thing rejects.

.then()/.catch(): the older style

readFile("hello.mjs", "utf8")
  .then((text) => console.log("got", text.length))
  .catch((err) => console.error("failed:", err.message));

You'll read this in older code. Prefer async/await in new code — it reads top-to-bottom instead of nesting sideways.

The event loop in one paragraph

Node runs your JavaScript on one thread. When you await something slow, Node parks your function and runs other code. When the slow thing finishes, your function resumes where it left off. Consequences:

  • Slow I/O (files, network) is cheap — thousands can overlap.
  • Slow computation (a giant loop) blocks everything, because there's no await inside it for Node to park on. Keep heavy math out of the main thread.

Timers fit the same model:

await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("one second later");

The await-in-loops trap

// SLOW: each iteration waits for the last
for (const f of files) {
  await processFile(f);
}

// FAST: all start together
await Promise.all(files.map((f) => processFile(f)));

If the iterations are independent, Promise.all + map is the idiom. (If order or rate-limiting matters, the slow loop is correct — know which you need.)

Check

  • [ ] You can explain pending/fulfilled/rejected in one sentence
  • [ ] You know an async function always returns a promise
  • [ ] You can parallelize independent awaits with Promise.all
  • [ ] You can say what blocks the event loop and what doesn't

Next: Modules and npm.