Skip to content
aviral gupta

// I1.6 · ~40 min · Intermediate

Build: a quiz with a countdown

You build a quiz page in which each question waits for a click or for the countdown, whichever comes first: two promises, one Promise.race, and a loop with await.

Lesson 6 of 6 in I1 Asynchronous JavaScript

End of the module

You will be able to

  • Turn the answer click and the countdown into promises: ask() and countdown()
  • Race the answer against the countdown with Promise.race, and stop the countdown afterwards
  • Ask the questions one at a time with await in a for...of loop, and show the score
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson I1.5. What does this print?

    const {promise, resolve} = Promise.withResolvers();
    resolve("a");
    resolve("b");
    console.log(await promise);
  2. Predict · Activity 2 of 7

    Predict before you read on. Nobody answers, and the countdown resolves with null after 20 ms. What does this print?

    const noAnswer = new Promise(() => {}); // nobody clicks
    const timeUp = new Promise((resolve) => setTimeout(() => resolve(null), 20));
    
    const answer = await Promise.race([noAnswer, timeUp]);
    console.log(answer === null ? "Time's up!" : answer);
  3. Practice · Activity 3 of 7

    Fill in the function that stops the countdown, so that left stays at 3 after stop().

    function countdown(seconds, tick) {
      let left = seconds;
      const id = setInterval(() => (left -= 1), tick);
      return {stop: () => ____(id), left: () => left};
    }
    stop: () =>(id),
  4. Practice · Activity 4 of 7

    Match each part of the quiz to its job.

  5. Practice · Activity 5 of 7

    The answer comes after 10 ms, the countdown would end after 30 ms. What does this print?

    const ask = () => new Promise((resolve) => setTimeout(() => resolve("reject"), 10));
    const timeUp = () => new Promise((resolve) => setTimeout(() => resolve(null), 30));
    
    const answer = await Promise.race([ask(), timeUp()]);
    console.log(answer === null ? "Time's up!" : answer === "reject" ? "Right!" : "Wrong.");
  6. Brain teaser · Activity 6 of 7

    Brain teaser. A loop with forEach and an async callback. In which order are the lines printed?

    const questions = ["q1", "q2"];
    questions.forEach(async (question) => {
      console.log("ask", question);
      await null;
      console.log("answered", question);
    });
    console.log("score");
  7. Apply · Activity 7 of 7

    Mini-task: extend your quiz. Show "Question 2 of 3" in a new paragraph above each question, and empty it at the end. After a wrong answer or when the time is up, show the right answer too, such as "Wrong. The answer is reject." or "Time’s up! The answer is reject.". Keep "Right!" for a right answer. Try it through a local web server.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

The quiz, start to finish

The page has a question, an empty #answers box, a timer, a result line and a Start button. main.js holds three questions as data. ask() and countdown() return promises, and run() races them for each question in a for...of loop. At the end the script plays its own quiz, the way the exercise tests do: it answers the first question right, the second wrong, and lets the time run out on the third.

main.js

const questions = [
  { text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
  { text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
  { text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question

const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');

// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
  questionText.textContent = question.text;
  const buttons = question.options.map((option) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = option;
    return button;
  });
  answers.replaceChildren(...buttons);
  return new Promise((resolve) => {
    for (const button of buttons) {
      button.addEventListener('click', () => resolve(button.textContent), { once: true });
    }
  });
}

// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
  const { promise, resolve } = Promise.withResolvers();
  let left = seconds;
  timerText.textContent = `${left} s left`;
  const id = setInterval(() => {
    left -= 1;
    timerText.textContent = `${left} s left`;
    if (left === 0) {
      clearInterval(id);
      resolve(null);
    }
  }, 1000);
  return { promise, stop: () => clearInterval(id) };
}

async function run() {
  startButton.disabled = true;
  result.textContent = '';
  let score = 0;
  for (const question of questions) {
    const timer = countdown(SECONDS);
    const answer = await Promise.race([ask(question), timer.promise]);
    timer.stop();
    if (answer === question.answer) score += 1;
    result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
  }
  questionText.textContent = 'Done.';
  answers.replaceChildren();
  timerText.textContent = '';
  result.textContent = `Score: ${score} of ${questions.length}`;
  startButton.disabled = false;
}

startButton.addEventListener('click', run);

// The script plays its own quiz, the way the exercise tests do.
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const click = (label) => [...answers.querySelectorAll('button')].find((b) => b.textContent === label).click();

startButton.click();
console.log('1:', questionText.textContent, '|', timerText.textContent);
click('Promise.all');
await pause(0);
console.log('->', result.textContent);
click('resolve');
await pause(0);
console.log('->', result.textContent);
console.log('3:', questionText.textContent);
await pause(3200); // no answer
console.log('->', questionText.textContent, '|', result.textContent);

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Quick quiz</title>
</head>
<body>
  <h1>Quick quiz</h1>
  <p id="question">Press Start.</p>
  <div id="answers"></div>
  <p id="timer"></p>
  <p id="result"></p>
  <button id="start" type="button">Start</button>
  <script type="module" src="main.js"></script>
</body>
</html>

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Output

1: Which combinator waits for every promise to fulfil? | 3 s left
-> Right!
-> Wrong.
3: What does await do with a rejected promise?
-> Done. | Score: 1 of 3
<!-- #result -->
<p id="result">Score: 1 of 3</p>
  • After each click the script waits one turn, pause(0): the race settles and run() continues after its await.
  • The third question got no answer: after 3 seconds the countdown resolved with null, race gave null, and that was the last round, so the score replaced “Time’s up!”.
  • Each round stopped its countdown, so only one interval was ever running.
  • Start was disabled while the quiz ran and enabled again at the end.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Exercises

Exercise 1 of 3

A promise for the answer

Finish ask(question). It already shows the question and its answer buttons. Make it return a promise that resolves with the text of the first button clicked; give each button a click listener with {once: true} that calls resolve. The starter returns nothing, so the quiz rushes through every question at once.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: the first question is already gone. What does run() get when it awaits a function that returns nothing?

  2. Hint 2

    Return new Promise((resolve) => { … }) at the end of ask, and add the listeners inside it.

  3. Hint 3

    for (const button of buttons) button.addEventListener('click', () => resolve(button.textContent), { once: true });

Show a solution

One way to solve it. Yours can look different and still pass the checks.

const questions = [
  { text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
  { text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
  { text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question

const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');

// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
  questionText.textContent = question.text;
  const buttons = question.options.map((option) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = option;
    return button;
  });
  answers.replaceChildren(...buttons);
  return new Promise((resolve) => {
    for (const button of buttons) {
      button.addEventListener('click', () => resolve(button.textContent), { once: true });
    }
  });
}

// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
  const { promise, resolve } = Promise.withResolvers();
  let left = seconds;
  timerText.textContent = `${left} s left`;
  const id = setInterval(() => {
    left -= 1;
    timerText.textContent = `${left} s left`;
    if (left === 0) {
      clearInterval(id);
      resolve(null);
    }
  }, 1000);
  return { promise, stop: () => clearInterval(id) };
}

async function run() {
  startButton.disabled = true;
  result.textContent = '';
  let score = 0;
  for (const question of questions) {
    const timer = countdown(SECONDS);
    const answer = await Promise.race([ask(question), timer.promise]);
    timer.stop();
    if (answer === question.answer) score += 1;
    result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
  }
  questionText.textContent = 'Done.';
  answers.replaceChildren();
  timerText.textContent = '';
  result.textContent = `Score: ${score} of ${questions.length}`;
  startButton.disabled = false;
}

startButton.addEventListener('click', run);
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

const questions = [
  { text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
  { text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
  { text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question

const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');

// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
  questionText.textContent = question.text;
  const buttons = question.options.map((option) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = option;
    return button;
  });
  answers.replaceChildren(...buttons);
  // TODO: return a promise that resolves with the text of the first button clicked.
}

// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
  const { promise, resolve } = Promise.withResolvers();
  let left = seconds;
  timerText.textContent = `${left} s left`;
  const id = setInterval(() => {
    left -= 1;
    timerText.textContent = `${left} s left`;
    if (left === 0) {
      clearInterval(id);
      resolve(null);
    }
  }, 1000);
  return { promise, stop: () => clearInterval(id) };
}

async function run() {
  startButton.disabled = true;
  result.textContent = '';
  let score = 0;
  for (const question of questions) {
    const timer = countdown(SECONDS);
    const answer = await Promise.race([ask(question), timer.promise]);
    timer.stop();
    if (answer === question.answer) score += 1;
    result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
  }
  questionText.textContent = 'Done.';
  answers.replaceChildren();
  timerText.textContent = '';
  result.textContent = `Score: ${score} of ${questions.length}`;
  startButton.disabled = false;
}

startButton.addEventListener('click', run);

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const text = (page, selector) => page.document.querySelector(selector).textContent;
const labels = (page) => [...page.document.querySelectorAll('#answers button')].map((b) => b.textContent);
async function answer(page, label) {
  const button = [...page.document.querySelectorAll('#answers button')].find((b) => b.textContent === label);
  assert.ok(button, `there is no answer button ${label}; the buttons are ${JSON.stringify(labels(page))}`);
  button.click();
  await pause(0);
}
async function start() {
  const page = await loadPage();
  page.document.querySelector('#start').click();
  await pause(0);
  return page;
}

test('Start shows the first question and its three answers', async () => {
  const page = await start();
  assert.equal(text(page, '#question'), 'Which combinator waits for every promise to fulfil?', 'the question is ' + text(page, '#question'));
  assert.deepEqual(labels(page), ['Promise.race', 'Promise.all', 'Promise.any'], 'the answer buttons are ' + JSON.stringify(labels(page)));
});

test('A right answer says Right! and shows the next question', async () => {
  const page = await start();
  await answer(page, 'Promise.all');
  assert.equal(text(page, '#result'), 'Right!', 'the result is ' + text(page, '#result'));
  assert.equal(text(page, '#question'), 'Which function makes a new promise fail?', 'the question is ' + text(page, '#question'));
});

test('A wrong answer says Wrong.', async () => {
  const page = await start();
  await answer(page, 'Promise.race');
  assert.equal(text(page, '#result'), 'Wrong.', 'the result is ' + text(page, '#result'));
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Quick quiz</title>
</head>
<body>
  <h1>Quick quiz</h1>
  <p id="question">Press Start.</p>
  <div id="answers"></div>
  <p id="timer"></p>
  <p id="result"></p>
  <button id="start" type="button">Start</button>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Exercise 2 of 3

Time is up

Finish countdown(seconds). It already shows the seconds left and takes one off every second. When left reaches 0, it must stop the interval with clearInterval and resolve the promise with null, so that the race ends with "Time’s up!". The starter never resolves, so a question without an answer waits forever while the timer counts below zero.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: after 3 seconds the timer shows 0 s left, then goes below zero. What should happen at 0?

  2. Hint 2

    Inside the interval callback, after updating the text: if (left === 0) { … }.

  3. Hint 3

    if (left === 0) { clearInterval(id); resolve(null); }

Show a solution

One way to solve it. Yours can look different and still pass the checks.

const questions = [
  { text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
  { text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
  { text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question

const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');

// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
  questionText.textContent = question.text;
  const buttons = question.options.map((option) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = option;
    return button;
  });
  answers.replaceChildren(...buttons);
  return new Promise((resolve) => {
    for (const button of buttons) {
      button.addEventListener('click', () => resolve(button.textContent), { once: true });
    }
  });
}

// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
  const { promise, resolve } = Promise.withResolvers();
  let left = seconds;
  timerText.textContent = `${left} s left`;
  const id = setInterval(() => {
    left -= 1;
    timerText.textContent = `${left} s left`;
    if (left === 0) {
      clearInterval(id);
      resolve(null);
    }
  }, 1000);
  return { promise, stop: () => clearInterval(id) };
}

async function run() {
  startButton.disabled = true;
  result.textContent = '';
  let score = 0;
  for (const question of questions) {
    const timer = countdown(SECONDS);
    const answer = await Promise.race([ask(question), timer.promise]);
    timer.stop();
    if (answer === question.answer) score += 1;
    result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
  }
  questionText.textContent = 'Done.';
  answers.replaceChildren();
  timerText.textContent = '';
  result.textContent = `Score: ${score} of ${questions.length}`;
  startButton.disabled = false;
}

startButton.addEventListener('click', run);
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

const questions = [
  { text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
  { text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
  { text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question

const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');

// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
  questionText.textContent = question.text;
  const buttons = question.options.map((option) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = option;
    return button;
  });
  answers.replaceChildren(...buttons);
  return new Promise((resolve) => {
    for (const button of buttons) {
      button.addEventListener('click', () => resolve(button.textContent), { once: true });
    }
  });
}

// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
  const { promise, resolve } = Promise.withResolvers();
  let left = seconds;
  timerText.textContent = `${left} s left`;
  const id = setInterval(() => {
    left -= 1;
    timerText.textContent = `${left} s left`;
    // TODO: at 0, stop the interval and resolve the promise with null.
  }, 1000);
  return { promise, stop: () => clearInterval(id) };
}

async function run() {
  startButton.disabled = true;
  result.textContent = '';
  let score = 0;
  for (const question of questions) {
    const timer = countdown(SECONDS);
    const answer = await Promise.race([ask(question), timer.promise]);
    timer.stop();
    if (answer === question.answer) score += 1;
    result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
  }
  questionText.textContent = 'Done.';
  answers.replaceChildren();
  timerText.textContent = '';
  result.textContent = `Score: ${score} of ${questions.length}`;
  startButton.disabled = false;
}

startButton.addEventListener('click', run);

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const text = (page, selector) => page.document.querySelector(selector).textContent;
const labels = (page) => [...page.document.querySelectorAll('#answers button')].map((b) => b.textContent);
async function answer(page, label) {
  const button = [...page.document.querySelectorAll('#answers button')].find((b) => b.textContent === label);
  assert.ok(button, `there is no answer button ${label}; the buttons are ${JSON.stringify(labels(page))}`);
  button.click();
  await pause(0);
}
async function start() {
  const page = await loadPage();
  page.document.querySelector('#start').click();
  await pause(0);
  return page;
}

test('The timer starts at 3 s left and shows 2 s left a second later', async () => {
  const page = await start();
  assert.equal(text(page, '#timer'), '3 s left', 'at the start the timer shows ' + text(page, '#timer'));
  await pause(1100);
  assert.equal(text(page, '#timer'), '2 s left', 'after a second the timer shows ' + text(page, '#timer'));
});

test('After 3 seconds without an answer, the time is up and the next question shows', async () => {
  const page = await start();
  try {
    await pause(3300);
    assert.equal(text(page, '#result'), "Time's up!", 'the result is ' + text(page, '#result'));
    assert.equal(text(page, '#question'), 'Which function makes a new promise fail?', 'the question is ' + text(page, '#question'));
    assert.equal(text(page, '#timer'), '3 s left', 'the next countdown shows ' + text(page, '#timer'));
  } finally {
    page.window.close(); // stops a timer that never ends, so the test run can finish
  }
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Quick quiz</title>
</head>
<body>
  <h1>Quick quiz</h1>
  <p id="question">Press Start.</p>
  <div id="answers"></div>
  <p id="timer"></p>
  <p id="result"></p>
  <button id="start" type="button">Start</button>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Exercise 3 of 3

One question at a time

Fix run(). The starter uses questions.forEach with an async callback, and forEach does not wait for it: all three questions are asked at once and the score appears straight away. Use a for...of loop, so that each await pauses run() until that question is decided. Start must stay disabled until the score is shown.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: the page shows the third question and the score at once. Who waits for the promises the forEach callback returns?

  2. Hint 2

    Replace questions.forEach(async (question) => { … }); with for (const question of questions) { … } and keep the body.

  3. Hint 3

    The await inside for...of pauses run() itself, so the lines after the loop run only after the last question.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

const questions = [
  { text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
  { text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
  { text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question

const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');

// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
  questionText.textContent = question.text;
  const buttons = question.options.map((option) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = option;
    return button;
  });
  answers.replaceChildren(...buttons);
  return new Promise((resolve) => {
    for (const button of buttons) {
      button.addEventListener('click', () => resolve(button.textContent), { once: true });
    }
  });
}

// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
  const { promise, resolve } = Promise.withResolvers();
  let left = seconds;
  timerText.textContent = `${left} s left`;
  const id = setInterval(() => {
    left -= 1;
    timerText.textContent = `${left} s left`;
    if (left === 0) {
      clearInterval(id);
      resolve(null);
    }
  }, 1000);
  return { promise, stop: () => clearInterval(id) };
}

async function run() {
  startButton.disabled = true;
  result.textContent = '';
  let score = 0;
  for (const question of questions) {
    const timer = countdown(SECONDS);
    const answer = await Promise.race([ask(question), timer.promise]);
    timer.stop();
    if (answer === question.answer) score += 1;
    result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
  }
  questionText.textContent = 'Done.';
  answers.replaceChildren();
  timerText.textContent = '';
  result.textContent = `Score: ${score} of ${questions.length}`;
  startButton.disabled = false;
}

startButton.addEventListener('click', run);
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

const questions = [
  { text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
  { text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
  { text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question

const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');

// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
  questionText.textContent = question.text;
  const buttons = question.options.map((option) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = option;
    return button;
  });
  answers.replaceChildren(...buttons);
  return new Promise((resolve) => {
    for (const button of buttons) {
      button.addEventListener('click', () => resolve(button.textContent), { once: true });
    }
  });
}

// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
  const { promise, resolve } = Promise.withResolvers();
  let left = seconds;
  timerText.textContent = `${left} s left`;
  const id = setInterval(() => {
    left -= 1;
    timerText.textContent = `${left} s left`;
    if (left === 0) {
      clearInterval(id);
      resolve(null);
    }
  }, 1000);
  return { promise, stop: () => clearInterval(id) };
}

async function run() {
  startButton.disabled = true;
  result.textContent = '';
  let score = 0;
  // TODO: ask one question at a time; forEach does not wait for the await inside.
  questions.forEach(async (question) => {
    const timer = countdown(SECONDS);
    const answer = await Promise.race([ask(question), timer.promise]);
    timer.stop();
    if (answer === question.answer) score += 1;
    result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
  });
  questionText.textContent = 'Done.';
  answers.replaceChildren();
  timerText.textContent = '';
  result.textContent = `Score: ${score} of ${questions.length}`;
  startButton.disabled = false;
}

startButton.addEventListener('click', run);

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const text = (page, selector) => page.document.querySelector(selector).textContent;
const labels = (page) => [...page.document.querySelectorAll('#answers button')].map((b) => b.textContent);
async function answer(page, label) {
  const button = [...page.document.querySelectorAll('#answers button')].find((b) => b.textContent === label);
  assert.ok(button, `there is no answer button ${label}; the buttons are ${JSON.stringify(labels(page))}`);
  button.click();
  await pause(0);
}
async function start() {
  const page = await loadPage();
  page.document.querySelector('#start').click();
  await pause(0);
  return page;
}

test('Start shows only the first question, and Start is disabled', async () => {
  const page = await start();
  assert.equal(text(page, '#question'), 'Which combinator waits for every promise to fulfil?', 'the question is ' + text(page, '#question'));
  assert.equal(page.document.querySelector('#start').disabled, true, 'Start should be disabled while the quiz runs');
});

test('Three answers give the score, and Start works again', async () => {
  const page = await start();
  await answer(page, 'Promise.all');
  await answer(page, 'finally');
  await answer(page, 'It throws');
  assert.equal(text(page, '#question'), 'Done.', 'at the end the question line is ' + text(page, '#question'));
  assert.equal(text(page, '#result'), 'Score: 2 of 3', 'the result is ' + text(page, '#result'));
  assert.equal(page.document.querySelector('#start').disabled, false, 'Start should be enabled after the quiz');
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Quick quiz</title>
</head>
<body>
  <h1>Quick quiz</h1>
  <p id="question">Press Start.</p>
  <div id="answers"></div>
  <p id="timer"></p>
  <p id="result"></p>
  <button id="start" type="button">Start</button>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Common mistakes

Racing the countdown object instead of its promise

function countdown(ms) {
  const {promise, resolve} = Promise.withResolvers();
  const id = setTimeout(() => resolve(null), ms);
  return {promise, stop: () => clearTimeout(id)};
}
const noAnswer = new Promise(() => {});
const timer = countdown(20);
const answer = await Promise.race([noAnswer, timer]);
timer.stop();
console.log(answer.toLowerCase());

What Node.js prints

TypeError: answer.toLowerCase is not a function

Why, and the fix

timer is the object {promise, stop}, not a promise. race treats a value that is not a promise as already fulfilled, so it wins at once and answer is that object, before anyone could click. Race the promise inside it: Promise.race([noAnswer, timer.promise]).

Forgetting that the time can run out

const noAnswer = new Promise(() => {});
const timeUp = new Promise((resolve) => setTimeout(() => resolve(null), 20));
const answer = await Promise.race([noAnswer, timeUp]);
console.log(answer.toLowerCase() === "reject" ? "Right!" : "Wrong.");

What Node.js prints

TypeError: Cannot read properties of null (reading 'toLowerCase')

Why, and the fix

When nobody answers in time, the countdown wins and answer is null, and null has no methods. Check for the timeout first: if (answer === null) show "Time’s up!", and compare answers only after that.

Taking the result of withResolvers as an array

const [promise, resolve] = Promise.withResolvers();
setTimeout(() => resolve(null), 20);
console.log(await promise);

What Node.js prints

TypeError: object is not iterable (cannot read property Symbol(Symbol.iterator))

Why, and the fix

withResolvers returns an object with the keys promise, resolve and reject, and an object cannot be destructured with square brackets. Use curly braces and the exact names: const {promise, resolve} = Promise.withResolvers();

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Two promises: the answer and the time

ask(question) shows the question, builds one button per option and returns a promise that resolves with the text of the first button clicked: each button gets a click listener with {once: true} that calls resolve. countdown(seconds) shows the seconds left and, with setInterval, takes one off every second. Its promise comes from Promise.withResolvers, because resolve is called inside the interval’s callback: at 0 it clears the interval and resolves with null. It also returns stop(), which calls clearInterval.

Race them, then clean up

await Promise.race([ask(question), timer.promise]) gives whichever settles first: the clicked text, or null when the time is up. The race does not stop the loser. A click that comes too late still resolves its own promise, which nobody waits for any more, and that is harmless. A countdown that is never stopped keeps ticking and changes the timer text during the next question, so call timer.stop() straight after the race. clearInterval on an interval that has already ended does nothing.

One question at a time

run() is an async function. In a for...of loop, each await pauses the loop until the race for that question is decided, so the questions come one after another and the score is shown only after the last. questions.forEach(async (question) => …) does not work: forEach calls the callback for every question at once and does not wait for the promises it returns, so all questions are asked together and the score appears straight away. The Start button is disabled while a quiz runs, so that a second click cannot start a second quiz.

Sources

Last reviewed October 5, 2026