Skip to main content
PProfileMatch

JavaScript Promises, async/await and fetch Explained

ProgrammingLesson 4 of 45 min read

What you'll learn

  • Explain why JavaScript uses callbacks and promises and predict the order of async output
  • Write async functions with await and handle errors with try/catch
  • Run requests in parallel with Promise.all and Promise.allSettled
  • Use fetch correctly, including checking response.ok and parsing JSON

On this page

  1. One thread, many waiting jobs
  2. async and await in a working example
  3. Sequential, parallel and racing
  4. Common mistakes and the errors they produce
  5. How this shows up in real projects and interviews

Run these four lines and the output order surprises most beginners: the script logs "1 start", then "2 end of script", then "3 promise callback", and only then "4 timeout 0", even though the timer was set to zero milliseconds before the promise was created. If you can explain that order, you understand the event loop, and everything else in asynchronous JavaScript follows from it.

order.js
console.log("1 start");
setTimeout(() => console.log("4 timeout 0"), 0);
Promise.resolve().then(() => console.log("3 promise callback"));
console.log("2 end of script");

Output

1 start
2 end of script
3 promise callback
4 timeout 0

One thread, many waiting jobs

JavaScript runs your code on a single thread: one thing at a time, start to finish. Slow operations such as network requests and timers are handed to the environment (the browser or Node), which keeps working while your code carries on. When one finishes, its callback is placed in a queue, and the event loop runs queued callbacks only when the current code has completely finished. Promise callbacks go into a higher-priority queue (the microtask queue) that is emptied before the next timer, which is why line 3 beats line 4 above. The practical rule is simple: synchronous code always finishes first, and async results arrive later.

A promise is an object that stands for a value that is not ready yet. It is in one of three states: pending, fulfilled with a value, or rejected with an error. You attach handlers with .then(onValue) and .catch(onError), and each .then returns a new promise, so steps chain. Before promises, the same flow meant nesting callbacks inside callbacks; async/await is syntax that lets you write the chain as if it were ordinary sequential code.

async and await in a working example

An async function always returns a promise, and inside it await pauses that function (not the whole program) until a promise settles, then gives you its value or throws its error. The example below is a complete program: a tiny parcel-status server and a client that calls it with fetch, in one file so you can run it with nothing installed. Save it as tracker.mjs; the .mjs extension lets Node treat it as an ES module, and global fetch is built into current Node versions.

tracker.mjs
import http from "node:http";

const parcels = {
  "P-100": { status: "out for delivery", etaMinutes: 25 },
  "P-200": { status: "at depot", etaMinutes: 240 },
};

const server = http.createServer((req, res) => {
  const id = req.url.split("/").pop();
  const parcel = parcels[id];
  res.setHeader("Content-Type", "application/json");
  if (!parcel) {
    res.statusCode = 404;
    res.end(JSON.stringify({ error: "unknown parcel" }));
    return;
  }
  setTimeout(() => res.end(JSON.stringify({ id, ...parcel })), 50);   // pretend latency
});

async function getParcel(base, id) {
  const res = await fetch(`${base}/parcels/${id}`);
  if (!res.ok) {
    throw new Error(`Parcel ${id}: HTTP ${res.status}`);   // fetch does NOT reject on 404
  }
  return res.json();
}

server.listen(0, async () => {
  const base = `http://127.0.0.1:${server.address().port}`;
  try {
    const one = await getParcel(base, "P-100");
    console.log(one);

    const [a, b] = await Promise.all([getParcel(base, "P-100"), getParcel(base, "P-200")]);
    console.log(a.status, "|", b.status);

    const results = await Promise.allSettled([getParcel(base, "P-200"), getParcel(base, "P-999")]);
    console.log(results.map((r) => r.status));
    console.log(results[1].reason.message);

    await getParcel(base, "P-404");
  } catch (err) {
    console.log("caught:", err.message);
  } finally {
    server.close();
    console.log("server closed");
  }
});

Output

{ id: 'P-100', status: 'out for delivery', etaMinutes: 25 }
out for delivery | at depot
[ 'fulfilled', 'rejected' ]
Parcel P-999: HTTP 404
caught: Parcel P-404: HTTP 404
server closed

Study the key lines. fetch returns a promise that resolves to a response as soon as the headers arrive, and it only rejects on a network failure. A 404 or 500 is still a successful fetch, so you must check res.ok yourself, as getParcel does. res.json() is also asynchronous, which is why it is returned (or awaited) rather than used directly. In the browser the same getParcel works unchanged with a real URL; only the server half would be different.

Sequential, parallel and racing

Awaiting one call after another runs them one at a time. If the calls do not depend on each other, start them together and await the combined promise. Promise.all resolves with all results in order but rejects as soon as any input rejects. Promise.allSettled waits for everything and reports each outcome, which suits "load as many dashboard panels as possible". Promise.race settles with whichever input settles first, a handy way to build a timeout.

timing.mjs
const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));

function withTimeout(promise, ms) {
  const timer = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`gave up after ${ms} ms`)), ms)
  );
  return Promise.race([promise, timer]);
}

let t0 = Date.now();
await wait(100, "a");
await wait(100, "b");
console.log("sequential took at least 200 ms:", Date.now() - t0 >= 195);

t0 = Date.now();
await Promise.all([wait(100, "a"), wait(100, "b")]);
console.log("parallel took under 190 ms:", Date.now() - t0 < 190);

try {
  await withTimeout(wait(300, "slow"), 50);
} catch (err) {
  console.log(err.message);
}
console.log(await withTimeout(wait(10, "fast"), 50));

const lines = [];
[1, 2].forEach(async (n) => {
  await wait(10);
  lines.push(n);
});
console.log("right after forEach:", lines.length);
await wait(30);
console.log("later:", lines);

async function five() { return 5; }
console.log(five());          // forgot await
console.log(await five());

Output

sequential took at least 200 ms: true
parallel took under 190 ms: true
gave up after 50 ms
fast
right after forEach: 0
later: [ 1, 2 ]
Promise { 5 }
5

The last two examples are the classic slips. forEach ignores the promises its async callback returns, so nothing waits and the array is still empty on the next line. Use for...of with await for sequential work, or await Promise.all(items.map(async (item) => ...)) for parallel work. And calling an async function without await gives you a pending Promise object, not its value.

Common mistakes and the errors they produce

  • Assuming `fetch` rejects on HTTP errors. A 404 response is a normal resolution. Check res.ok and throw yourself.
  • Forgetting `await`. You print Promise { <pending> } or later hit TypeError: Cannot read properties of undefined because you used the promise as if it were the data.
  • Using `await` outside an async function. In a classic script this is SyntaxError: await is only valid in async functions and the top level bodies of modules. Wrap the code in an async function or run it as a module.
  • An unhandled rejection. A rejected promise with no catch crashes modern Node with the error printed and exit code 1, and in a browser it logs Uncaught (in promise). Add try/catch around awaits.
  • Awaiting independent calls one by one. Three 200 ms calls take 600 ms instead of about 200 ms. Use Promise.all.
  • Mixing `.then` and `await` carelessly. Forgetting to return the promise inside a .then makes the next step run early with undefined.
  • Parsing the body twice. Calling res.json() and then res.text() throws a TypeError saying the body has already been read (the wording differs between browsers and Node). Read it once, or call res.clone() first.

How this shows up in real projects and interviews

Nearly every application is an orchestration of async calls: authenticate, fetch the profile, fetch related data in parallel, render. Good teams care about error paths (what the user sees when the call fails), timeouts, and not firing sequential requests that could be parallel. Interviews ask you to predict output ordering like the first example, to implement a sleep or retry helper, and to explain Promise.all versus allSettled. In React you will meet these ideas again in useEffect and data fetching, and the server side of this lesson is the starting point for the Node.js course. For typed versions of these functions, continue to TypeScript.

Try it yourself

Add a retry(fn, attempts, delayMs) helper to tracker.mjs that calls an async function, and if it throws, waits delayMs and tries again up to attempts times before rethrowing the last error. Use it so getParcel(base, "P-999") is tried three times, and count how many times the server received that request.

Show a hint

Write a loop with for (let i = 1; i <= attempts; i++), try { return await fn(); } catch (err) { if (i === attempts) throw err; await wait(delayMs); }. Count requests with a let hits = 0 incremented inside the server callback.

Key takeaways

  • Synchronous code finishes first, then promise callbacks (microtasks), then timers and other queued tasks.
  • async functions return promises; await pauses only that function and throws if the promise rejects.
  • fetch rejects only on network failure; always check response.ok and then call response.json().
  • Use Promise.all for independent work, allSettled when partial results are acceptable, race for timeouts.
  • forEach does not wait for async callbacks; use for...of or Promise.all(map(...)) instead.

Test yourself

A trick from this lesson's ride: Code now, promises next, timers later Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is the difference between a promise and async/await?

A promise is the object that represents a future value; async/await is syntax built on top of promises. An async function returns a promise, and await lets you read the resolved value in ordinary-looking code instead of chaining .then calls. Both handle the same underlying promises.

Why does fetch not throw an error on 404 in JavaScript?

fetch treats any HTTP response, including 404 and 500, as a successful network exchange, and only rejects when the request could not complete at all, such as a network failure. Check response.ok or response.status and throw your own error when it is false.

How do I run multiple async calls at the same time in JavaScript?

Start all the calls first, then await them together with Promise.all([a(), b()]), which returns the results in order. If you want results even when some fail, use Promise.allSettled. Awaiting each call on its own line runs them one after another.