Async & concurrency

Status: đŸŸ© COMPLETE Last updated: 2026-06-19 Plain-English tagline: How code can “wait for something slow” (a database query, an API call, a file read) without freezing the whole program. The mechanism behind every await, Promise, then(), and setTimeout you’ll ever see in JavaScript.


In plain English

A computer can do MANY things, but one CPU core can only do ONE thing at any single moment. Yet your laptop runs a browser, a music player, a video call, and a code editor all “at the same time.” How?

Two answers:

  1. Multiple cores — your laptop has 8+ CPU cores, each independently doing work
  2. Switching very fast — even with one core, the OS switches between tasks thousands of times per second, giving the ILLUSION of simultaneity

For JavaScript specifically, there’s a third answer that matters more: asynchrony. JavaScript runs on a SINGLE THREAD (one core, one task at a time). It can’t be “doing many things at once” within the language. But it CAN say “start this slow operation, and when it’s done, come back to me.”

That’s what async is. When your code says:

const data = await fetch("/api/posts");


JavaScript:

  1. Initiates the network request
  2. Doesn’t wait there. Lets the network do its thing
  3. Goes off and does other work (handles UI events, runs timers, etc.)
  4. When the network response arrives, JavaScript comes back and picks up where it left off
  5. data now has the response; the next line runs

The keywords async, await, Promise, then(), and setTimeout are all about THIS pattern. They’re how single-threaded JavaScript handles slow operations without freezing.

This entry is the CS-side view of why async exists and what’s happening underneath. For specific JS syntax, see JavaScript. For Node-specific event loop details, see Node.js (concept).


Why it matters

Three reasons even a non-coder benefits from understanding async:

  1. Almost every line of modern JS is async. Loading data, sending data, reading files, calling APIs, running database queries — all async. Reading code or AI-generated code requires knowing what await actually means.

  2. The most common bug pattern in modern code is mishandled async. Forgetting await, mixing then() and await, race conditions, unhandled promise rejections — these aren’t language quirks, they’re consequences of single-threaded async.

  3. Performance discussions hinge on it. “Why is this slow?” often comes down to “this is doing one slow thing at a time when it could be doing them in parallel” — a fundamentally async insight.

The trade-off: async has a learning curve. Understanding the mental model takes effort. After that, modern code reads naturally.


The two big ideas

Idea 1: Concurrency vs. parallelism

These get mixed up constantly. The clean distinction:

  • Concurrency — managing MANY tasks IN PROGRESS, even if only one runs at a moment. Like a chef juggling 5 dishes — chopping while pasta boils, stirring while bread rises. Single brain, many tasks “active.”
  • Parallelism — actually DOING multiple things at the SAME instant. Like 5 chefs, each cooking one dish. Multiple brains.

JavaScript is heavily CONCURRENT (one thread juggling many in-progress operations) but rarely PARALLEL (would need Web Workers or worker threads — separate JS contexts).

Idea 2: Blocking vs. non-blocking

When code is “waiting” for something:

  • Blocking — the whole program stops while waiting. CPU idle. Nothing else can happen.
  • Non-blocking — the program registers “wake me when this finishes” and goes off to do other work. CPU keeps busy.

JavaScript’s I/O (network, files, timers) is fundamentally non-blocking. Heavy CPU work (calculations, JSON parsing of huge data) is blocking — it monopolizes the thread until done.


The event loop — JavaScript’s secret

The mechanism behind JS’s async is called the event loop. The model:

1. JavaScript runs your code top-to-bottom.
2. When it hits an async operation (fetch, setTimeout, file read):
   - It starts the operation
   - Hands off the slow work to the runtime (browser or Node.js)
   - Continues running other code
3. When the slow work finishes, the runtime puts the result + callback into a QUEUE.
4. When JavaScript finishes the current synchronous code, the event loop:
   - Picks the next callback from the queue
   - Runs it
   - Repeats

This loop runs continuously. The single thread is never idle when there’s work to do; it just doesn’t BLOCK while waiting for slow operations.

Mental picture:

Main thread:    [code] [callback] [callback] [code] [callback] ...
                       ↑
                       picked from queue when main was free

Queue:          [fetch result] [timer fired] [file read done]
                       ↑
                       runtime added these

Some operations have priority (microtasks like Promise callbacks always run before macrotasks like setTimeout) but that’s the broad picture.


The three async syntaxes in JavaScript (in order of evolution)

1. Callbacks (oldest, still around)

Pass a function to be called when the operation completes:

fetchUser(123, (err, user) => {
  if (err) return console.error(err);
  console.log(user);
});

The problem: “callback hell” when chaining many async operations.

fetchUser(123, (err, user) => {
  fetchPosts(user.id, (err, posts) => {
    fetchComments(posts[0].id, (err, comments) => {
      // ↑ nested forever
    });
  });
});

2. Promises (mid-2010s)

A Promise is an object representing a future value:

fetchUser(123)
  .then(user => fetchPosts(user.id))
  .then(posts => fetchComments(posts[0].id))
  .then(comments => console.log(comments))
  .catch(err => console.error(err));

Flatter than callbacks but still chains forever for complex flows.

3. async/await (current standard)

Built on Promises but reads like synchronous code:

try {
  const user = await fetchUser(123);
  const posts = await fetchPosts(user.id);
  const comments = await fetchComments(posts[0].id);
  console.log(comments);
} catch (err) {
  console.error(err);
}

This is what modern code looks like. The await keyword pauses execution of THIS function until the promise resolves; the rest of the program keeps running.


Parallel vs. sequential awaits

A common mistake: sequential when parallel would work.

Sequential (slow):

const user = await fetchUser(id);       // ~100ms
const posts = await fetchPosts(id);     // ~100ms (doesn't depend on user)
const comments = await fetchComments(id); // ~100ms (doesn't depend on posts)
// Total: ~300ms

Each await blocks the function until done.

Parallel (fast):

const [user, posts, comments] = await Promise.all([
  fetchUser(id),
  fetchPosts(id),
  fetchComments(id),
]);
// All three start immediately. Total: ~100ms (the slowest)

Promise.all waits for ALL promises to resolve before continuing. If any rejects, the whole thing rejects.

Use sequential when one operation needs the OUTPUT of another. Use parallel when they’re independent.


A concrete example

Say you want to display a user’s posts AND their friends list:

// Slow — sequential
async function loadUserDashboard(userId) {
  const posts = await fetch(`/api/users/${userId}/posts`).then(r => r.json());
  const friends = await fetch(`/api/users/${userId}/friends`).then(r => r.json());
  return { posts, friends };
}
 
// Fast — parallel
async function loadUserDashboard(userId) {
  const [posts, friends] = await Promise.all([
    fetch(`/api/users/${userId}/posts`).then(r => r.json()),
    fetch(`/api/users/${userId}/friends`).then(r => r.json()),
  ]);
  return { posts, friends };
}

If each fetch takes 200ms, the first version takes 400ms total. The second takes 200ms. Same code, twice as fast.

This is the #1 performance optimization in modern frontend/backend code.


When async goes wrong

A few classic failure modes:

Forgetting await

async function saveUser(user) {
  db.users.create(user);   // ← forgot await
  return { ok: true };       // returns before save finishes
}

The function returns success before the save completes. If the save fails later, you’ve already told the caller it succeeded. The user sees “saved” but the data isn’t actually saved.

Unhandled promise rejection

fetchUser(123)
  .then(user => console.log(user));
  // ← no .catch(); if fetch fails, the error vanishes silently

Modern Node.js terminates the process on unhandled rejections (good); browsers log a console error (better than nothing). Always .catch() or wrap in try/catch.

Race conditions

async function refreshUserData() {
  const user = await fetch(`/api/me`);
  setUser(user);  // ← what if refreshUserData() got called twice in quick succession?
}

If the user clicks “refresh” twice and the SECOND request finishes FIRST, the older response overwrites the newer. Solution: track a “request ID” and ignore stale responses.

Sequential when parallel works

The example above. Most common in code that grew organically without considering parallelism.


Generators and async iterators (advanced)

For truly stream-like operations (LLM token-by-token output, paginated APIs):

async function* paginatedUsers() {
  let page = 1;
  while (true) {
    const res = await fetch(`/api/users?page=${page}`);
    const data = await res.json();
    if (data.length === 0) return;
    for (const user of data) yield user;
    page++;
  }
}
 
for await (const user of paginatedUsers()) {
  console.log(user.name);
}

The async function* returns an async iterator; for await...of consumes it. Lazy — only fetches the next page when consumed. Very useful for streaming.


Web Workers — actual parallelism in JavaScript

For CPU-heavy work that DOES need to run in parallel, JavaScript has Web Workers (browser) and Worker Threads (Node). Separate JS contexts, separate threads, communicate via message passing.

// main.js
const worker = new Worker("./heavy-task.js");
worker.postMessage({ input: ... });
worker.onmessage = (e) => console.log("Result:", e.data);

Workers can’t access the DOM directly; they’re for CPU work, not UI. Used for image processing, big calculations, cryptography, ML inference in the browser.

For typical webapp work, you almost never need workers — Node.js async on the server + React’s reconciler in the browser handle everything you’d want.


Common gotchas

  • await only works inside async functions. Top-level await is a recent addition (Node 14.8+, ES modules). In CommonJS or older code, you need to wrap in an async function and call it.

  • async function always returns a Promise. Even if the function body returns 42, the caller gets Promise.resolve(42). To use the value, await it.

  • Forgetting await is silently broken. TypeScript can warn (@typescript-eslint/no-floating-promises). Without that rule, a missing await results in code that “works” inconsistently.

  • forEach doesn’t await. array.forEach(async x => await doThing(x)) doesn’t wait for anything. Use for...of + await, or Promise.all(array.map(...)).

  • map returns a Promise array. array.map(async x => x * 2) returns Promise<number>[]. Need await Promise.all(...) to unwrap.

  • Promise.all rejects if ANY promise rejects. If you want all results regardless of failures, use Promise.allSettled — returns an array of {status, value} or {status, reason} objects.

  • The microtask queue runs before the macrotask queue. Promise.resolve().then(...) runs BEFORE setTimeout(..., 0). Surprising; rarely matters but can in tight loops.

  • setTimeout(fn, 0) doesn’t run immediately. It runs after the current code finishes (and after pending microtasks). The “0” is a minimum, not exact.

  • Async functions still run synchronous code synchronously. async function foo() { console.log("a"); console.log("b"); } logs “a” then “b” immediately. Only await causes a pause.

  • Throwing inside async is fine. Catch with try/catch or .catch(). Like a sync throw at the surface, just delivered through the promise.

  • Resource leaks from forgotten await. A connection opened, work started, then the function returns. The work continues invisibly, with no one to handle errors. Always await async work you initiate.

  • Long synchronous loops block the event loop. A for loop doing 1M iterations stops EVERYTHING — UI freezes, other callbacks queue up. Break into chunks, use Web Workers, or yield with await new Promise(r => setTimeout(r, 0)).

  • Promise.race returns the FIRST to settle. Useful for timeouts (Promise.race([fetch(), timeout(5000)])). Other promises continue but their results are ignored.

  • async/await is sugar over .then(). Same underlying mechanism. Mixing them in one codebase is legal; usually unnecessary. Pick one style.

  • Cancellation isn’t built into Promises. Once started, you can’t “cancel” a Promise. Use AbortController + AbortSignal for cancellable fetches and operations.

  • fetch without a timeout can hang forever. Combine with AbortSignal.timeout(ms) (modern) or Promise.race with a timeout promise.

  • Streams and async iterators are different. Node Readable streams aren’t directly for await-able without a small adapter (or use Readable.from(...)).

  • React’s useEffect doesn’t accept async functions directly. useEffect(async () => {}) returns a Promise, not a cleanup function. Wrap: useEffect(() => { const run = async () => {}; run(); }, []).

  • Promise.allSettled is the safer Promise.all. When some failures are expected and shouldn’t kill the whole batch, allSettled gives you per-item status.

  • for await...of consumes streams sequentially. If you need parallel processing of a stream, you’d use a worker pool, not for await.

  • Multiple awaits in sequence on independent operations is almost always a bug. Code review red flag.

  • Race conditions are notoriously hard to debug. They manifest as “sometimes it works.” Often: the same user-facing action triggered twice, with stale data overwriting fresh.

  • AI-generated code often does sequential awaits when parallel works. Watch for this; rewrite with Promise.all when independent operations are awaited in sequence.

  • Async error handling is its own skill. Errors in async code have stack traces that may not include the calling code. Modern Node + DevTools have improved this, but a thrown error inside a forgotten then may “disappear.”

  • JavaScript’s single thread is sometimes a benefit. No shared mutable state, no locks, no race conditions WITHIN a function. The downsides (heavy CPU work blocks) are usually solvable.


When async matters most

  • API endpoints / server functions — every request handler is async (database, external APIs)
  • React components fetching data — useEffect with fetch, server components with await
  • LLM responses — streaming tokens as they arrive, async iterators
  • Background jobs — queues, retries, delayed execution
  • WebSocket handlers — events arriving asynchronously
  • Form submissions — uploading files, waiting for confirmation

In practice, you’ll hit async on every meaningful piece of code. Understanding the mental model pays back constantly.


See also


Sources