Mehdi Tmimi JavaScript

JavaScript Lab · Asynchrony

One dashboard, four ways to wait

The lab server takes 2 seconds to answer each call. You load a student dashboard from it four ways: synchronous XHR, callbacks, promises, then async/await. Each one fixes what hurt in the one before.

The same four calls, two ways to wait: in line, or side by side.

The starting point

The page is already built: two cards, a clock and a text field. Together, we only write the calls. Each of you gets your own invented student, with invented courses and grades: nothing to set up.

  • The clock is JavaScript on purpose: a CSS animation could keep turning when JavaScript cannot. Watch it during every load: after each one, the line beside the button says how long it took, and whether the page froze.
  • The text field: type in it while the dashboard loads.
  • Ready-made, in the HTML tab (index.html): showAnnouncements(list), showStudent(me), showCourse(course), showGrade(grade), showError(card, message) (card: 'announcements' or 'grade'), and start(), which empties the cards and starts the stopwatch.
  • Your part, in the JS tab (script.js): loadAnnouncements(), loadGrade() and the Load button. API, on line 2, is your own address.
  • A typo or a mistake? The line next to the button turns red. An error your catch handles shows in its card; one nobody catches says “Uncaught error”, and the playground’s Console says where: an “HTML line” is the ready-made code; your mistake is at the first plain “line”, in the JS tab.
How the playground behaves (read when stuck)
  • “Loading…” stays until both cards are filled or show an error: a card your code does not write yet keeps it there, and 2 s after the last answer the line says so. Run, at the top, restarts the page; the calls start only when you press Load the dashboard. Auto-run restarts the page at every change in your code: press Load again after you type. To type a whole function, turn Auto-run off, then press Run (Ctrl+Enter).
  • A solution opens in a new tab, for reading: the code you saved stays as it is. To go on from the solution, press “Keep this code here” in that tab: it replaces your saved code. Then close your old tab (if it says “Changed in another tab”, press “Load that version” there).
  • The editor closes brackets and quotes for you: when you reach a ) or } it already wrote, press End or →, do not type it again. Click in the code first: Shift+Alt+F puts the indentation right, Ctrl+Z undoes.
  • Your code shows as text in the preview, or the Console says “Cannot set properties of null (setting 'onclick')”? Your code went into the HTML tab: press Repair the page.
Your code
// ---------- Your code ----------

function loadAnnouncements() {
  // 1 call:  GET API + '/announcements'  ->  showAnnouncements(list)
}

function loadGrade() {
  // 3 calls, each one needs the answer of the one before:
  // 1. GET API + '/me'
  //      ->  showStudent(me)
  // 2. GET API + '/students/' + me.id + '/courses'
  //      ->  showCourse(courses[0])
  // 3. GET API + '/students/' + me.id + '/courses/' + courses[0].id + '/grade'
  //      ->  showGrade(grade)
  // A call fails?  showError('grade', message)
}

document.getElementById('load').onclick = function () {
  start();
  loadAnnouncements();
  loadGrade();
};

The four addresses and their answers: the API documentation, below. Working beside the playground? Give it at least 900 px (narrower, it shows one panel at a time) and open the monitor alone in the rest.

On a phone, the Web Playground works (Code, Preview and Console at the bottom), and the monitor stays in this tab: come back to it after each Load. On an iPhone or iPad, and in Safari on a Mac, the preview hides error messages (the Console only says “Script error.”): a computer with Chrome, Edge or Firefox makes this lab easier.

Live monitor

What the server sees

Each call your dashboard makes appears here as a bar, from the moment it arrives at the server to the moment it is answered. In line or side by side: you see it without opening the developer tools. The four latest runs stay on screen, numbered, the newest on top; the pencil names one (“sync”, “trap”). Dashed lines mark 6 s and 8 s, the two times this lab is about.

Connecting…

No call yet. Press Load in your dashboard. A card says HTTP 401? Open the starting point again.

Open the monitor alone, for the projector

Someone else is using your session? Open this page with ?new=1: you get a fresh one (then reopen the starting point, and the monitor alone if it is open).

API documentation

The lab server

In your code, every address starts with API, the constant on line 2: your own address on the lab server. A plain address in your code is not your student: it lands in a session shared by your whole network, and your monitor never sees it. Opened in a tab of this browser, the plain addresses answer for the same student: https://api.mehditmimi.com/me.

CallAnswer (JSON)
GET /announcements[{ date, title, text }, …] three announcements
GET /me{ id, name, program } your student
GET /students/{id}/courses[{ id, title }, …] your three courses; {id} comes from /me
GET /students/{id}/courses/{courseId}/grade{ studentId, courseId, course, grade, outOf } a grade out of 20; {courseId} comes from /courses
  • The server waits 2 seconds before it answers each call to one of these four addresses; any other address answers 404 at once.
  • Options, at the end of the address: ?delay=1 waits 1 second instead (0 to 5); ?fail=1 answers 500 after the wait. Both: ?delay=1&fail=1.
  • Errors, with a JSON body { "error": "…" }: 404 an address that does not exist, or an id that is not yours, 401 an API address the server does not know, 429 too many calls (wait a little), 500 with fail=1.
Example: GET /me (your student has another name)
{
  "id": 8174,
  "name": "Ilyas Fassi",
  "program": "Bachelor, software engineering"
}

Synchronous XHR

Write getSync(url): const xhr = new XMLHttpRequest();, then xhr.open('GET', url, false): false means “wait right here”. xhr.send() then returns only when the server has answered, and getSync returns that answer as an object. Use it for the four calls, inside a try/catch whose catch calls showError.

Predict first

Press Load once, then type in the text field. What does the clock do? How long until the cards fill, and do they fill one by one?

  1. xhr.responseText is the server’s answer as text; JSON.parse turns it into an object.
  2. The /courses call needs me.id, the /grade call needs courses[0].id too: each line waits for the one before, so that is easy here.
  3. Check xhr.status before you parse: if it is not 200, throw new Error('HTTP ' + xhr.status); your catch shows it in the card.
Check after you predict

The clock stops for about 8 seconds: the dashboard page is frozen, while your lab tab keeps drawing the monitor. What you type in the text field appears only when the page is free again, all at once. Then both cards fill at the same moment: while it waited, the page could not even repaint, so “Loading…” never showed. The line beside the button says it too: “page frozen”.

In the monitor: four bars, one after the other. On a computer, Chrome’s own developer tools (F12, the Issues tab; not the playground’s Console) even flag synchronous XMLHttpRequest on the main thread as deprecated: never do this on a real page.

Clicked Load again during the freeze?

The second click waited too, like your keystrokes: it runs once the first load ends, and the page freezes another 8 s. The line still says about 8 s, and “page frozen” about 8 s too: the second click restarted the stopwatch and the freeze meter. The monitor shows the truth: 8 calls in line, the second load right after the first (two runs of about 8 s back to back, or one run of about 17 s). The disabled button did not protect you: it was disabled and enabled again before the page could even repaint.

Break it

Change '/courses' into '/courses?fail=1' (inside the quotes): the server answers 500 after its wait. What does the card say? Then check xhr.status in getSync (hint 3) and load again.

What happened

Without the check, the server’s error is valid JSON, so courses is the error object, courses[0] is undefined, and showCourse(courses[0]) throws a TypeError: your catch shows “Cannot read properties of undefined (reading 'title')” in the card, an error that says nothing about the server (with no try/catch, the line says “Uncaught error” and the card waits). With the check, throw stops the function and the card shows HTTP 500.

Solution
Synchronous XHR
// ---------- Your code: synchronous XHR ----------

function getSync(url) {
  const xhr = new XMLHttpRequest();
  xhr.open('GET', url, false);   // false: wait here for the answer
  xhr.send();                    // returns only when the server has answered
  if (xhr.status !== 200) throw new Error('HTTP ' + xhr.status);
  return JSON.parse(xhr.responseText);
}

function loadAnnouncements() {
  try {
    showAnnouncements(getSync(API + '/announcements'));
  } catch (err) {
    showError('announcements', err.message);
  }
}

function loadGrade() {
  try {
    const me = getSync(API + '/me');
    showStudent(me);
    const courses = getSync(API + '/students/' + me.id + '/courses');
    showCourse(courses[0]);
    const grade = getSync(API + '/students/' + me.id + '/courses/' +
                          courses[0].id + '/grade');
    showGrade(grade);
  } catch (err) {
    showError('grade', err.message);
  }
}

document.getElementById('load').onclick = function () {
  start();
  loadAnnouncements();
  loadGrade();
};

Callbacks

Now xhr.open('GET', url, true): true means “do not wait, call me back”. xhr.send() returns at once; xhr.onload runs later, when the answer arrives. Write get(url, callback): it calls callback(null, data) when it works, callback(error) when it fails (error first: Node’s convention, you will meet it again in part 2). Then rewrite the inside of loadAnnouncements() and loadGrade() with it; delete getSync, keep the Load button.

Predict first

Will the dashboard still take 8 seconds? Where must the call for the courses start?

  1. Code written after get(...) runs before the answer arrives: the answer exists only inside the callback.
  2. So the /courses call goes inside the callback of /me, and the /grade call inside the callback of /courses. Write the empty shape first, get(url, function (err, me) { });, then fill it.
  3. Every callback of loadGrade() starts with if (err) return showError('grade', err.message); (in loadAnnouncements(), the card is 'announcements').
  4. xhr.onload runs for every answer, a 500 too: check xhr.status there. xhr.onerror runs when no answer comes at all (network down).
Check after you predict

The clock keeps running and you can type. The announcements arrive after 2 s, the grade after 6 s: the two cards wait at the same time, so the dashboard takes 6 s, not 8. Your code still does one thing at a time: the browser waits for both answers, and each callback runs when its answer has arrived and the page’s one thread is free.

In the monitor: announcements beside me, then courses, then grade. In the code: three callbacks inside each other, and an error check at each level. That shape has a name: callback hell.

Predict the order

Add three lines: console.log('B') first in the callback of /announcements, then console.log('A') and console.log('C') around loadAnnouncements() in the Load button. In what order does the playground’s Console print them?

Three lines to add
function loadAnnouncements() {
  get(API + '/announcements', function (err, list) {
    console.log('B');
    …
  });
}

document.getElementById('load').onclick = function () {
  start();
  console.log('A');
  loadAnnouncements();
  console.log('C');
  loadGrade();
};
Check after you predict

A, C, then B, 2 s later. loadAnnouncements() only starts the call and returns at once, so C prints right away; B waits for the answer. Code written after an asynchronous call runs before its callback. You see A, B, C? Your console.log('B') is outside the callback: it must be inside function (err, list) { … }, the function you give to get.

Break it

Put ?fail=1 on the /courses call again. Which callback receives the error, and what if it forgot its if (err)?

What happened

Only the callback of /courses receives the error. Without its if (err), courses is undefined and courses[0] throws inside the callback; no try/catch outside can catch it: the line says “Uncaught error”, and the card waits forever.

Freeze it yourself

In the callback of /me, right after showStudent(me) (before the call for the courses), add the loop below, which keeps the page busy for 3 seconds. Load: what do the clock, the line and the grade card do?

Add right after showStudent(me)
// 3 s of work: the page can do nothing else
const t = Date.now();
while (Date.now() - t < 3000) {}
What happened

The clock stops for 3 s, 2 s after Load, and the line says “page frozen 3.0 s”: your callback holds the page’s one thread, and nothing else can run, not even the other callbacks (the announcements answer, if it arrives meanwhile, waits too). The call for the courses leaves only when your loop ends, at 5 s, so the grade comes at about 9 s instead of 6. In the monitor the load splits into two runs: for 3 s the server had nothing to do. Waiting for an answer frees the page; your own long code never does. Node.js has the same single thread: you will meet it again in part 2. The clock stopped right at the click, and the monitor shows one normal run? Your loop is outside the callback: it ran during the click itself, and froze the page before any answer came.

Solution
Callbacks
// ---------- Your code: callbacks ----------

function get(url, callback) {
  const xhr = new XMLHttpRequest();
  xhr.open('GET', url, true);   // true: do not wait, call me back
  xhr.onload = function () {
    if (xhr.status !== 200) return callback(new Error('HTTP ' + xhr.status));
    callback(null, JSON.parse(xhr.responseText));
  };
  xhr.onerror = function () { callback(new Error('network error')); };
  xhr.send();                   // returns at once
}

function loadAnnouncements() {
  get(API + '/announcements', function (err, list) {
    if (err) return showError('announcements', err.message);
    showAnnouncements(list);
  });
}

function loadGrade() {
  get(API + '/me', function (err, me) {
    if (err) return showError('grade', err.message);
    showStudent(me);
    get(API + '/students/' + me.id + '/courses', function (err, courses) {
      if (err) return showError('grade', err.message);
      showCourse(courses[0]);
      get(API + '/students/' + me.id + '/courses/' +
          courses[0].id + '/grade', function (err, grade) {
        if (err) return showError('grade', err.message);
        showGrade(grade);
      });
    });
  });
}

document.getElementById('load').onclick = function () {
  start();
  loadAnnouncements();
  loadGrade();
};

Promises

A promise is an object for an answer that will come later. Replace your get(url, callback) with get(url), XHR wrapped in a promise: return new Promise(function (resolve, reject) { … }), resolve(data) when it works, reject(error) when it fails. loadAnnouncements() and loadGrade() become chains: get(url).then(…), with one .catch at the end. Then write it with fetch(url): its promise gives a response, not the data; check response.ok, then response.json() reads the body and returns a promise of the data.

Predict first

Same timings as callbacks? What does loadGrade() look like now?

  1. Return the next call from .then: the next .then waits for it.
  2. The /grade call needs me.id, but the second .then only receives the courses: declare let me; before the chain, name the first .then’s parameter student, and write me = student;. A parameter named me would hide that let.
  3. One .catch at the end catches an error from any step of the chain.
  4. fetch(url) gives a promise of the response, not the response: .json() goes inside its .then, return fetch(url).then(function (response) { … return response.json(); });. “fetch(...).json is not a function” means that .then is missing.
  5. fetch’s promise is rejected only when no answer can be read (network down, server not found, blocked by CORS). On a 404 or a 500 it is resolved: check response.ok and throw yourself.
Check after you predict

The same 6 s, the page alive, the same monitor: promises change the code, not the waiting. loadGrade() is flat now: each .then returns the next call, and a single .catch handles the three levels.

Break it

In the fetch version, put ?fail=1 on /courses and remove the response.ok line. Then put it back, take ?fail=1 off, and remove the return before the /courses call. Which .then fails, and what does the card say?

What happened

Without response.ok, courses is the error object: the .then that receives it fails with a message about title, not about the 500, so the cause is hidden. Without the return, the next .then runs at once with undefined: the card says “Cannot read properties of undefined (reading '0')”, and in the monitor courses is called, but grade never is.

Solution
XHR in a promise
// ---------- Your code: XHR in a promise ----------

function get(url) {
  return new Promise(function (resolve, reject) {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.onload = function () {
      if (xhr.status !== 200) return reject(new Error('HTTP ' + xhr.status));
      resolve(JSON.parse(xhr.responseText));
    };
    xhr.onerror = function () { reject(new Error('network error')); };
    xhr.send();
  });
}

function loadAnnouncements() {
  return get(API + '/announcements')
    .then(showAnnouncements)
    .catch(function (err) { showError('announcements', err.message); });
}

function loadGrade() {
  let me;
  return get(API + '/me')
    .then(function (student) {
      me = student;
      showStudent(me);
      return get(API + '/students/' + me.id + '/courses');
    })
    .then(function (courses) {
      showCourse(courses[0]);
      return get(API + '/students/' + me.id + '/courses/' +
                 courses[0].id + '/grade');
    })
    .then(showGrade)
    .catch(function (err) { showError('grade', err.message); });
}

document.getElementById('load').onclick = function () {
  start();
  loadAnnouncements();
  loadGrade();
};
With fetch: replace only get()
function get(url) {
  return fetch(url).then(function (response) {
    // a 500 does not reject
    if (!response.ok) throw new Error('HTTP ' + response.status);
    return response.json();
  });
}

async/await

An async function can await a promise: the function pauses there until the answer arrives, while the page goes on. try/catch handles the errors, as in the synchronous version. Write get(url) with await fetch(url), then make loadAnnouncements() and loadGrade() async: an await before each get(...), inside a try/catch.

Predict first

The code will look almost like the synchronous version. 8 s like it, or 6 s?

  1. await works inside an async function only (or at the top level of a module; the playground runs a classic script).
  2. get(url) can be async too: await fetch(url), check response.ok, then return response.json().
  3. One try around the three await lines catches an error from any of them.
  4. Each get(...) needs its own await: without it you hold a promise, courses[0] is undefined, and the card talks about 'title'.
Check after you predict

The code reads top to bottom like the synchronous version, yet the clock never stops: await pauses loadGrade(), not the page. The dashboard takes 6 s, like with promises: async/await is promises underneath.

Predict the order

Add two lines: console.log('grade shown') after showGrade(grade), and console.log('Load is over?') as the last line inside the Load button’s function, just before its };. Which one prints first, and when?

Two lines to add
async function loadGrade() {
  try {
    …
    showGrade(grade);
    console.log('grade shown');
  } catch (err) {
    …
  }
}

document.getElementById('load').onclick = function () {
  start();
  loadAnnouncements();
  loadGrade();
  console.log('Load is over?');
};
Check after you predict

“Load is over?” first, right after the click; “grade shown” about 6 s later. await pauses loadGrade(), not the Load button that called it: the button got a promise back and carried on. The code looks synchronous; it is not.

Break it

Remove the await before get(API + '/me'). What is me now, which address does the next line call, and what does the server answer?

What happened

me is a promise, not the student: the card shows “undefined (undefined)”, the next call asks for /students/undefined/courses, and the server answers 404, which catch shows as HTTP 404. In the monitor, me and courses start together: without await, nothing waited.

Solution
async/await
// ---------- Your code: async/await ----------

async function get(url) {
  const response = await fetch(url);
  if (!response.ok) throw new Error('HTTP ' + response.status);
  return response.json();
}

async function loadAnnouncements() {
  try {
    showAnnouncements(await get(API + '/announcements'));
  } catch (err) {
    showError('announcements', err.message);
  }
}

async function loadGrade() {
  try {
    const me = await get(API + '/me');
    showStudent(me);
    const courses = await get(API + '/students/' + me.id + '/courses');
    showCourse(courses[0]);
    const grade = await get(API + '/students/' + me.id + '/courses/' +
                            courses[0].id + '/grade');
    showGrade(grade);
  } catch (err) {
    showError('grade', err.message);
  }
}

document.getElementById('load').onclick = function () {
  start();
  loadAnnouncements();
  loadGrade();
};

The await trap

One line must wait for both cards: console.log('Dashboard ready'). An async function returns a promise at once, resolved when its body ends: that is what a caller can await. Make the Load button async and await both loads:

The Load button, with async/await: replace yours
document.getElementById('load').onclick = async function () {
  start();
  await loadAnnouncements();
  await loadGrade();
  console.log('Dashboard ready');
};
Predict first

How long does the dashboard take now?

Check after you predict

8 s again, though the page stays alive: loadGrade() starts only when loadAnnouncements() is over, and the two do not depend on each other. In the monitor, the bars are back in line. The monitor draws this run like the synchronous one: the server sees only the order of the calls. Your page tells them apart: the clock kept turning, and the line says “page alive”. In line is not frozen.

Back to 6 s, without touching loadGrade() or loadAnnouncements(). Work in your own tab.

  1. Called without await, loadAnnouncements() and loadGrade() each start their calls and give back a promise at once. Start both first; wait only after.
  2. Keep the two promises: const a = loadAnnouncements(); and const g = loadGrade();, then await a; and await g;.
  3. Promise.all([…]) is the short form: give it both promises, and it waits for all of them.
  4. Inside the brackets, call both loads: loadAnnouncements() and loadGrade(), with their (). Without them, Promise.all gets two functions, not promises: nothing is called, “Dashboard ready” prints at once, the line says “No call went out”, and the monitor stays empty. .then(showGrade) takes a function, to call later; Promise.all takes promises: calls already started.
Solution

Start both, then wait. Called without await, each load starts its calls and returns its promise at once; the two run side by side, and each await only waits for what is left: 6 s.

Start both, then await each: replace the Load button
document.getElementById('load').onclick = async function () {
  start();
  // both start now
  const a = loadAnnouncements();
  const g = loadGrade();
  await a;
  await g;
  console.log('Dashboard ready');
};

Promise.all says it in one line: it waits until every promise you give it is resolved. Each load here catches its own errors, so its promise is always resolved, and “Dashboard ready” prints even when a card fails. A load that does not catch them: break it, below.

The short form, Promise.all: replace the Load button
document.getElementById('load').onclick = async function () {
  start();
  await Promise.all([loadAnnouncements(), loadGrade()]);
  console.log('Dashboard ready');
};

6 s, and announcements sits beside me in the monitor again. Promise.all is more than shorter: it watches both promises from the start. With await a; await g;, if g fails while you are still waiting for a, the browser reports an uncaught error, even inside a try. Use await where a call needs the one before (me, then courses); between calls that do not, it only makes you wait in line. Promise.all is the right tool when the next line needs both answers: const [list, me] = await Promise.all([get(API + '/announcements'), get(API + '/me')]);

Break it

Replace loadAnnouncements() with this version, which catches nothing and asks for ?fail=1, and keep the Promise.all Load button. Does the grade card stop? Does “Dashboard ready” print?

Replace loadAnnouncements()
async function loadAnnouncements() {
  showAnnouncements(await get(API + '/announcements?fail=1'));
}
What happened

The grade card still fills at 6 s, and the monitor shows its three calls to the end: a rejection cancels nothing. Promise.all is rejected at 2 s, when the announcements fail, so only the await gives up: “Dashboard ready” never prints, and the line says “Uncaught error”, because nothing catches that rejection. To wait for every load whatever happens: await Promise.allSettled([…]). It is never rejected and gives one result per promise, { status: 'fulfilled', value } (resolved) or { status: 'rejected', reason } (rejected). It shows nothing by itself: with allSettled alone, the announcements card keeps “…”, and 2 s after the last answer the line says a card still waits, because only a catch calls showError. Read the results instead, as below: the card shows HTTP 500 and the line ends.

With allSettled, read the results: replace the Load button
document.getElementById('load').onclick = async function () {
  start();
  const results = await Promise.allSettled([loadAnnouncements(), loadGrade()]);
  if (results[0].status === 'rejected') {
    showError('announcements', results[0].reason.message);
  }
  console.log('Dashboard ready');
};

Quick check · 5 questions

xhr.open('GET', url, false); xhr.send(); The server takes 2 s. What happens to the page meanwhile?

See the answer

false makes send() wait for the answer, and while it waits the page’s main thread runs nothing else. The keystrokes are kept and handled at the end, all at once.

With callbacks, where must the /courses call start?

See the answer

On the next line, the answer has not arrived yet: me does not exist. A setTimeout guesses, and a slow day breaks it. The callback is the one place where the answer is known.

The server answers 500. What does the promise of fetch(url) do?

See the answer

For fetch, an answer is an answer, even a 500: its promise is rejected only when no answer can be read. Check response.ok and throw yourself.

const a = await getA(); const b = await getB(); The two calls do not depend on each other and take 2 s each. How long in total?

See the answer

The second line starts when the first is over. The page lives (not D), but the calls wait in line. await Promise.all([getA(), getB()]): 2 s.

The Load button calls loadGrade() without await. Inside it, await get(url) waits 2 s. What waits?

See the answer

await pauses the function it is written in and hands the thread back to the page; the function resumes when the promise settles. The button, without await, does not wait: it gets a promise and carries on. With await it would wait too, and two await in a row put the calls in line: the trap of section 06. Chapter 13 (coming soon) shows what runs in the meantime.

Recap

  1. Synchronous XHR, open(…, false), freezes the page until the answer: never on a real page.
  2. Asynchronous XHR, open(…, true), lets the page live; your code runs in a callback when the answer arrives. The page still has one thread: a long callback freezes it too.
  3. Calls that need each other nest their callbacks into a pyramid, with an error check at every level.
  4. A promise turns the pyramid into a chain: each .then returns the next call, one .catch handles them all. fetch resolves even on a 500: check response.ok.
  5. await pauses the function, not the page. Calls that do not need each other: start them all, then wait for them together with Promise.all.

Sources

← All chapters