29

I'm toying with promises in JavaScript and tried to promisify setTimeout function:

function timeout(ms) {
  return new Promise(function(resolve, reject) {
    setTimeout(function() {
      resolve('timeout done');
    }, ms);
  }); 
}

var myPromise=timeout(3000); 

myPromise.then(function(result) { 
  console.log(result); // timeout done
})

Fairly straightforward but I was wondering how would I go about canceling my timeout before the promise resolves. timeout returns Promise object hence I loose access to value that setTimeout returns and cannot cancel timeout via clearTimeout. What woud be the best way to do it?

BTW there is no real purpose for this, I just wonder how this would be approached. Also I plunked it here http://plnkr.co/edit/NXFjs1dXWVFNEOeCV1BA?p=preview

Bergi
  • 630,263
  • 148
  • 957
  • 1,375
spirytus
  • 10,726
  • 14
  • 61
  • 75
  • you can use decorators as well, more details here: https://stackoverflow.com/a/61242606/1691423 – vlio20 Apr 16 '20 at 09:09

4 Answers4

32

Edit 2021 all platforms have converged on AbortController as the cancellation primitive and there is some built in support for this.

In Node.js

// import { setTimeout } from 'timers/promises' // in ESM
const { setTimeout } = require('timers/promises');
const ac = new AbortController();

// cancellable timeout
(async () => {
  await setTimeout(1000, null, { signal: ac.signal });
})();

// abort the timeout, rejects with an ERR_ABORT
ac.abort();

In Browsers

You can polyfill this API and use the same as the example above:


function delay(ms, value, { signal } = {}) {
    return new Promise((resolve, reject) => {
        const listener = () => {
            clearTimeout(timer);
            reject(signal.reason);
        };
        signal?.throwIfAborted();
        const timer = setTimeout(() => {
            signal?.removeEventListener('abort', listener);
            resolve(value);
        }, ms);
        signal?.addEventListener('abort', listener);
    });
}

What you can do it that, you can return a canceller from your timeout function and invoke it when needed. This way you do not need to store the timeoutid globally (or on the outer scope) and also this can manage multiple calls to the function as well. Each instance of the object return by the function timeout will have its own canceler that can perform the cancellation.

function timeout(ms) {
  var timeout, promise;

  promise = new Promise(function(resolve, reject) {
    timeout = setTimeout(function() {
      resolve('timeout done');
    }, ms);
  }); 

  return {
           promise:promise, 
           cancel:function(){clearTimeout(timeout );} //return a canceller as well
         };
}

var timeOutObj =timeout(3000); 

timeOutObj.promise.then(function(result) { 
  console.log(result); // timeout done
});

//Cancel it.
timeOutObj.cancel();

Plnkr

Bergi
  • 630,263
  • 148
  • 957
  • 1,375
PSL
  • 123,204
  • 21
  • 253
  • 243
21

PSL's answer is right, however - there are a few caveats and I'd do it a bit differently.

  • A timeout being cleared means the code will not run - so we should reject the promise.
  • Returning two things isn't necessary in our case, we can monkey patch in JavaScript.

Here:

function timeout(ms, value) {
    var p = new Promise(function(resolve, reject) {
        p._timeout = setTimeout(function() {
            resolve(value);
        }, ms);
        p.cancel = function(err) {
            reject(err || new Error("Timeout"));
            clearTimeout(p._timeout); // We actually don't need to do this since we
                                      // rejected - but it's well mannered to do so
        };
    });
    return p;
}

Which would let us do:

var p = timeout(1500)
p.then(function(){
     console.log("This will never log");
})

p.catch(function(){
     console.log("This will get logged so we can now handle timeouts!")
})
p.cancel(Error("Timed out"));

One might be interested in full blown cancellation and indeed some libraries support this directly as a feature of the library. In fact I'd dare say most do. However, this causes interference problems. Quoting KrisKowal from here:

My position on cancellation has evolved. I am now convinced that cancellation (bg: that propagates) is inherently impossible with the Promise abstraction because promises can multiple dependess and dependees can be introduced at any time. If any dependee cancels a promise, it would be able to interfere with future dependees. There are two ways to get around the problem. One is to introduce a separate cancellation "capability", perhaps passed as an argument. The other is to introduce a new abstraction, a perhaps thenable "Task", which in exchange for requiring that each task only have one observer (one then call, ever), can be canceled without fear of interference. Tasks would support a fork() method to create a new task, allowing another dependee to retain the task or postpone cancellation.

peja
  • 866
  • 10
  • 19
Benjamin Gruenbaum
  • 270,886
  • 87
  • 504
  • 504
  • 4
    Although I can't find it documented anywhere, a Promise's "settler" function would appear to run in the same event turn as `var p = new Promise()`, therefore you can't have references to `p` inside the settler. The solution (at least the only one I can think of) is rather ugly but works - [DEMO](http://jsfiddle.net/9bnjevvu/). – Roamer-1888 Aug 17 '14 at 16:53
  • Thus fixed, this is a better solution. – Roamer-1888 Aug 17 '14 at 18:21
  • As an addendum to the quote, kriskowal has put up some more thoughts [here](https://github.com/kriskowal/gtor/blob/master/cancelation.md) – Clark Pan Sep 11 '14 at 07:09
  • 2
    @Benjamin-Gruenbaum has the better answer. If you add members to the promise then you will not be able to cancel from a dependant promise (i.e., the .then() result); see [this article](https://blog.codecentric.de/en/2015/03/cancelable-async-operations-promises-javascript/) for more information – arolson101 Jun 19 '15 at 14:19
  • 5
    It gives `TypeError: Cannot set property '_timeout' of undefined ` in timeout function. – dd619 May 02 '19 at 09:22
3

The above to answers by @Benjamin and @PSL work, but what if you need the cancelable timeout to be used by an outside source while being canceled internally?

For example, the interaction might look somewhat like this:

// externally usage of timeout 
async function() {
  await timeout() // timeout promise 
} 

// internal handling of timeout 
timeout.cancel() 

I needed this kind of implementation myself, so here's what I came up with:

/**
 * Cancelable Timer hack.
 *
 *  @notes
 *    - Super() does not have `this` context so we have to create the timer
 *      via a factory function and use closures for the cancelation data.
 *    - Methods outside the consctutor do not persist with the extended
 *      promise object so we have to declare them via `this`.
 *  @constructor Timer
 */
function createTimer(duration) {
  let timerId, endTimer
  class Timer extends Promise {
    constructor(duration) {
      // Promise Construction
      super(resolve => {
        endTimer = resolve
        timerId = setTimeout(endTimer, duration)
      })
      // Timer Cancelation
      this.isCanceled = false
      this.cancel = function() {
        endTimer()
        clearTimeout(timerId)
        this.isCanceled = true
      }
    }
  }
  return new Timer(duration)
}

Now you can use the timer like this:

let timeout = createTimer(100)

And have the promise canceled somewhere else:

 if (typeof promise !== 'undefined' && typeof promise.cancel === 'function') {
  timeout.cancel() 
}
Lorenzo
  • 31
  • 2
1

This is my answer in TypeScript:

  private sleep(ms) {
    let timerId, endTimer;
    class TimedPromise extends Promise<any> {
      isCanceled: boolean = false;
      cancel = () => {
        endTimer();
        clearTimeout(timerId);
        this.isCanceled = true;
      };
      constructor(fn) {
        super(fn);
      }
    }
    return new TimedPromise(resolve => {
      endTimer = resolve;
      timerId = setTimeout(endTimer, ms);
    });
  }

Usage:

const wait = sleep(10*1000);
setTimeout(() => { wait.cancel() },5 * 1000);
await wait; 
Peter Hansen
  • 21,046
  • 5
  • 50
  • 72
Richard
  • 14,427
  • 9
  • 57
  • 85