57

In this answer, a promise chain is built recursively.

Simplified slightly, we have :

function foo() {
    function doo() {
        // always return a promise
        if (/* more to do */) {
            return doSomethingAsync().then(doo);
        } else {
            return Promise.resolve();
        }
    }
    return doo(); // returns a promise
}

Presumably this would give rise to a call stack and a promise chain - ie "deep" and "wide".

I would anticipate a memory spike larger than either performing a recursion or building a promise chain alone.

  • Is this so?
  • Has anyone considered the memory issues of building a chain in this way?
  • Would memory consumption differ between promise libs?
Community
  • 1
  • 1
Roamer-1888
  • 19,138
  • 5
  • 33
  • 44
  • 4
    Can you run this on JSPerf? Why presume when you can test? – Evan Davis Apr 28 '15 at 17:23
  • 2
    *"Presumably this would give rise to a call stack"* nope, since it's asynchronous, the call stack would not grow out of control. – Kevin B Apr 28 '15 at 17:23
  • The difference between how promises and simple recursion handle some cases is fascinating, and shows some of the major benefits of using promises (here, they don't actually recurse). – ssube Apr 28 '15 at 17:25
  • 1
    you return a function, not the result of calling a function, so it's not "really" recursive in a workload-estimation sense. only if the return had to wait on execution would it get stacked up... – dandavis Apr 28 '15 at 17:25
  • https://alexn.org/blog/2017/10/11/javascript-promise-leaks-memory.html – Bergi Feb 09 '18 at 00:16
  • @Mathletics you want to be able to reason about what the code is going to do, not just profile. Profiling helps you know "how much" not "what" is going on. – Justin Meiners Nov 19 '18 at 19:00

5 Answers5

48

a call stack and a promise chain - ie "deep" and "wide".

Actually, no. There is no promise chain here as we know it from doSomeThingAsynchronous.then(doSomethingAsynchronous).then(doSomethingAsynchronous).… (which is what Promise.each or Promise.reduce might do to sequentially execute handlers if it was written this way).

What we are facing here is a resolve chain1 - what happens in the end, when the base case of the recursion is met, is something like Promise.resolve(Promise.resolve(Promise.resolve(…))). This is only "deep", not "wide", if you want to call it that.

I would anticipate a memory spike larger than either performing a recursion or building a promise chain alone.

Not a spike actually. You'd slowly, over time, build a bulk of promises that are resolved with the innermost one, all representing the same result. When, at the end of your task, the condition is fulfilled and the innermost promise resolved with an actual value, all of these promises should be resolved with the same value. That would end up with O(n) cost for walking up the resolve chain (if implemented naively, this might even be done recursively and cause a stack overflow). After that, all the promises except for the outermost can become garbage collected.

In contrast, a promise chain that is built by something like

[…].reduce(function(prev, val) {
    // successive execution of fn for all vals in array
    return prev.then(() => fn(val));
}, Promise.resolve())

would show a spike, allocating n promise objects at the same time, and then slowly resolve them one by one, garbage-collecting the previous ones until only the settled end promise is alive.

memory
  ^     resolve      promise "then"    (tail)
  |      chain          chain         recursion
  |        /|           |\
  |       / |           | \
  |      /  |           |  \
  |  ___/   |___     ___|   \___     ___________
  |
  +----------------------------------------------> time

Is this so?

Not necessarily. As said above, all the promises in that bulk are in the end resolved with the same value2, so all we would need is to store the outermost and the innermost promise at one time. All intermediate promises may become garbage-collected as soon as possible, and we want to run this recursion in constant space and time.

In fact, this recursive construct is totally necessary for asynchronous loops with a dynamic condition (no fixed number of steps), you cannot really avoid it. In Haskell, where this is used all the time for the IO monad, an optimisation for it is implemented just because of this case. It is very similar to tail call recursion, which is routinely eliminated by compilers.

Has anyone considered the memory issues of building a chain in this way?

Yes. This was discussed at promises/aplus for example, though with no outcome yet.

Many promise libraries do support iteration helpers to avoid the spike of promise then chains, like Bluebird's each and map methods.

My own promise library3,4 does feature resolve chains without introducing memory or runtime overhead. When one promise adopts another (even if still pending), they become indistinguishable, and intermediate promises are no longer referenced anywhere.

Would memory consumption differ between promise libs?

Yes. While this case can be optimised, it seldom is. Specifically, the ES6 spec does require Promises to inspect the value at every resolve call, so collapsing the chain is not possible. The promises in the chain might even be resolved with different values (by constructing an example object that abuses getters, not in real life). The issue was raised on esdiscuss but remains unresolved.

So if you use a leaking implementation, but need asynchronous recursion, then you better switch back to callbacks and use the deferred antipattern to propagate the innermost promise result to a single result promise.

[1]: no official terminology
[2]: well, they are resolved with each other. But we want to resolve them with the same value, we expect that
[3]: undocumented playground, passes aplus. Read the code at your own peril: https://github.com/bergus/F-Promise
[4]: also implemented for Creed in this pull request

Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • As a point-of-data allocating a promise in bluebird takes less memory than allocating an empty array, so if you think allocating 10K empty arrays is reasonable the same is true for promises there - other libraries don't do this as well. – Benjamin Gruenbaum Apr 29 '15 at 07:45
  • @BenjaminGruenbaum: I'm less worried about the memory size of a single promise (and Bluebird does unbelievable well for sure there), but that 10K promises would need to be allocated (at the same time) at all. The memory growth for the resolve chain is not necessary (as is the need set 10K of promises to "fulfilled" state at once). – Bergi Apr 29 '15 at 10:18
  • _Technically_ this can be optimized away, but in practice it'd be a hard optimization (detect function equality in chain, and create a CompositePromise that does something like TCO with recursion, detect no further handlers are added to each interim promise and decompose the chain, lots of caveats) but instead libraries expose methods like `.each` :) – Benjamin Gruenbaum Apr 29 '15 at 10:21
  • @BenjaminGruenbaum: But `.each` doesn't cover this case at all. It only works for a fixed number of iterations over an array, not for a recursion of variable depth (where the base case is dynamically determined, only at the time when it's done) – Bergi Apr 29 '15 at 10:25
  • You mean like a `while`? It was considered at one point but we couldn't find any _real_ use cases for it. – Benjamin Gruenbaum Apr 29 '15 at 10:26
  • @BenjaminGruenbaum: What do you mean by "function equality in chain"? There are no functions in a `.then(a).then(a)` chain here. Notice it is `something().then(recurse)` not `recurse().then(something)`. – Bergi Apr 29 '15 at 10:27
  • 1
    @BenjaminGruenbaum: Yes, this pattern is exactly like a `while` loop with an asynchronous body. That's why it is so useful :-) – Bergi Apr 29 '15 at 10:30
  • BTW, I needed to convince myself that all promises in the *resolve chain* become resolved with the same value. [This fiddle](http://jsfiddle.net/ospvmnbz/) shows how the recursion drills down, and yes, the end result does indeed get logged, once per recursion, as it unwinds. – Roamer-1888 Apr 29 '15 at 19:32
  • @Bergi Thank you for this detailed explanation. I came here because we ran into an issue with a similar recursive pattern. Actually we ran into a stack overflow issue during resolve but only in specific environment and I could not yet create a small example which ran into SO too. However it only fails with native promise while it works with bluebird. My question would be if your comment `if implemented naively, this might even be done recursively and cause a stack overflow` is related to the Promise implementation or the recursive call implementation? – Peter Porfy Sep 02 '16 at 08:34
  • @PeterPorfy: I mean a naive promise implementation. A demo doesn't need to run on SO to be reproducible, but you really should file a bug with your environment vendor. – Bergi Sep 02 '16 at 08:37
  • Thank you. Most probably we are going to rewrite the recursive pattern to not build up resolve chains (promise exposed with simple inner recursive function) at least for performance reasons. One more interesting point: it is only happening for us while the newrelic npm lib is loaded which I guess adds additional function calls to core async modules to track context and the actual problem might be there. I will continue trying to create an independent example and if we find the core affected parts I will file a bug to the appropriate vendor. – Peter Porfy Sep 02 '16 at 08:44
  • @Bergi jfyi: https://discuss.newrelic.com/t/recursive-promise-chain-problem/40704 – Peter Porfy Sep 02 '16 at 09:17
16

Disclaimer: premature optimization is bad, the real way to find out about performance differences is to benchmark your code, and you shouldn't worry about this (I've only had to once and I've used promises for at least 100 projects).

Is this so?

Yes, the promises would have to "remember" what they're following, if you do this for 10000 promises you'd have a 10000 long promise chain, if you don't then you won't (for example, with recursion) - this is true for any queueing flow control.

If you have to keep track of 10000 extra things (the operations) then you need to keep memory for it and that takes time, if that number is a million it might not be viable. This varies among libraries.

Has anyone considered the memory issues of building a chain in this way?

Of course, this is a big issue, and a use case for using something like Promise.each in libraries like bluebird over thenable chaining.

I've personally had in my code to avoid this style for a quick app that traverses all the files in a VM once - but in the vast majority of cases it's a non issue.

Would memory consumption differ between promise libs?

Yes, greatly. For example bluebird 3.0 will not allocate an extra queue if it detects a promise operation is already asynchronous (for example if it starts with a Promise.delay) and will just execute things synchronously (because the async guarantees are already preserved).

This means that what I claimed in my answer to the first question isn't always true (but is true in the regular use case) :) Native promises will never be able to do this unless internal support is provided.

Then again - it's no surprise since promise libraries differ by orders of magnitude from one another.

Benjamin Gruenbaum
  • 270,886
  • 87
  • 504
  • 504
  • Thanks @Bergi :). By the way it seems that bluebird does this about 100 times faster than native promises. Checked on jsperf (at http://jsperf.com/native-promises-vs-recursion-chaining - remove the bluebird script line and compare) – Benjamin Gruenbaum Apr 28 '15 at 17:42
  • Notice that `Promise.each` doesn't cover recursion. – Bergi Apr 28 '15 at 17:42
  • @BenjaminGruenbaum: I don't see any bluebird in that jsperf? – Bergi Apr 29 '15 at 10:21
  • Include it in the first line, (as a ` – Benjamin Gruenbaum Apr 29 '15 at 10:22
  • `Promise.each` is for cases where a number of iterations is known in advance. A case where you don't know is e.g. when you're fetching objects via a REST API, there's more than one page, and it doesn't tell you how many pages there are. – x-yuri Dec 08 '21 at 14:58
5

I just came out a hack that may help solving the problem: don't do recursion in the last then, rather, do it in the last catch, since catch is out of the resolve chain. Using your example, it would be like this:

function foo() {
    function doo() {
        // always return a promise
        if (/* more to do */) {
            return doSomethingAsync().then(function(){
                        throw "next";
                    }).catch(function(err) {
                        if (err == "next") doo();
                    })
        } else {
            return Promise.resolve();
        }
    }
    return doo(); // returns a promise
}
dotslashlu
  • 3,361
  • 4
  • 29
  • 56
  • The question does't seek a "solution" - rather it invites a discussion on memory usage. – Roamer-1888 Apr 14 '16 at 12:00
  • 8
    Now we have drawn a conclusion through discussion, why not take it further to solve the problem? I myself encountered the problem, so I reached this page through searching, there maybe other readers concerning how to solve the problem, why not let them find the answer here? Since this is your thread? This is the community, check the clauses when you are asking a question next time, whom the content belongs to? – dotslashlu Apr 14 '16 at 15:15
1

To complement the awesome existing answers I'd like to illustrate the expression, which is the result of such an asynchronous recursion. For the sake of simplicity I use a simple function that computes the power of a given base and exponent. The recursive and base case are equivalent to those of the OP's example:

const powerp = (base, exp) => exp === 0 
 ? Promise.resolve(1)
 : new Promise(res => setTimeout(res, 0, exp)).then(
   exp => power(base, exp - 1).then(x => x * base)
 );

powerp(2, 8); // Promise {...[[PromiseValue]]: 256}

With the help of some substitution steps the recursive portion can be replaced. Please note that this expression can be evaluated in your browser:

// apply powerp with 2 and 8 and substitute the recursive case:

8 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 8)).then(
  res => 7 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 7)).then(
    res => 6 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 6)).then(
      res => 5 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 5)).then(
        res => 4 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 4)).then(
          res => 3 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 3)).then(
            res => 2 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 2)).then(
              res => 1 === 0 ? Promise.resolve(1) : new Promise(res => setTimeout(res, 0, 1)).then(
                res => Promise.resolve(1)
              ).then(x => x * 2)
            ).then(x => x * 2)
          ).then(x => x * 2)
        ).then(x => x * 2)
      ).then(x => x * 2)
    ).then(x => x * 2)
  ).then(x => x * 2)
).then(x => x * 2); // Promise {...[[PromiseValue]]: 256}

Interpretation:

  1. With new Promise(res => setTimeout(res, 0, 8)) the executor is invoked immediately and executes a non-bllocking computation (mimicked with setTimeout). Then an unsettled Promise is returned. This is equivalent with doSomethingAsync() of the OP's example.
  2. A resolve callback is associated with this Promise via .then(.... Note: The body of this callback was substituted with the body of powerp.
  3. Point 2) is repeated and a nested then handler structure is build up until the base case of the recursion is reached. The base case returns a Promise resolved with 1.
  4. The nested then handler structure is "unwound" by calling the associated callback correspondingly.

Why is the generated structure nested and not chained? Because the recursive case within the then handlers prevents them from returning a value until the base case is reached.

How can this work without a stack? The associated callbacks form a "chain", which bridges the successive microtasks of the main event loop.

0

This promise pattern will generate a recursive chain. So, each resolve() will create a new stack frame (with its own data), utilizing some memory. This means that large number of chained functions using this promise pattern can produce stack overflow errors.

To illustrate this, I'll use a tiny promise library called Sequence, which I've written. It relies on recursion to achieve sequential execution for chained functions:

var funcA = function() { 
    setTimeout(function() {console.log("funcA")}, 2000);
};
var funcB = function() { 
    setTimeout(function() {console.log("funcB")}, 1000);
};
sequence().chain(funcA).chain(funcB).execute();

Sequence works great for small/medium sized chains, in the range of 0-500 functions. However, at about 600 chains Sequence starts degradating and generating often stack overflow errors.

The bottom line is: currently, recursion-based promise libraries are more suitable for smaller/medium sized function chains, while reduce-based promise implementations are ok for all cases, including larger chains.

This of course doesn't mean that recursion-based promises are bad. We just need to use them with their limitations in mind. Also, it's rare that you'll need to chain that many calls (>=500) via promises. I typically find myself using them for async configurations which utilize heavily ajax. But even if the most complex cases I haven't seen a situation with more than 15 chains.

On a side note...

These statistics were retrieved from tests performed with another of my libraries - provisnr - which captures the achieved number of function calls within a given interval of time.

neatsu
  • 162
  • 2
  • 6