Module: JavaScript
JavaScript·081·8 MIN READ

081: Async/Await and Concurrency

TOPICS COVERED: Async/Await and Concurrency

Learning outcomes

By the end, you can:

  • explain what an async function returns;
  • use await to consume a Promise without claiming it blocks the whole program;
  • handle rejected Promises with try, catch, and finally;
  • choose sequential execution for dependent work and Promise.all concurrency for independent work;
  • return an async result to the caller and avoid floating Promises.

Retrieval warm-up

  1. What are the three Promise states?
  2. What determines the state of the new Promise returned by then?
  3. Why must a dependent Promise be returned from a then handler?

Vocabulary

  • Async function: Function declared with async that always returns a promise from its body result. — Source: MDN: async function
  • Await expression: Suspends the async function until its operand settles, resuming with value or thrown reason. — Source: MDN: await
  • Continuation: Portion of the async function resuming after await completes. — Source: MDN: await
  • Sequential: Awaiting steps one-by-one; total time is the sum of each step. — Source: MDN: Promise.all
  • Concurrent: Operations overlapping in time even though JS executes instructions serially. — Source: MDN: Promise.all
  • Parallel: True simultaneous execution across cores/workers, beyond single-threaded JS. — Source: MDN: Web Workers API
  • Fail-fast: Promise.all rejects immediately on the first member rejection. — Source: MDN: Promise.all
  • Continuation (official): "The continuation is the portion of an async function that resumes after an await." — Source: MDN: await — Continuation
  • Fail-fast (official): "Fail-fast means the first rejection or error stops further processing, as in Promise.all." — Source: MDN: Promise.all — Fail-fast

Mental model: readable Promise choreography

async and await are syntax built on Promises. They do not replace Promise states or error behavior.

js
async function answer() {
  return 42;
}

const result = answer();
console.log(result instanceof Promise); // true
result.then(console.log);               // later: 42

Calling an async function runs its body synchronously until it returns, throws, or reaches an await. At an await, JavaScript obtains a Promise for the expression. If pending, the function yields control to its caller. Other runnable work can continue. When the Promise settles, the function's continuation is scheduled; fulfillment produces the awaited value, while rejection throws at the await point.

Therefore, say "await pauses this async function" rather than "await pauses JavaScript". It does not make CPU-heavy code non-blocking. Code before the first await still runs synchronously, and resumed code still occupies the event loop while running.

Self-check: cover the lines after an await and predict what the caller receives when execution reaches that point. The answer is still the async function's Promise. Then uncover the continuation and predict which value appears if the awaited Promise fulfills and which path runs if it rejects.

Beginner self-study example: a two-step profile

The example is deterministic and needs no network.

js
function after(delay, value, shouldFail = false) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (shouldFail) {
        reject(new Error(`Could not load ${value}`));
      } else {
        resolve(value);
      }
    }, delay);
  });
}

async function showProfile() {
  console.log("Loading profile...");

  try {
    const user = await after(200, { id: 7, name: "Asha" });
    const message = await after(100, `Welcome, ${user.name}`);
    console.log(message);
    return user;
  } catch (error) {
    console.error(`Profile error: ${error.message}`);
    return null;
  } finally {
    console.log("Loading finished");
  }
}

showProfile().then((user) => {
  console.log(user ? `User ID: ${user.id}` : "No user");
});

console.log("Page remains available");

Step-by-step explanation

  1. showProfile() immediately returns a Promise to its caller.
  2. Its body first prints Loading profile... synchronously.
  3. after(...) starts a timer and returns a pending Promise. await pauses only showProfile and returns control.
  4. The script prints Page remains available.
  5. The first Promise fulfills. showProfile resumes with the user object.
  6. The second operation must use user.name, so starting it after the first is genuinely sequential.
  7. The function returns user; its returned Promise fulfills with that object.
  8. finally runs before the caller's then observes completion.

Expected output

text
Loading profile...
Page remains available
Welcome, Asha
Loading finished
User ID: 7

Set the first call to after(200, "user", true). The rejection behaves like a throw at await; control enters catch, then finally, and the returned null fulfills the async function's Promise. If the caller must see a rejection, omit return null and throw error after logging.

Error boundaries

Place try around the awaits whose failures you can meaningfully handle:

js
async function loadSettings() {
  try {
    return await readSettings();
  } catch (error) {
    console.error("Settings unavailable", error);
    return { theme: "system" };
  }
}

This intentionally recovers with fallback data. By contrast:

js
async function loadSettings() {
  try {
    return await readSettings();
  } catch (error) {
    console.error("Settings unavailable", error);
    throw error;
  }
}

This reports and preserves rejection. An async function with an uncaught throw returns a rejected Promise. The caller must await, return, or deliberately catch that Promise.

Intermediate example: sequential versus concurrent

Use sequential awaits when later work depends on earlier output:

js
async function dependentReport() {
  const user = await after(300, { id: 5, name: "Lee" });
  const orders = await after(300, [
    { userId: user.id, total: 18 },
    { userId: user.id, total: 12 },
  ]);
  return { user, orders };
}

The second request conceptually needs user.id; about 600 ms is expected here, though timers are not exact.

If operations are independent, start both and wire their error handling together immediately with Promise.all:

js
async function dashboard() {
  const started = performance.now();

  const [weather, notices] = await Promise.all([
    after(400, "Weather: sunny"),
    after(250, "Notices: 2"),
  ]);

  console.log(weather);
  console.log(notices);
  console.log(`About ${Math.round(performance.now() - started)} ms`);
}

dashboard().catch((error) => console.error(error.message));

Expected output is the two strings in array order and elapsed time around the slower 400 ms operation, not their 650 ms sum. Promise.all preserves input order regardless of completion order.

Avoid this accidental serialization:

js
const weather = await getWeather();
const notices = await getNotices();

Also avoid starting two Promises and awaiting them separately if either can reject before its await is connected. Promise.all([getWeather(), getNotices()]) attaches handling to both as they start.

Choosing a combinator

  • Promise.all: every result is required; reject if any fails.
  • Promise.allSettled: collect every success and failure.
  • Promise.any: use the first fulfillment; reject if all reject.
  • Promise.race: use the first settlement, fulfillment or rejection.

None cancels losing operations. Use an API's cancellation mechanism when cancellation matters.

Optional advanced stable example: limited batches

Starting thousands of requests together can overwhelm a browser or service. A simple stable compromise is batching:

js
async function processInBatches(items, batchSize) {
  const results = [];

  for (let index = 0; index < items.length; index += batchSize) {
    const batch = items.slice(index, index + batchSize);
    const values = await Promise.all(
      batch.map((item) => after(100, item.toUpperCase())),
    );
    results.push(...values);
  }

  return results;
}

processInBatches(["a", "b", "c", "d", "e"], 2).then(console.log);

Expected output after roughly three batches:

text
["A", "B", "C", "D", "E"]

Items in each batch overlap; batches are sequential. This is not a full task pool, but it clearly limits in-flight operations.

Common mistakes and debugging

  • Forgetting async: await is valid inside async functions and at top level in JavaScript modules, not ordinary classic-script top level.
  • Forgetting await: a variable contains a Promise rather than its value. Inspect it or use type tooling.
  • Forgetting to return the async call: function save() { saveAsync(); } floats work. Use return saveAsync() or make the boundary async and await it.
  • Using forEach with an async callback: forEach does not await callbacks. Use for...of for sequential work or Promise.all(items.map(async ...)) for concurrent work.
  • Serializing independent work: identify dependencies before placing awaits.
  • Catching too broadly: a giant try can hide programming errors and make recovery unclear.
  • Assuming Promise.all cancels: it rejects early, while other operations generally continue.

Set breakpoints after each await and inspect the Network panel in later fetch examples. Always terminate top-level calls with await, return, or a rejection handler.

Security and performance

Concurrency can improve elapsed time but increases resource usage. Bound fan-out, respect rate limits, and do not retry non-idempotent actions blindly. Never include passwords, API keys, or personal information in errors shown to users. A catch block should distinguish expected operational failures from bugs when possible. Async syntax does not protect shared UI state from stale results; cancellation or request identity checks may be needed when users trigger overlapping loads.

Exercises

Level 1: rewrite

Rewrite with async/await:

js
function getLabel() {
  return Promise.resolve("Ready").then((value) => value.toUpperCase());
}
js
async function getLabel() {
  const value = await Promise.resolve("Ready");
  return value.toUpperCase();
}

Calling either version returns a Promise fulfilled with READY.

Level 2: handle failure

Write loadName that awaits after(100, "name", true), returns "Guest" on failure, and always logs Complete.

js
async function loadName() {
  try {
    return await after(100, "name", true);
  } catch (error) {
    return "Guest";
  } finally {
    console.log("Complete");
  }
}

Level 3: remove accidental serialization

Make these independent operations concurrent:

js
const profile = await getProfile();
const messages = await getMessages();
return { profile, messages };
js
const [profile, messages] = await Promise.all([
  getProfile(),
  getMessages(),
]);
return { profile, messages };

Use this only if neither call requires the other's result.

Recap

Async functions always return Promises. await suspends one async function's continuation, not the whole runtime. Rejection throws at the await point, making focused try/catch/finally useful. Sequence dependent operations; start independent operations together with Promise.all; bound concurrency when fan-out is large.

Official references

Streams and backpressure

An async iterator is a convenient application-level stream, but a platform stream also exposes explicit flow control. A ReadableStream producer should not enqueue without regard for controller.desiredSize; a consumer should use pipeTo/pipeThrough or getReader() and release the reader in cleanup.

js
const source = new ReadableStream({
  start(controller) {
    controller.enqueue("one\n");
    controller.enqueue("two\n");
    controller.close();
  },
});

const lines = [];
for await (const chunk of source) lines.push(chunk.trim());
console.assert(lines.join(",") === "one,two");

For a real producer, pause or stop production when desiredSize <= 0, resume from pull(), and implement cancel(reason) to release sockets, timers, or file handles. pipeTo propagates errors and cancellation according to its options; a manually written while (true) loop must reproduce those guarantees itself. Backpressure limits queued data, but it does not limit total data, validate content, or cancel an upstream server that ignores the signal.

Test a slow writable sink, an error in the transform, consumer cancellation, and a producer that attempts to enqueue after close. Assert that cleanup runs once and that memory does not grow with an unbounded source. Interview question: why is await stream.getReader().read() not equivalent to buffering the entire response? It requests one chunk at a time, allowing the consumer's pace to control the producer.

Retries, backoff, and bounded concurrency

Retry only transient failures and only when repeating the operation is safe. A network timeout after a POST does not prove the server did nothing; retrying it can create a duplicate. Prefer idempotency keys or server-supported conditional requests for uncertain writes.

js
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function retry(operation, {
  attempts = 3,
  baseDelay = 100,
  shouldRetry = () => true,
} = {}) {
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    try {
      return await operation(attempt);
    } catch (error) {
      if (attempt === attempts || !shouldRetry(error, attempt)) throw error;
      const jitter = Math.random() * baseDelay;
      await sleep(baseDelay * 2 ** (attempt - 1) + jitter);
    }
  }
}

let failures = 0;
retry(() => {
  if (failures++ < 2) throw new Error("temporary");
  return "success";
}, { attempts: 3, baseDelay: 10 }).then(console.log);

Exponential backoff reduces synchronized retry storms; jitter prevents many clients from retrying on the same schedule. Production code should honor Retry-After, cap the maximum delay, stop on cancellation, and classify status codes rather than retry every exception.

For many independent items, a small worker pool bounds in-flight work without serializing everything:

js
async function mapConcurrent(items, limit, worker) {
  if (!Number.isInteger(limit) || limit < 1) throw new RangeError("limit must be positive");
  const results = new Array(items.length);
  let next = 0;

  async function consume() {
    while (true) {
      const index = next++;
      if (index >= items.length) return;
      results[index] = await worker(items[index], index);
    }
  }

  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, consume));
  return results;
}

mapConcurrent([1, 2, 3, 4], 2, (value) => sleep(10).then(() => value * 2))
  .then((values) => console.assert(values.join(",") === "2,4,6,8"));

The output order is input order even if completion order differs. A rejected worker rejects the pool, but already-started workers generally continue; add an AbortSignal if coordinated cancellation is required.

Interview questions

  1. Why is await in a loop sometimes correct? It preserves dependency order and limits concurrency when the next operation needs the previous result.
  2. Why is Promise.all(items.map(work)) dangerous for 100,000 items? It starts all work and may exhaust sockets, memory, rate limits, or service capacity.
  3. Which failures should be retried? Bounded, classified transient failures such as selected 408, 429, or 503, subject to method safety and server policy.
  4. What test proves the pool is bounded? Track active++ on start and active-- in finally; assert the maximum never exceeds the chosen limit.
js
let active = 0;
let maximum = 0;
await mapConcurrent([1, 2, 3, 4, 5], 2, async (value) => {
  active += 1;
  maximum = Math.max(maximum, active);
  try {
    await sleep(1);
    return value;
  } finally {
    active -= 1;
  }
});
console.assert(maximum <= 2);