How to Annul Promises in JavaScript

Creating Cancelable Tasks in JavaScript with Promise.withResolvers and AbortController

In JavaScript, you might already know how to cancel a request: you can use xhr.abort() for XHR and signal for fetch. But how do you cancel a regular Promise?

Currently, JavaScript's Promise does not natively provide an API to cancel a regular Promise. So, what we’ll discuss next is how to discard/ignore the result of a Promise.

Method 1: Using the New Promise.withResolvers()

A new API that can now be used is Promise.withResolvers(). It returns an object containing a new Promise object and two functions to resolve or reject it.

Here’s how the code looks:

let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

Now we can do this:

const { promise, resolve, reject } = Promise.withResolvers();

So we can utilize this to expose a cancel method:

const buildCancelableTask = <T>(asyncFn: () => Promise<T>) => {
  let rejected = false;
  const { promise, resolve, reject } = Promise.withResolvers<T>();

  return {
    run: () => {
      if (!rejected) {
        asyncFn().then(resolve, reject);
      }

      return promise;
    },

    cancel: () => {
      rejected = true;
      reject(new Error('CanceledError'));
    },
  };
};

Then we can use it with the following test code:

const sleep = (ms: number) => new Promise(res => setTimeout(res, ms));

const ret = buildCancelableTask(async () => {
  await sleep(1000);
  return 'Hello';
});

(async () => {
  try {
    const val = await ret.run();
    console.log('val: ', val);
  } catch (err) {
    console.log('err: ', err);
  }
})();

setTimeout(() => {
  ret.cancel();
}, 500);

Here, we preset the task to take at least 1000ms, but we cancel it within the next 500ms, so you will see:

Note that this is not true cancellation but an early rejection. The original asyncFn() will continue to execute until it resolves or rejects, but it doesn’t matter because the promise created with Promise.withResolvers<T>() has already been rejected.

Method 2: Using AbortController

Just like we cancel fetch requests, we can implement a listener to achieve early rejection. It looks like this:

const buildCancelableTask = <T>(asyncFn: () => Promise<T>) => {
  const abortController = new AbortController();

  return {
    run: () =>
      new Promise<T>((resolve, reject) => {
        const cancelTask = () => reject(new Error('CanceledError'));

        if (abortController.signal.aborted) {
          cancelTask();
          return;
        }

        asyncFn().then(resolve, reject);

        abortController.signal.addEventListener('abort', cancelTask);
      }),

    cancel: () => {
      abortController.abort();
    },
  };
};

It has the same effect as mentioned above but uses AbortController. You can use other listeners here, but AbortController provides the additional benefit that if you call cancel multiple times, it won’t trigger the 'abort' event more than once.

Based on this code, we can go further to build a cancelable fetch. This can be useful in scenarios like sequential requests, where you might want to discard previous request results and use the latest request results.

const buildCancelableFetch = <T>(
  requestFn: (signal: AbortSignal) => Promise<T>,
) => {
  const abortController = new AbortController();

  return {
    run: () =>
      new Promise<T>((resolve, reject) => {
        if (abortController.signal.aborted) {
          reject(new Error('CanceledError'));
          return;
        }

        requestFn(abortController.signal).then(resolve, reject);
      }),

    cancel: () => {
      abortController.abort();
    },
  };
};

const ret = buildCancelableFetch(async signal => {
  return fetch('http://localhost:5000', { signal }).then(res =>
    res.text(),
  );
});

(async () => {
  try {
    const val = await ret.run();
    console.log('val: ', val);
  } catch (err) {
    console.log('err: ', err);
  }
})();

setTimeout(() => {
  ret.cancel();
}, 500);

Please note that this does not affect the server-side processing logic; it merely causes the browser to discard/cancel the request. In other words, if you send a POST request to update user information, it may still take effect. Therefore, this is more commonly used in scenarios where a GET request is made to fetch new data.

Building a Simple Sequential Request React Hook

We can further encapsulate a simple sequential request React hook:

import { useCallback, useRef } from 'react';

type RequestFn<T> = (signal: AbortSignal) => Promise<T>;

const buildCancelableFetch = <T>(
  requestFn: (signal: AbortSignal) => Promise<T>,
) => {
  const abortController = new AbortController();

  return {
    run: () =>
      new Promise<T>((resolve, reject) => {
        if (abortController.signal.aborted) {
          reject(new Error('CanceledError'));
          return;
        }

        requestFn(abortController.signal).then(resolve, reject);
      }),

    cancel: () => {
      abortController.abort();
    },
  };
};

function useLatest<T>(value: T) {
  const ref = useRef(value);
  ref.current = value;

  return ref;
}

export function useSequentialRequest<T>(requestFn: RequestFn<T>) {
  const requestFnRef = useLatest(requestFn);
  const currentRequest = useRef<{ cancel: () => void } | null>(null);

  return useCallback(() => {
    if (currentRequest.current) {
      currentRequest.current.cancel();
    }

    const { run, cancel } = buildCancelableFetch(
      requestFnRef.current,
    );
    currentRequest.current = { cancel };

    const promise = run().then(res => {
      currentRequest.current = null;
      return res;
    });

    return promise;
  }, [requestFnRef]);
}

Then we can simply use it:

import { useSequentialRequest } from './useSequentialRequest';

export function App() {
  const run = useSequentialRequest(async (signal: AbortSignal) => {
    const ret = await fetch('http://localhost:5000', { signal }).then(
      res => res.text(),
    );
    console.log(ret);
  });

  return (
    <button onClick={run}>Run</button>
  );
}

This way, when you click the button multiple times quickly, you will only get the latest request data, discarding the previous requests.

Do you have other use cases? Feel free to share with me!

If you find my content helpful, please consider subscribing. I send a weekly newsletter every Sunday with the latest web development updates. Thanks for your support!

Join the conversation

or to participate.