The gotcha of unhandled promise rejections

Let's say you wanted to display a bunch of chapters on the page, and for whatever reason, the API only gives you a chapter at a time. You could do this:

async function showChapters(chapterURLs) {
  for (const url of chapterURLs) {
    const response = await fetch(url);
    const chapterData = await response.json();
    appendChapter(chapterData);
  }
}

This gives the correct result – all the chapters appear in the right order. But, it's kinda slow, because it waits for each chapter to finish fetching before it tries to fetch the next one.

Alternatively, you could do the fetches in parallel:

async function showChapters(chapterURLs) {
  const chapterPromises = chapterURLs.map(async (url) => {
    const response = await fetch(url);
    return response.json();
  });

  const chapters = await Promise.all(chapterPromises);

  for (const chapterData of chapters) appendChapter(chapterData);
}

Great! Except, you're now waiting on the last chapter before showing the first.

For the best performance, do the fetches in parallel, but handle them in sequence:

async function showChapters(chapterURLs) {
  const chapterPromises = chapterURLs.map(async (url) => {
    const response = await fetch(url);
    return response.json();
  });

  for await (const chapterData of chapterPromises) {
    appendChapter(chapterData);
  }
}

However, this has introduced a tricky bug involving unhandled promise rejections.

Unhandled promise rejections

Unhandled promise rejections happen when a promise… is rejected… but isn't handled.

Ok ok, they're like the promise equivalent of an uncaught error. Like this:

const promise = Promise.reject(Error('BAD'));

The rejected state of this promise is 'unhandled' because nothing is dealing with the rejection.

Here are a few ways it could be handled:

// Like this:
try {
  await promise;
} catch (err) {
  // …
}

// Or this:
promise.catch(() => {
  // …
});

// Or even just this:
await promise;
// In this case the promise is handled,
// but a rejection will be turned into a throw.

// Including this:
promise.then(() => {
  // …
});
// …although, since this doesn't handle the rejected case,
// it returns a new promise that's also rejected,
// and that new promise will be unhandled (all new promises are unhandled).

A promise is handled when something is done in reaction to that promise, even if it's creating another rejected promise, or turning a rejected promise into a throw.

Once a promise is rejected, you have until just-after the next processing of microtasks to handle that rejection, else it may count as an unhandled rejection ('may', because there's a little bit of wiggle room with task queuing).

const promise = Promise.reject(Error('BAD'));

// You can handle the promise here
// without it being an 'unhandled rejection'.

queueMicrotask(() => {
  // Or here.
});

setTimeout(() => {
  // But here might be too late.
}, 0);

Unhandled rejections are problematic

Unhandled rejections are a bit like uncaught errors, in that they cause the entire program to exit with an error code in Node and Deno.

In browsers, you get errors appearing in the console, again similar to uncaught errors:

In the console: Uncaught (in promise) TypeError: Failed to fetch

They might also appear in error logging systems, if the system listens for unhandled rejections:

addEventListener('unhandledrejection', (event) => {
  // …Log the error to the server…
});

The point is, you want to avoid unhandled rejections.

But where are the unhandled rejections in the example?

It's not immediately obvious:

async function showChapters(chapterURLs) {
  const chapterPromises = chapterURLs.map(async (url) => {
    const response = await fetch(url);
    return response.json();
  });

  for await (const chapterData of chapterPromises) {
    appendChapter(chapterData);
  }
}

The promises in chapterPromises are handled by the for await in each iteration of the loop. When the loop encounters a rejected promise, it becomes a throw, which abandons the function and rejects the promise showChapters returned.

The bug happens if a promise rejects before the for await handles that promise, or if that promise is never reached.

For example: If chapterPromises[0] takes a long time to resolve, and meanwhile chapterPromises[1] rejects, then chapterPromises[1] is an unhandled rejection, because the loop hasn't reached it yet.

Or: If chapterPromises[0] and chapterPromises[1] reject, then chapterPromises[1] is an unhandled rejection, because the loop is abandoned before it gets to chapterPromises[1].

Ugh. The "unhandled promise rejection" feature is there so you don't 'miss' rejected promises, but in this case it's a false positive, because the promise returned by showChapters already sufficiently captures the success/failure of the operation.

This issue doesn't always involve fetches, it could be any bit of work you start early, then pick up the result later. Like a worker task.

This doesn't always involve for await either. It impacts any situation where you start the work early, then handle the result later, asynchronously.

It wasn't an issue for the Promise.all example:

async function showChapters(chapterURLs) {
  const chapterPromises = chapterURLs.map(async (url) => {
    const response = await fetch(url);
    return response.json();
  });

  const chapters = await Promise.all(chapterPromises);

  for (const chapterData of chapters) appendChapter(chapterData);
}

In this case all the promises in chapterPromises are handled immediately, by Promise.all, which returns a single promise that's immediately handled by the await. But this solution has worse performance than our sequential solution.

What's the real solution?

Unfortunately it's a bit of a hack. The solution is to immediately mark the promises as handled, before they have a chance to become unhandled rejections.

async function showChapters(chapterURLs) {
  const chapterPromises = chapterURLs.map(async (url) => {
    const response = await fetch(url);
    return response.json();
  });

  // TODO: 'handle' every promise in chapterPromises here

  for await (const chapterData of chapterPromises) {
    appendChapter(chapterData);
  }
}

One way to do this is to add a dummy catch handler to each promise:

for (const promise of chapterPromises) promise.catch(() => {});

This doesn't change the promises other than marking them as 'handled'. They're still rejected promises. It doesn't cause errors to be missed/swallowed elsewhere.

A shorter way to achieve the same thing is Promise.allSettled:

Promise.allSettled(chapterPromises);

This works because allSettled handles all the promises you give it, similar to Promise.all, but unlike Promise.all it never returns a rejected promise itself (unless something is fundamentally wrong with the input iterator).

Both of these look pretty hacky, and likely to confuse others that read the code later. Because of this, I'd probably create a helper function like preventUnhandledRejections:

// In promise-utils.js:
export function preventUnhandledRejections(...promises) {
  for (const promise of promises) promise.catch(() => {});
}

And comment its usage:

import { preventUnhandledRejections } from './promise-utils.js';

async function showChapters(chapterURLs) {
  const chapterPromises = chapterURLs.map(async (url) => {
    const response = await fetch(url);
    return response.json();
  });

  // Avoid unhandled rejections leaking out of this function.
  // The subsequent `for await` handles all the relevant promises.
  preventUnhandledRejections(...chapterPromises);

  for await (const chapterData of chapterPromises) {
    appendChapter(chapterData);
  }
}

I wish there was a less 'blunt' way of handling this in JavaScript, but I'm not sure what that would look like. The design of the "unhandled rejections" feature directly clashes with starting work early and handling the result later, or not handling the result if a prerequisite fails.

In the meantime, preventUnhandledRejections does the trick!

For completeness, here's an abortable implementation of showChapters, that also handles bad responses.

Thanks to Surma and Thomas Steiner for proof-reading.

View this page on GitHub

Comments powered by Disqus

Jake Archibald next to a 90km sign

Hello, I’m Jake and that is my tired face. I’m a developer of sorts.

Elsewhere

Contact

Feel free to throw me an email, unless you're a recruiter, or someone trying to offer me 'sponsored content' for this site, in which case write your request on a piece of paper, and fling it out the window.