Skip to content
aviral gupta

// I1.5 · ~33 min · Intermediate

Writing your own promise-based functions

After this lesson you turn a timer, a callback API or a one-time event into a function that returns a promise, so the rest of your code can simply await it.

Lesson 5 of 6 in I1 Asynchronous JavaScript

You will be able to

  • Wrap a timer or callback API in new Promise and call resolve or reject at the right moment
  • Turn a one-time event into a promise, and use Promise.withResolvers when resolve is needed outside
  • Use the ready-made helpers: Promise.try anywhere, util.promisify and timers/promises in Node.js
  1. Warm-up · Activity 1 of 7

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

    Promise.resolve(5)
      .then((n) => n * 2)
      .then((n) => console.log(n));
  2. Predict · Activity 2 of 7

    Predict before you read on. In which order are the four lines printed?

    console.log("1");
    const p = new Promise((resolve) => {
      console.log("2");
      resolve();
    });
    p.then(() => console.log("4"));
    console.log("3");
  3. Practice · Activity 3 of 7

    MDN’s alarm(). Fill in the function that makes the promise fail for a negative delay.

    function alarm(person, delay) {
      return new Promise((resolve, reject) => {
        if (delay < 0) {
          ____(new Error("negative delay"));
          return;
        }
        setTimeout(() => resolve(`Wake up, ${person}!`), delay);
      });
    }
    if (delay < 0) {(new Error("negative delay"));
  4. Practice · Activity 4 of 7

    Match each tool to what it gives you.

  5. Practice · Activity 5 of 7

    The executor calls three settle functions. What does this print?

    const p = new Promise((resolve, reject) => {
      resolve("first");
      reject(new Error("second"));
      resolve("third");
    });
    p.then((v) => console.log("fulfilled:", v), (e) => console.log("rejected:", e.message));
  6. Brain teaser · Activity 6 of 7

    Brain teaser. resolve is given a promise that rejects. How does p settle?

    const p = new Promise((resolve) => {
      resolve(Promise.reject(new Error("inner")));
    });
  7. Apply · Activity 7 of 7

    Mini-task: you are given readSetting(name, callback), which calls back after 10 ms with (null, value) or with (error) for an unknown name. Write getSetting(name) that returns a promise. Then await two settings at once with Promise.all, and catch the error of a name that does not exist.

    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

A timer, a callback, an event, then the helpers

Each block turns a different kind of asynchronous work into a promise. wait() wraps setTimeout. translate() wraps an error-first callback: reject on an error, resolve with the result. once() turns the next click into a promise; the listener is added before the click. withResolvers lets a timer call resolve from outside, and Promise.try turns a synchronous throw into a rejection.

main.js

// 1. A timer: wait(ms, value) fulfils with value after ms milliseconds.
const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));

// 2. A callback API with error-first callbacks (a fake dictionary).
function lookUp(word, callback) {
  setTimeout(() => {
    if (word === "cat") callback(null, "Katze");
    else callback(new Error(`no entry for ${word}`));
  }, 10);
}
function translate(word) {
  return new Promise((resolve, reject) => {
    lookUp(word, (error, result) => {
      if (error) reject(error);
      else resolve(result);
    });
  });
}

// 3. A one-time event.
const once = (target, type) =>
  new Promise((resolve) => target.addEventListener(type, resolve, {once: true}));

console.log(await wait(10, "timer done"));
console.log("cat:", await translate("cat"));
try {
  await translate("dog");
} catch (error) {
  console.log("dog:", error.message);
}

const button = new EventTarget();
const clicked = once(button, "click"); // listen first
setTimeout(() => button.dispatchEvent(new Event("click")), 10);
console.log("event:", (await clicked).type);

// 4. withResolvers: resolve from outside an executor.
const {promise, resolve} = Promise.withResolvers();
setTimeout(() => resolve("resolved from outside"), 10);
console.log(await promise);

// 5. Promise.try: a function that may throw before it returns anything.
const parse = (text) => JSON.parse(text);
console.log("try:", await Promise.try(parse, "{bad").catch((error) => error.name));

Run it with

node main.js

Output

timer done
cat: Katze
dog: no entry for dog
event: click
resolved from outside
try: SyntaxError
  • Every function here returns a promise, so the program uses await and try/catch the same way for all of them.
  • The click promise was created before the event fired. Created after it, it would never settle.
  • JSON.parse threw at once, and Promise.try turned it into a rejection that catch could handle.
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.

Exercises

Exercise 1 of 2

Promisify a callback API

config.js has readConfig(name, callback), which calls back with (null, value), or with an Error for an unknown name. Write readConfigAsync(name) so that it returns a promise: fulfilled with the value, or rejected with readConfig’s error. The starter returns from the callback, which does not give the caller anything.

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.

Hints
  1. Hint 1

    A return inside the callback goes back to readConfig, not to your caller. What should readConfigAsync return instead?

  2. Hint 2

    Return new Promise((resolve, reject) => { … }) and call readConfig inside it.

  3. Hint 3

    In the callback: if (error) reject(error); else resolve(value);

Show a solution

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

import {readConfig} from "./config.js";

export function readConfigAsync(name) {
  return new Promise((resolve, reject) => {
    readConfig(name, (error, value) => {
      if (error) reject(error);
      else resolve(value);
    });
  });
}
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

import {readConfig} from "./config.js";

export function readConfigAsync(name) {
  return readConfig(name, (error, value) => value);
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readConfigAsync} from './main.js';

test('readConfigAsync returns a promise', () => {
  const result = readConfigAsync('theme');
  assert.ok(result instanceof Promise, `readConfigAsync returned ${result}, not a promise`);
});

test('A known name fulfils with its value', async () => {
  const got = await readConfigAsync('size');
  assert.equal(got, 14, `readConfigAsync('size') gave ${got}`);
});

test('An unknown name rejects with the callback error', async () => {
  await assert.rejects(readConfigAsync('font'), {message: 'no config named font'}, 'readConfigAsync should reject for an unknown name');
});

config.js

// A callback API: calls back with (error, value) after 5 ms.
const configs = {theme: "dark", size: 14};

export function readConfig(name, callback) {
  setTimeout(() => {
    if (name in configs) callback(null, configs[name]);
    else callback(new Error(`no config named ${name}`));
  }, 5);
}

package.json

{
  "type": "module"
}

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

Run the program:

node main.js

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

node --test
Download learnrun.js

Exercise 2 of 2

Wait for an event, with a time limit

waitFor(target, type, ms) should fulfil with the event when target fires type within ms milliseconds, and otherwise reject with an Error whose message is no <type> within <ms> ms. The starter’s timer hands the Error to resolve, so a missing event looks like a success. Fix 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.

Hints
  1. Hint 1

    Run the tests: the timeout cases fulfil. Which of the two functions makes a promise fail?

  2. Hint 2

    resolve(new Error(…)) fulfils with an Error object as the value. Only reject makes the promise reject.

  3. Hint 3

    setTimeout(() => reject(new Error(`no ${type} within ${ms} ms`)), ms);

Show a solution

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

export function waitFor(target, type, ms) {
  return new Promise((resolve, reject) => {
    target.addEventListener(type, resolve, {once: true});
    setTimeout(() => reject(new Error(`no ${type} within ${ms} ms`)), ms);
  });
}
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

export function waitFor(target, type, ms) {
  return new Promise((resolve, reject) => {
    target.addEventListener(type, resolve, {once: true});
    setTimeout(() => resolve(new Error(`no ${type} within ${ms} ms`)), ms);
  });
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {waitFor} from './main.js';

test('An event in time fulfils with the event', async () => {
  const door = new EventTarget();
  const opened = waitFor(door, 'open', 50);
  setTimeout(() => door.dispatchEvent(new Event('open')), 5);
  const event = await opened;
  assert.equal(event.type, 'open', `waitFor fulfilled with ${event}`);
});

test('No event in time rejects with no open within 20 ms', async () => {
  const door = new EventTarget();
  await assert.rejects(waitFor(door, 'open', 20), {message: 'no open within 20 ms'}, 'waitFor should reject when the event does not come');
});

test('An event after the time limit does not change the result', async () => {
  const door = new EventTarget();
  const late = waitFor(door, 'open', 10);
  setTimeout(() => door.dispatchEvent(new Event('open')), 30);
  await assert.rejects(late, {message: 'no open within 10 ms'}, 'the late event should not turn the rejection into a success');
});

package.json

{
  "type": "module"
}

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

Run the program:

node main.js

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

node --test
Download learnrun.js

Common mistakes

Returning from the callback instead of calling resolve

function wait(ms) {
  return new Promise(() => {
    setTimeout(() => {
      return "done";
    }, ms);
  });
}
console.log(await wait(10));

What Node.js prints

Warning: Detected unsettled top-level await

Why, and the fix

The return goes back to the timer, which ignores it, and the executor never receives resolve at all. The promise stays pending forever, so the top-level await never finishes and Node.js stops with exit code 13. Take resolve as the executor’s parameter and call it: new Promise((resolve) => setTimeout(() => resolve("done"), ms)).

Throwing inside a callback in the executor

function checkedWait(ms) {
  return new Promise((resolve) => {
    setTimeout(() => {
      if (ms > 50) throw new Error("too long");
      resolve(ms);
    }, 0);
  });
}
checkedWait(100)
  .then((ms) => console.log("waited", ms))
  .catch((error) => console.log("caught:", error.message));

What Node.js prints

Error: too long

Why, and the fix

Only a throw directly in the executor rejects the promise. This throw happens later, inside the timer’s callback, after the executor has returned, so no promise sees it: it is an uncaught error and the catch never runs. Report failures in callbacks with reject: take reject as a parameter and call reject(new Error("too long")); return.

Passing two values to resolve

const person = new Promise((resolve) => resolve("Ada", 36));
const [name, age] = await person;
console.log(name, age.toFixed(1));

What Node.js prints

TypeError: age.toFixed is not a function

Why, and the fix

A promise holds exactly one value; resolve ignores every argument after the first. So person fulfils with the string "Ada", and destructuring a string gives its letters: name is "A", age is "d". Put several values into one: resolve({name: "Ada", age: 36}) or resolve(["Ada", 36]).

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

new Promise wraps a callback

new Promise((resolve, reject) => { … }) runs its function, the executor, at once, before the next line. Inside, start the work and, in its callback, call resolve(value) on success or reject(error) on failure. The executor’s return value is ignored, so return in a callback never settles anything. Only the first call to resolve or reject counts; later calls do nothing. A throw directly in the executor rejects the promise, but a throw later, inside a timer callback, is an uncaught error.

Events and withResolvers

An event can happen many times, a promise settles once. For a one-time event, pass resolve as the listener with {once: true}, which removes the listener after its first call. Add the listener before the event can fire: a promise does not see an event that has already happened. When resolve must be called from somewhere outside the executor, such as another function, Promise.withResolvers() gives you an object with promise, resolve and reject.

Ready-made helpers

Promise.try(fn, ...args) calls fn at once and always gives a promise: fulfilled with its value, or rejected if it throws, even synchronously. Promise.resolve(fn()) would let a synchronous throw escape. In Node.js, util.promisify(fn) turns a function with an error-first (error, value) callback into one that returns a promise, and node:timers/promises has a setTimeout(ms, value) that returns a promise. In the browser you write wrappers like these yourself.

Sources

Last reviewed October 5, 2026