Avoid the Promise.all pitfall! Rate limit async function calls
Mike Talbot ⭐

Mike Talbot ⭐ @miketalbot

About: Serial CTO

Location:
Bristol, UK
Joined:
May 18, 2020

Avoid the Promise.all pitfall! Rate limit async function calls

Publish Date: Oct 12 '23
34 7

Have you fallen into the Promise.all pitfall? You know the one; you grab a list of things from somewhere and run a parallel function against all of them:



     const list = await getMeSomeList()
     const results = await Promise.all(list.map(someAsyncFunction))


Enter fullscreen mode Exit fullscreen mode

Works a treat when the list has a few things in it, but lets say there are suddenly 10,000 records returned - this could really get messy.

You are really trying to spin too many plates, and memory or resources are going to become tight...

Man spinning many plates

The Solution

Well you could just install the async package which has lots of useful functions like mapLimit which will reduce the burden and only run a number in parallel.

If that's overkill - then you can achieve a similar result using a simple rate limiter:



class Semaphore {
    constructor(maxConcurrency) {
        this.maxConcurrency = maxConcurrency
        this.currentConcurrency = 0
        this.queue = []
    }

    async acquire() {
        return new Promise((resolve) => {
            if (this.currentConcurrency < this.maxConcurrency) {
                this.currentConcurrency++
                resolve()
            } else {
                this.queue.push(resolve)
            }
        })
    }

    release() {
        if (this.queue.length > 0) {
            const resolve = this.queue.shift()
            resolve()
        } else {
            this.currentConcurrency--
        }
    }
}

export function rateLimit(asyncFunction, rate) {
    const semaphore = new Semaphore(rate)

    return async function process(...args) {
        await semaphore.acquire()
        try {
            return await asyncFunction(...args)
        } finally {
            semaphore.release()
        }
    }
}



Enter fullscreen mode Exit fullscreen mode

With that in hand your code would change to be just this:



     const list = await getMeSomeList()
     const results = await Promise.all(list.map(rateLimit(someAsyncFunction, 20))


Enter fullscreen mode Exit fullscreen mode

This would mean that it would keep 20 running at a time until the list way finished. Every time one of the someAsyncFunctions returns another one is started until the list is exhausted. Easy right :)

Comments 7 total

  • wakywayne
    wakywayneOct 12, 2023

    Is 20 the recommended number? Surely you should have a much higher limit, correct?

    • Mike Talbot ⭐
      Mike Talbot ⭐Oct 12, 2023

      It depends on what the async function is doing :) Normally I'd set a 100 or so. But it depends, if you are querying a database then perhaps no more than 20 or 30 in parallel to avoid too much contention or too many database connections required. Given the speed of Redis, my tests have shown 20 - 30 concurrent is optimal under my configuration.

    • Voltra
      VoltraOct 14, 2023

      If you run HTTP requests in parallel, you'll hit that limit

      • Mike Talbot ⭐
        Mike Talbot ⭐Oct 15, 2023

        Dead right, especially on HTTP rather than HTTP2

  • Yeom suyun
    Yeom suyunOct 12, 2023

    My library has a function that was written for a similar purpose.
    How is it?

    /**
     * Run multiple functions in parallel with a specified limit on the number of parallel executions.
     * @param {number} size
     * @param {Function[]} callbacks
     * @returns {Promise<({ value: * }|{ reason: * })[]>}
     */
    const parallel = (size, ...callbacks) => {
        /** @type {({ value: * }|{ reason: * })[]} */
        const result = []
        return new Promise(resolve => {
            const length = callbacks.length
            if (length < size) size = length
            let index = 0
            const finally_callback = () => {
                if (index < length) run(index++)
                else if (++index == length + size) resolve(result)
            }
            /** @param {number} i */
            const run = (i) =>
                Promise.resolve(callbacks[i]())
                    .then(value => result[i] = { value }, reason => result[i] = { reason })
                    .finally(finally_callback)
            while (index < size) run(index++)
        })
    }
    
    Enter fullscreen mode Exit fullscreen mode
    • Mike Talbot ⭐
      Mike Talbot ⭐Oct 12, 2023

      I see that you are returning an array of results of exceptions, that's an interesting concept and would fit certain circumstances well. If you require all of the results then the Promise.all works well as it throws an exception on the first one that fails which stops the execution of the other remaining ones.

  • Midas/XIV
    Midas/XIVOct 13, 2023

    Just added this to my code base! it's really useful, thanks!

Add comment