JavaScript

Asynchronous JavaScript: promises, async/await and fetch without the mystery

Why JavaScript needs asynchronous code, what a promise actually is, how async/await maps onto it, and a fetch() helper that handles the three ways a request can fail.

By the TechTutorZone editorial team · Published · 6 min read (1,201 words)

JavaScript runs on one thread. If a network request blocked that thread for 300 milliseconds, the page would freeze for 300 milliseconds: no scrolling, no clicks, no animation. So anything slow is asynchronous: you start it, hand over a function to run when it finishes, and carry on. Promises and async/await are the syntax that makes this bearable. This tutorial explains the model, then uses it for the thing you will do most, fetching JSON from a server, with error handling that actually covers the failure cases.

The event loop in one paragraph

The engine runs your code to completion, then looks at a queue of pending callbacks (a timer fired, a response arrived, the user clicked) and runs the next one, and so on forever. A callback never interrupts running code; it waits its turn. That is why setTimeout(fn, 0) runs after the current code finishes, and why a while loop that waits for a flag to change will never see it change: the code that would change it is stuck in the queue behind the loop.

console.log('one');
setTimeout(() => console.log('three'), 0);
console.log('two');
// one, two, three

What a promise is

A promise is an object that represents a value that is not there yet. It is in one of three states: pending, then either fulfilled with a value or rejected with a reason (usually an Error). Once settled it never changes. You attach handlers with .then() and .catch(), and each of those returns a new promise, which is what allows chaining:

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

wait(500)
  .then(() => { console.log('half a second'); return 42; })
  .then(value => console.log(value))        // 42
  .catch(err => console.error(err))
  .finally(() => console.log('done either way'));

Two rules of chaining: a value returned from a then handler becomes the next promise's value; a promise returned from a handler is waited for and its result flows on. An exception thrown in any handler rejects the chain, and the nearest catch below it receives it. Promises you never catch produce an "unhandled rejection" warning in the console, and in Node.js a crash.

async and await

async/await is syntax over the same promises. An async function always returns a promise. Inside it, await pauses the function (not the thread; other code runs) until a promise settles, then gives you its value or throws its rejection. The chain above becomes ordinary-looking code:

async function demo() {
  try {
    await wait(500);
    console.log('half a second');
    const value = 42;
    console.log(value);
  } catch (err) {
    console.error(err);
  } finally {
    console.log('done either way');
  }
}
demo();

try/catch works because await turns a rejection into a thrown exception. That is the whole reason to prefer async/await: error handling, loops and conditionals use the normal keywords instead of callback nesting.

fetch, and the three ways it fails

fetch(url) returns a promise for a Response. Reading the body is a second asynchronous step: response.json() returns another promise. Most tutorials show:

const data = await (await fetch('/api/tasks')).json();

and that line hides three distinct failures:

  1. The request never completed. No network, DNS failure, CORS blocked, the user navigated away. fetch rejects with a TypeError. This is the only case where the promise rejects.
  2. The server answered with an error status. 404, 500, 403. fetch fulfils: a response arrived. response.ok is false (it is true only for 200 to 299). Code that does not check it will try to parse an HTML error page as JSON.
  3. The body is not what you expected. response.json() rejects with a SyntaxError if the body is not valid JSON, for example an empty body from a 204 or a proxy's HTML interstitial.

A helper that handles all three, and that every project ends up writing:

async function getJSON(url, { timeoutMs = 10000, ...options } = {}) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const response = await fetch(url, { ...options, signal: controller.signal });
    if (!response.ok) {
      throw new Error(`${response.status} ${response.statusText} for ${url}`);
    }
    return await response.json();
  } finally {
    clearTimeout(timer);
  }
}

// use
try {
  const tasks = await getJSON('/api/tasks');
  render(tasks);
} catch (err) {
  if (err.name === 'AbortError') showMessage('The server took too long.');
  else showMessage(`Could not load tasks: ${err.message}`);
}

Notes on the helper. AbortController is how you cancel a fetch; without a timeout a request on a bad connection can hang for minutes. The return await inside the try is deliberate: with a plain return response.json(), a parse failure would happen after the function had already left the try block. finally clears the timer whether the request succeeded or not. Sending JSON is the same shape with a body:

await getJSON('/api/tasks', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ text: 'Buy milk' }),
});

Doing several things at once

Awaiting in sequence when the requests do not depend on each other wastes time. Start them all, then wait:

// slow: 3 round trips one after another
const a = await getJSON('/api/a');
const b = await getJSON('/api/b');

// fast: 1 round trip's worth of waiting
const [a, b] = await Promise.all([getJSON('/api/a'), getJSON('/api/b')]);

// tolerate failures individually
const results = await Promise.allSettled([getJSON('/api/a'), getJSON('/api/b')]);
for (const r of results) {
  if (r.status === 'fulfilled') use(r.value); else console.warn(r.reason);
}

Promise.all rejects as soon as any input rejects; allSettled waits for all and reports each. Promise.race settles with the first to settle, useful for timeouts if you cannot use AbortController. A for loop with await inside runs sequentially, which is sometimes what you want (rate limits) and sometimes a bug; array.forEach(async ...) does not wait at all, so use for...of or Promise.all(array.map(...)).

Pitfalls

  • Forgetting await. const data = getJSON(url) gives you a promise object, and data.length is undefined. Editors can warn about unused promises; turn that on.
  • Not checking response.ok. Failure 2 above. Everyone does it once.
  • Catching too broadly. catch (e) {} that swallows everything hides bugs. At least log; ideally show the user something.
  • Race conditions in the UI. The user types "a", then "ab"; the "a" response arrives last and overwrites the "ab" results. Keep a request counter or abort the previous request when a new one starts.
  • Top-level await works in ES modules (<script type="module">) and in the console, but not in classic scripts; wrap in an async function there.

Exercise

Point getJSON at a public JSON address such as https://api.github.com/repos/nodejs/node and show the repository's description and star count on a page. Then break it on purpose, three ways: a wrong hostname, a URL that returns 404 (/repos/nodejs/does-not-exist), and a URL that returns HTML (https://github.com). Confirm each produces a different, readable message. Finally, set the timeout to 1ms and confirm the abort path works.

Summary

  • One thread, a queue of callbacks; slow things are started and resumed later.
  • A promise is a settled-once container; await unwraps it and turns rejection into a throw.
  • fetch rejects only for network failure; check response.ok, and expect .json() to fail.
  • Run independent requests with Promise.all; add a timeout with AbortController.

Next: when it still does not work, debug it properly.