Skip to content
aviral gupta

// B4.1 · ~32 min · Beginner

From callbacks to promises

After this lesson you can read and write error-first callbacks, and turn any callback API into a promise you can await: with a built-in promise API, util.promisify, or a wrapper of your own.

Lesson 1 of 5 in B4 Asynchronous Node

Start of the module

You will be able to

  • Read and write error-first callbacks, and explain why they nest and why try/catch misses their errors
  • Use the promise APIs of node:fs/promises and node:timers/promises, and util.promisify where it fits
  • Wrap a callback API by hand with new Promise, and call a callback exactly once
  1. Warm-up · Activity 1 of 7

    Warm-up from B3.2: which of these calls return a promise that you can await? Pick all that apply.

    Select all that apply.

  2. Predict · Activity 2 of 7

    Predict before you read on. later() calls its callback with an error after 10 ms. What does this program print?

    function later(callback) {
      setTimeout(() => callback(new Error('disk full')), 10);
    }
    
    try {
      later((err) => {
        if (err) throw err;
      });
      console.log('started');
    } catch (error) {
      console.log('caught', error.message);
    }
  3. Practice · Activity 3 of 7

    readFile and promisify are imported from node:fs and node:util. Fill in the function that turns the callback readFile into one that returns a promise.

    const readFileAsync = ____(readFile);
    const text = await readFileAsync('notes.txt', 'utf8');
    console.log(text.trim());
    const readFileAsync = (readFile);
  4. Practice · Activity 4 of 7

    Each callback API on the left has a promise form. Match each one to the promise form you should use.

  5. Practice · Activity 5 of 7

    lookup() calls its callback with the result only, not error-first. What does this program print?

    import {promisify} from 'node:util';
    
    function lookup(name, callback) {
      setTimeout(() => callback(name.toUpperCase()), 10);
    }
    
    const lookupAsync = promisify(lookup);
    try {
      console.log(await lookupAsync('ada'));
    } catch (error) {
      console.log('rejected with', error);
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. parsePort follows the error-first convention, or so it seems. What does this program print?

    function parsePort(text, callback) {
      const port = Number(text);
      if (!Number.isInteger(port)) callback(new Error('bad port: ' + text));
      callback(null, port);
    }
    
    parsePort('abc', (err, port) => {
      if (err) console.log('error:', err.message);
      else console.log('port', port);
    });
  7. Apply · Activity 7 of 7

    Mini-task. In main.js, write readSetting(name, callback): it reads settings.json with readFile from node:fs, parses it, and calls callback(null, value) for that key, or callback(err) for a missing file or broken JSON, exactly once in each case. Then make readSettingAsync = promisify(readSetting), await it in try/catch and print theme: dark, or the error's code or name. Try it with settings.json present, missing and broken.

    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

One task in callbacks, promisify and fs/promises

first.txt names a second file, and the program reads both, in order, three ways. With callbacks, the second read nests inside the first. A small promise wrapped by hand lets top-level await wait for the callback version. Then util.promisify(readFile) and readFile from node:fs/promises do the same with await, flat. setTimeout from node:timers/promises fulfils with the value you give it, and a missing file arrives as a rejection that try/catch can handle.

main.js

import {readFile} from 'node:fs';
import {readFile as readFilePromise} from 'node:fs/promises';
import {setTimeout as delay} from 'node:timers/promises';
import {promisify} from 'node:util';

// 1. Error-first callbacks: the second read nests inside the first.
function viaCallbacks(done) {
  readFile('first.txt', 'utf8', (err, next) => {
    if (err) return done(err);
    readFile(next.trim(), 'utf8', (err, message) => {
      if (err) return done(err);
      done(null, message.trim());
    });
  });
}

// 2. Wrapped by hand, so that await can wait for it.
const first = await new Promise((resolve, reject) => {
  viaCallbacks((err, message) => (err ? reject(err) : resolve(message)));
});
console.log('callbacks:', first);

// 3. util.promisify: the callback is last and error-first, so it fits.
const readFileAsync = promisify(readFile);
const next = await readFileAsync('first.txt', 'utf8');
console.log('promisify:', (await readFileAsync(next.trim(), 'utf8')).trim());

// 4. The built-in promise API: nothing to wrap.
const again = await readFilePromise('first.txt', 'utf8');
console.log('fs/promises:', (await readFilePromise(again.trim(), 'utf8')).trim());

console.log('timers/promises:', await delay(20, 'waited 20 ms'));

try {
  await readFilePromise('missing.txt', 'utf8');
} catch (error) {
  console.log('rejected with', error.code);
}

first.txt

second.txt

second.txt

Hello from second.txt

Run it with

node main.js

Output

callbacks: Hello from second.txt
promisify: Hello from second.txt
fs/promises: Hello from second.txt
timers/promises: waited 20 ms
rejected with ENOENT
  • The callback version needs two levels of nesting for two steps; the await versions stay flat.
  • The hand-made promise passes the error to reject and the message to resolve, so both outcomes reach await.
  • promisify(readFile) and readFile from node:fs/promises give the same text; prefer the built-in one when it exists.
  • delay(20, value) fulfils with value once 20 ms have passed.

Exercises

Exercise 1 of 2

Your own promisify

findUser(id, callback) is a callback API: after 10 ms it calls callback(null, user) for id 1, and callback(error) for any other id. Write toPromise(fn): it returns a function that takes the same arguments as fn, minus the callback, and returns a promise. The promise fulfils with the value and rejects with the error the callback receives. It must work for any function with an error-first callback last, such as add(a, b, callback). This part runs in the browser too.

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

    The returned function must return new Promise((resolve, reject) => { ... }).

  2. Hint 2

    Call fn inside the executor, with ...args and then your own callback as the last argument.

  3. Hint 3

    In that callback: if (err) reject(err); else resolve(value);

Show a solution

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

// A callback API, as older libraries have it: error first, value second.
export function findUser(id, callback) {
  setTimeout(() => {
    if (id === 1) callback(null, {id: 1, name: 'Ada'});
    else callback(new Error('no user ' + id));
  }, 10);
}

// Returns a promise version of fn. fn takes an error-first callback as its last argument.
export function toPromise(fn) {
  return (...args) =>
    new Promise((resolve, reject) => {
      fn(...args, (err, value) => {
        if (err) reject(err);
        else resolve(value);
      });
    });
}

const findUserAsync = toPromise(findUser);
console.log(await findUserAsync(1));
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// A callback API, as older libraries have it: error first, value second.
export function findUser(id, callback) {
  setTimeout(() => {
    if (id === 1) callback(null, {id: 1, name: 'Ada'});
    else callback(new Error('no user ' + id));
  }, 10);
}

// Returns a promise version of fn. fn takes an error-first callback as its last argument.
export function toPromise(fn) {
  return (...args) => {
    fn(...args, (err, value) => {
      // settle a promise here
    });
  };
}

const findUserAsync = toPromise(findUser);
console.log(await findUserAsync(1));

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {findUser, toPromise} from './main.js';

test('the new function returns a promise', () => {
  const result = toPromise(findUser)(1);
  assert.equal(typeof result?.then, 'function', 'toPromise(findUser)(1) should return a promise');
});

test('the promise fulfils with the value from the callback', async () => {
  const user = await toPromise(findUser)(1);
  assert.deepEqual(user, {id: 1, name: 'Ada'}, `expected {id: 1, name: 'Ada'}, got ${JSON.stringify(user)}`);
});

test('the promise rejects with the error from the callback', async () => {
  await assert.rejects(async () => toPromise(findUser)(2), {message: 'no user 2'}, 'toPromise(findUser)(2) should reject with the error "no user 2"');
});

test('every argument is passed on, with the callback last', async () => {
  const add = (a, b, callback) => setTimeout(() => callback(null, a + b), 1);
  const sum = await toPromise(add)(2, 3);
  assert.equal(sum, 5, `toPromise(add)(2, 3) should fulfil with 5, got ${sum}`);
});

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

Count lines with a promise

countLines(path, callback) counts the non-empty lines of a file with the callback readFile. Turn it into countLines(path), which returns a promise: it fulfils with the count, and rejects with the read error unchanged, so a missing file rejects with ENOENT. Use readFile from node:fs/promises (or util.promisify). Then change the last lines to await it, so that node main.js still prints notes.txt: 3 lines. Check with node --test.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    import {readFile} from 'node:fs/promises' gives you a readFile that returns a promise.

  2. Hint 2

    Make countLines an async function: const text = await readFile(path, "utf8"); then return the count.

  3. Hint 3

    Don't catch the error inside countLines: a rejection passes to the caller unchanged. At the end: console.log('notes.txt:', await countLines('notes.txt'), 'lines');

Show a solution

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

import {readFile} from 'node:fs/promises';

// Fulfils with the number of non-empty lines in the file at path.
export async function countLines(path) {
  const text = await readFile(path, 'utf8');
  return text.split('\n').filter((line) => line !== '').length;
}

console.log('notes.txt:', await countLines('notes.txt'), 'lines');
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

import {readFile} from 'node:fs';

// Calls back with the number of non-empty lines in the file at path.
export function countLines(path, callback) {
  readFile(path, 'utf8', (err, text) => {
    if (err) return callback(err);
    callback(null, text.split('\n').filter((line) => line !== '').length);
  });
}

countLines('notes.txt', (err, count) => {
  if (err) return console.error(err.message);
  console.log('notes.txt:', count, 'lines');
});

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';
import {countLines} from './main.js';

test('countLines(path) returns a promise of the count', async () => {
  const result = countLines('notes.txt');
  assert.equal(typeof result?.then, 'function', 'countLines("notes.txt") should return a promise');
  assert.equal(await result, 3, 'notes.txt has 3 non-empty lines');
});

test('a missing file rejects with ENOENT', async () => {
  await assert.rejects(async () => countLines('missing.txt'), {message: /ENOENT/}, 'countLines("missing.txt") should reject with the ENOENT error');
});

test('the program prints notes.txt: 3 lines', async () => {
  const got = (await runMain()).trim();
  assert.equal(got, 'notes.txt: 3 lines', `the program printed ${JSON.stringify(got)}`);
});

notes.txt

buy milk
water plants
call Ada

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

Awaiting the callback form of readFile

import {readFile} from 'node:fs';

const text = await readFile('notes.txt', 'utf8');
console.log(text);

What Node.js prints

TypeError [ERR_INVALID_ARG_TYPE]: The "cb" argument must be of type function. Received type string ('utf8')

Why, and the fix

readFile from node:fs is the callback form: its last argument must be a function, and here it is the string 'utf8'. It never returns the text or a promise, so await has nothing to wait for. Import the promise form instead: import {readFile} from 'node:fs/promises'. Or keep node:fs and pass a callback.

Checking err but going on anyway

import {readFile} from 'node:fs';

readFile('missing.txt', 'utf8', (err, text) => {
  if (err) console.error('could not read missing.txt');
  console.log(text.trim());
});

What Node.js prints

TypeError: Cannot read properties of undefined (reading 'trim')

Why, and the fix

The error is noticed and reported, but nothing stops the callback, so the next line runs with text undefined and crashes. Leave the callback after handling the error: if (err) { console.error(...); return; }. An else around the success path works too.

Promisifying a method without its object

import {promisify} from 'node:util';

class Store {
  constructor() {
    this.data = {theme: 'dark'};
  }
  get(key, callback) {
    callback(null, this.data[key]);
  }
}

const store = new Store();
const get = promisify(store.get);
console.log(await get('theme'));

What Node.js prints

TypeError: Cannot read properties of undefined (reading 'data')

Why, and the fix

promisify(store.get) takes the function alone, not the object it belongs to, so this is undefined when get runs. The docs warn about methods that use this. Bind the object first: promisify(store.get.bind(store)), or call the result with .call(store, "theme").

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

Error first, data second, once

Node.js's callback APIs take the callback as their last argument and call it when the work is done. By the convention Node.js adopted, its first parameter is the error: null on success, an Error on failure; the data comes second. So a callback starts with if (err) { ...; return; }. Without the return, the code below runs on undefined data. A throw inside a callback cannot be caught by a try around the call: the call has already returned when the callback runs, so the error crashes the program. And every step that needs the result of the step before nests one level deeper, which the docs call callback hell.

Promise forms: built in, or util.promisify

Many callback APIs have a promise twin you can await: readFile from node:fs/promises, and setTimeout from node:timers/promises, which fulfils after the delay with the value you pass: await setTimeout(100, 'done') gives 'done'. For a function without a twin, util.promisify(fn) returns a version that returns a promise. It assumes the callback is the last argument and error-first: a truthy first argument rejects the promise, the second argument fulfils it. So a callback called as callback(data) is read as an error. A method that uses this loses its object: promisify(store.get.bind(store)) keeps it.

Wrapping by hand, and settling once

When promisify does not fit, for example an API with separate success and error callbacks, wrap it yourself: return new Promise((resolve, reject) => ...) and settle it from the callback. Pass both outcomes on: a wrapper that only calls resolve turns every failure into undefined. An error thrown inside the executor rejects the promise. A promise settles once, and later resolve or reject calls are ignored, so a callback that fires twice is hidden, not fixed. When you write a callback API yourself, call the callback exactly once on every path: return right after callback(err), and call the success callback outside the try that guards your own work.

Sources

Last reviewed October 4, 2026