Executing asynchronous functions in sequence
Quick review.

It is not bullet proof. So much so that it is a bug dressed in javascript.

You must protect against improper use, inadvertent error, or failure to complete.

The next bit of code shows a variety of ways it will fail.

series([cb =&gt; {
    if (Math.random() &lt; 0.01) { throw "Spanner" }
    const handle = setTimeout(() =&gt; {
        cb('done');
        console.log("Not really done");
        cb('Done again');
    }, 1000);
    // Indeterminate completing clause
    setTimeout(() =&gt; clearTimeout(handle), Math.random() * 2000);
}, "Iron filings"], undefined); // undefined for a free throw if it got to the end


Why not use promises and async functions???

The following is immune to any of the above spannering.

async function series(tasks, tasksComplete) {
    if (Array.isArray(tasks)) {
        const timeoutPeriod = 5000; // 5 seconds and your out. Task can still complete
        const results = [];
        while (tasks.length) {
            const task = tasks.shift();
            if (typeof task === "function") {
                await new Promise(done =&gt; {
                        setTimeout(() =&gt; done("Too slow!") , timeoutPeriod);
                        task(done);
                    })
                    .then(result =&gt; results.push(result))
                    .catch(error =&gt; results.push(error))  // assuming good to continue
            }
        }
        if (typeof taskComplete === "function") { tasksComplete(results) }
    }
}


Update

In lieu of the above answer and comment you made the following might suit your needs a little better.

Rather than pass a callback to the series function, that function returns a promise which will resolve when all tasked have completed successfully, if there is a problem along the way the function will terminate returning the error.

Also the results stack is passed to each task. Rather than the last results the entire results stack is passed. It is up to the task which results it needs to use.

Also added are some options. Stop on error if true will stop on the first error, and timeout is greater then 0 will have a timeout error on tasks.

// Returns promise to do tasks in series
// stopOnError is false then continues tasks if there is an error placing
//             the error on the results stack
// timeout if &gt; 0 will throw error if task takes too long.
// Promise resolves with the results stack for both completed and error
function series(tasks, stopOnError = true, timeout = 0) {
    const results = [];
    async function doEachTask(completed, failed) {
        while (tasks.length) {
            const task = tasks.shift();
            if (typeof task === "function") {
                await new Promise((done,error) =&gt; {
                        if (timeout) { 
                            setTimeout(()=&gt; error(new Error("Too slow!")) , timeout);
                        }
                        task(done, results);
                    })
                    .then(result =&gt; results.push(result))
                    .catch(error =&gt; {
                        results.push(error);
                        if (stopOnError) { failed(results) }
                    });

            }
        }
        completed(results);
    }
    return new Promise(doEachTask);
}


Used as follows

series([
    (complete, data) =&gt; {
         setTimeout(()=&gt; {
             complete("Task1 complete");
         },1000)
    },
    (complete, data) =&gt; {
         console.log("Task 2 has data : "+data[0]);
         setTimeout(()=&gt; {
             complete("Task2 complete");
         },1000)
    }])
    .then(results =&gt; console.log(results))
    .catch(results =&gt; console.log("Error and available results : " + results));