Skip to content
aviral gupta

// B4.2 · ~32 min · Beginner

async/await and error handling

After this lesson you can await Node.js APIs with try/catch/finally, clean up in finally, tell when a rejection escapes your try and what Node.js does then, and rethrow errors with their cause.

Lesson 2 of 5 in B4 Asynchronous Node

You will be able to

  • Await Node.js APIs in try/catch/finally, and close a FileHandle in finally
  • Explain when a rejection escapes a try, and what Node.js does with an unhandled rejection
  • Rethrow with new Error(message, {cause}), and use top-level await in ES modules
  1. Warm-up · Activity 1 of 7

    Warm-up from B1.1: this program rejects a promise and nothing handles it. You run node main.js with no flags. What happens?

    Promise.reject(new Error('lost'));
    setTimeout(() => console.log('after'), 10);
  2. Predict · Activity 2 of 7

    Predict before you read on. missing.txt does not exist, and the catch throws the error again. What does this program print?

    import {readFile} from 'node:fs/promises';
    
    async function load() {
      try {
        return await readFile('missing.txt', 'utf8');
      } catch (error) {
        console.log('catch', error.code);
        throw error;
      } finally {
        console.log('finally');
      }
    }
    
    try {
      await load();
    } catch {
      console.log('outer');
    }
  3. Practice · Activity 3 of 7

    Fill in the option name so that the new error keeps the original one, and error.cause.code is still ENOENT.

    try {
      await readFile('settings.json', 'utf8');
    } catch (error) {
      throw new Error('cannot read settings', {____: error});
    }
    throw new Error('cannot read settings', {: error});
  4. Practice · Activity 4 of 7

    A program rejects a promise that nothing handles, and has no 'unhandledRejection' listener. Match each --unhandled-rejections mode to what happens.

  5. Practice · Activity 5 of 7

    missing.txt does not exist. What does this program print?

    import {open} from 'node:fs/promises';
    
    let file;
    try {
      file = await open('missing.txt');
      console.log(await file.readFile('utf8'));
    } catch (error) {
      console.log(error.code);
    } finally {
      await file?.close();
      console.log('closed');
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. The read starts first, then the program waits 100 ms for something else, and only then awaits the read inside try. missing.json does not exist. What happens?

    import {readFile} from 'node:fs/promises';
    import {setTimeout} from 'node:timers/promises';
    
    const text = readFile('missing.json', 'utf8'); // starts the read now
    await setTimeout(100); // something else first
    try {
      console.log(await text);
    } catch (error) {
      console.log('caught', error.code);
    }
  7. Apply · Activity 7 of 7

    Mini-task. Write loadConfig(path) in main.js: open the file with open from node:fs/promises, read and parse it, and close it in finally, also when parsing fails. Wrap every failure in new Error('cannot load ' + path, {cause: error}). At the top level, await loadConfig('config.json') in try/catch and print port 8080, or the message and the cause's code or name to stderr, with process.exitCode = 1. Try a good, a missing and a broken config.json.

    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

Load JSON through a FileHandle, with finally and a cause

loadJson opens a file, parses it and always closes it in finally, which prints what it did. Every failure is rethrown with a clear message and the original error as its cause. The loop at the top level awaits three files: a good one, a missing one and a broken one. Last, loadJson is called without await: its rejection reaches no catch, so the program listens for unhandledRejection, which would otherwise stop it with exit code 1.

main.js

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

// Reads a JSON file through a FileHandle, and always closes it.
async function loadJson(path) {
  let file;
  try {
    file = await open(path, 'r');
    return JSON.parse(await file.readFile('utf8'));
  } catch (error) {
    throw new Error('cannot load ' + path, {cause: error});
  } finally {
    await file?.close();
    console.log('  finally:', file ? 'closed ' + path : 'nothing to close');
  }
}

// Top-level await: main.js is an ES module, so no async function is needed.
for (const path of ['settings.json', 'missing.json', 'broken.json']) {
  try {
    console.log(path, '->', await loadJson(path));
  } catch (error) {
    console.log(path, '->', error.message, '| cause:', error.cause.code ?? error.cause.name);
  }
}

// A promise nobody awaits: its rejection reaches no catch.
process.on('unhandledRejection', (reason) => {
  console.log('unhandledRejection:', reason.message);
});
loadJson('missing.json');

settings.json

{"theme": "dark"}

broken.json

{theme: dark}

Run it with

node main.js

Output

  finally: closed settings.json
settings.json -> { theme: 'dark' }
  finally: nothing to close
missing.json -> cannot load missing.json | cause: ENOENT
  finally: closed broken.json
broken.json -> cannot load broken.json | cause: SyntaxError
  finally: nothing to close
unhandledRejection: cannot load missing.json
  • finally prints before the result: it runs before loadJson hands its value or error back.
  • For missing.json, open failed, so file is undefined and file?.close() does nothing.
  • broken.json was opened, so it is closed although JSON.parse threw.
  • The cause keeps the original error: ENOENT from open, SyntaxError from JSON.parse.
  • The listener replaces the crash; a real program would also set process.exitCode = 1 there.

Exercises

Exercise 1 of 2

Context, cause and cleanup

loadProfile(id, fetchProfile, log) awaits fetchProfile(id), which returns a promise. Make it return the profile; when fetchProfile rejects, throw new Error('cannot load profile ' + id) with the original error as its cause; and push 'done ' + id to log in every case, after a success and after a failure, using finally. This part runs in the browser too: fetchProfile is a stand-in for a slow service.

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

    Put return await fetchProfile(id) inside try. Without await, the rejection would skip your catch.

  2. Hint 2

    In catch: throw new Error('cannot load profile ' + id, {cause: error});

  3. Hint 3

    finally { log.push('done ' + id); } runs after the return and after the throw.

Show a solution

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

// fetchProfile(id) returns a promise of a profile. log collects what happened.
export async function loadProfile(id, fetchProfile, log) {
  try {
    return await fetchProfile(id);
  } catch (error) {
    throw new Error('cannot load profile ' + id, {cause: error});
  } finally {
    log.push('done ' + id);
  }
}

// A stand-in for a slow service: id 1 exists, every other id fails.
const fakeFetch = (id) =>
  new Promise((resolve, reject) => {
    setTimeout(() => (id === 1 ? resolve({id, name: 'Ada'}) : reject(new Error('HTTP 404'))), 10);
  });

const log = [];
console.log(await loadProfile(1, fakeFetch, log));
try {
  await loadProfile(2, fakeFetch, log);
} catch (error) {
  console.log(error.message, '<-', error.cause?.message);
}
console.log(log);
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

// fetchProfile(id) returns a promise of a profile. log collects what happened.
export async function loadProfile(id, fetchProfile, log) {
  const profile = await fetchProfile(id);
  return profile;
}

// A stand-in for a slow service: id 1 exists, every other id fails.
const fakeFetch = (id) =>
  new Promise((resolve, reject) => {
    setTimeout(() => (id === 1 ? resolve({id, name: 'Ada'}) : reject(new Error('HTTP 404'))), 10);
  });

const log = [];
console.log(await loadProfile(1, fakeFetch, log));
try {
  await loadProfile(2, fakeFetch, log);
} catch (error) {
  console.log(error.message, '<-', error.cause?.message);
}
console.log(log);

main.test.js

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

const found = async (id) => ({id, name: 'Ada'});
const notFound = async () => {
  throw new Error('HTTP 404');
};

test('returns the profile', async () => {
  const got = await loadProfile(1, found, []);
  assert.deepEqual(got, {id: 1, name: 'Ada'}, `loadProfile returned ${JSON.stringify(got)}`);
});

test('a failure is thrown again with context', async () => {
  await assert.rejects(loadProfile(2, notFound, []), {message: 'cannot load profile 2'}, 'expected the message "cannot load profile 2"');
});

test('the original error is the cause', async () => {
  const original = new Error('HTTP 404');
  let caught;
  try {
    await loadProfile(2, async () => {
      throw original;
    }, []);
  } catch (error) {
    caught = error;
  }
  assert.equal(caught?.cause, original, 'error.cause should be the very error that fetchProfile rejected with');
});

test('log gets done <id> after a success and after a failure', async () => {
  const log = [];
  await loadProfile(1, found, log);
  await loadProfile(2, notFound, log).catch(() => {});
  assert.deepEqual(log, ['done 1', 'done 2'], `log is ${JSON.stringify(log)}`);
});

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

Always close the file

firstLine(path, openFile) opens a file, reads it and returns its first line; openFile is open from node:fs/promises, and the tests pass a stand-in to count close() calls. Close the file in every case, also when reading fails. If opening fails with ENOENT, throw new Error(path + ': no such file') with the original error as its cause; rethrow every other error unchanged. Run node main.js and 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

    Declare let file; before try, so that finally can see it.

  2. Hint 2

    finally { await file?.close(); } closes the file after return and after throw, and skips it when open failed.

  3. Hint 3

    In catch: if (error.code === 'ENOENT') throw new Error(path + ': no such file', {cause: error}); then throw error; for the rest.

Show a solution

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

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

// The first line of the file at path. openFile is open from node:fs/promises; the tests pass their own.
export async function firstLine(path, openFile = open) {
  let file;
  try {
    file = await openFile(path, 'r');
    const text = await file.readFile('utf8');
    return text.split('\n')[0];
  } catch (error) {
    if (error.code === 'ENOENT') throw new Error(path + ': no such file', {cause: error});
    throw error;
  } finally {
    await file?.close();
  }
}

console.log(await firstLine('notes.txt'));
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 {open} from 'node:fs/promises';

// The first line of the file at path. openFile is open from node:fs/promises; the tests pass their own.
export async function firstLine(path, openFile = open) {
  const file = await openFile(path, 'r');
  const text = await file.readFile('utf8');
  return text.split('\n')[0];
}

console.log(await firstLine('notes.txt'));

main.test.js

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

test('reads the first line of notes.txt', async () => {
  const got = await firstLine('notes.txt');
  assert.equal(got, 'buy milk', `firstLine returned ${JSON.stringify(got)}`);
});

test('a missing file gets a clear message, with ENOENT as the cause', async () => {
  let caught;
  try {
    await firstLine('missing.txt');
  } catch (error) {
    caught = error;
  }
  assert.equal(caught?.message, 'missing.txt: no such file', `the message was ${JSON.stringify(caught?.message)}`);
  assert.equal(caught?.cause?.code, 'ENOENT', 'error.cause.code should be ENOENT');
});

test('the file is closed after reading', async () => {
  let closed = 0;
  const fakeOpen = async () => ({readFile: async () => 'a\nb\n', close: async () => {
    closed++;
  }});
  const got = await firstLine('fake.txt', fakeOpen);
  assert.equal(got, 'a', `firstLine returned ${JSON.stringify(got)}`);
  assert.equal(closed, 1, `close() was called ${closed} time(s), expected once`);
});

test('the file is closed when reading fails, and the error passes through', async () => {
  let closed = 0;
  const fakeOpen = async () => ({
    readFile: async () => {
      throw new Error('read failed');
    },
    close: async () => {
      closed++;
    }
  });
  await assert.rejects(firstLine('fake.txt', fakeOpen), {message: 'read failed'}, 'the read error should pass through unchanged');
  assert.equal(closed, 1, `close() was called ${closed} time(s), expected once`);
});

notes.txt

buy milk
water plants

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

A promise without await inside try

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

try {
  readFile('missing.json', 'utf8').then((text) => console.log(text));
} catch (error) {
  console.log('could not read', error.code);
}
console.log('after');

What Node.js prints

Error: ENOENT: no such file or directory, open 'missing.json'

Why, and the fix

readFile returns a promise at once, and the try block ends before it rejects, so the catch never runs. The .then has no rejection handler either, so the rejection is unhandled: Node.js prints the error after "after" and exits with code 1. Await the promise inside the try: const text = await readFile(...).

await inside a callback that is not async

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

['a.txt', 'b.txt'].forEach((name) => {
  const text = await readFile(name, 'utf8');
  console.log(text);
});

What Node.js prints

SyntaxError: Unexpected reserved word

Why, and the fix

Top-level await works only in the body of the module itself. The arrow function given to forEach is a function of its own, and it is not async, so await is not allowed there and the whole file fails to load. Use a for...of loop at the top level: for (const name of names) { const text = await readFile(name, "utf8"); }.

Closing a file that was never opened

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

let file;
try {
  file = await open('missing.txt');
} finally {
  await file.close();
}

What Node.js prints

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

Why, and the fix

open rejected with ENOENT, so file is still undefined when finally runs, and file.close() throws a TypeError. That new error replaces the ENOENT one, which is lost. Write await file?.close(): the ?. skips the call when there is no file, and the original error reaches you.

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

try, catch, finally, and closing what you open

Inside try, await turns a rejection into a thrown error that catch receives, error.code included. finally runs after try or catch in every case: after success, after a caught error, after return, and when catch throws again. That makes it the place for cleanup. open() from node:fs/promises resolves to a FileHandle, an open file that you read with await file.readFile('utf8') and must close with await file.close(). Declare let file; before try, and write await file?.close() in finally: if open itself failed, file is undefined. The docs say not to rely on automatic closing; it may not happen.

When a rejection escapes, and what Node.js does

try/catch sees a rejection only while it awaits that promise inside the block. B1.1 showed return without await. A call without await escapes too, as does .then without .catch, and a promise started early, const p = readFile(...), that rejects while the program awaits something else, before try { await p } is reached. Node.js then emits 'unhandledRejection'. In the default mode, --unhandled-rejections=throw, a program with no listener for it raises it as an uncaught exception: the error is printed and the exit code is 1. With a listener, the program carries on. warn only prints a warning, warn-with-error-code also sets exit code 1, none stays silent.

Rethrow with a cause, and await at the top

When you catch a low-level error and throw your own, keep the original as its cause: throw new Error('cannot load settings.json', {cause: error}). Your message says what failed; error.cause.code still says why, such as ENOENT. An uncaught error prints both, the original under [cause]. In an ES module, a .mjs file or a .js file under "type": "module", await works at the top level, outside any function. CommonJS files have no top-level await: there it is a SyntaxError. Inside a callback that is not async, await is a SyntaxError even in a module. If a top-level await never settles, Node.js prints a warning and exits with code 13.

Sources

Last reviewed October 4, 2026