Waiting on Multiple Callbacks
February 18, 2019
First, a refresher on asynchronous functions utilizing callbacks:
setTimeout(() => {
console.log('One second later');
}, 1000);
The above code will log One second later
to the console after at least a one
second delay (timing isn’t precise in JS, but it’ll usually be close…but I
digress). setTimeout
accepts a callback to be executed after a set number of
milliseconds that are passed in as the second argument. We, the developer of a
JavaScript application, do not need to be concerned what happens inside of the
setTimeout
function, we just need to understand that the event loop will
continue running, and after 1000
milliseconds our callback will be executed.
Plenty simple! But what happens when you have a number of asynchronous actions
you need completed before performing another action? Well in JavaScript proper,
there are no primitives for handling this situation with callbacks. You are
left to implement this logic on your own (or use a convenient library like
async
, which is linked at the bottom). But never fear! If you’ve read the
past article you’ve already seen the code to handle this. This time we’ll walk
through how and why this works.
Let’s start off by assuming we have an array of four numbers representing milliseconds.
const durations = [250, 500, 750, 1000];
Next we’re going to create a function that:
- Takes an array of
n
numbers - Creates
n
timeouts based on the values in the array - Each timeout will output its duration upon completion
- Upon all timeouts completing, will execute a callback that was passed in.
const createTimeouts = (durations, callback) => {
let remainingCount = durations.length;
durations.forEach((duration) => {
setTimeout(() => {
console.log(`I completed in ${duration} milliseconds`);
remainingCount -= 1;
if (remainingCount <= 0) {
callback();
}
}, duration);
});
}
Finally, we’ll invoke the function and pass in a function that will write out
to the console We are all done!
. So if we save the whole thing:
const durations = [250, 500, 750, 1000];
const createTimeouts = (durations, callback) => {
let remainingCount = durations.length;
durations.forEach((duration) => {
setTimeout(() => {
console.log(`I completed in ${duration} milliseconds`);
remainingCount -= 1;
if (remainingCount <= 0) {
callback();
}
}, duration);
});
}
createTimeouts(durations, () => {
console.log("We are all done!");
});
and then execute the script, we’ll see each timeout execute in order every
quarter second, with 1000
being swiftly followed by We are all done!
I completed in 250 milliseconds
I completed in 500 milliseconds
I completed in 750 milliseconds
I completed in 1000 milliseconds
We are all done!
There is no magic here! We’re using an integer, remainingCount
set to the
length of the number of callbacks we’re going to execute. Then on every
callback execution we decrement remainingCount
by one, and subsequently check
to see if we’ve reached 0
. If not, we keep going, but if 0
has indeed been
reached we’re going to call the callback that’s been passed into the
createTimeouts
function to let the caller know that everything’s been
completed.
Was that a bit underwhelming? Like most things in computer science, this potentially challenging problem could be solved with a little bit of math. Lucky for us!
Handling n
Functions Generically (and Naively)
Maybe you don’t want to write this looping code every time - maybe you are worried you may make a mistake, or you prefer not utilizing imperative constructs when possible. We can turn this into a generic helper pretty quickly, but we should define what we want first since there are many different ways we may want this information returned.
I want to create a function, we’ll call it whenAllSettled
, that will accept a
list of functions accepting node-style callbacks (err, result) => {}
, and
will execute each one in order. All executions, whether successful
or failures, will store an object with the keys type
and value
into a list
results
. When no error is raised, the type
key will be set to success
,
and value
set to whatever result
was in the callback. When an error was
thrown, we will set type
to failure
and value
to be the Error
object
sent to the callback.
To test it out, we’ll nest two levels of whenAllSettled
to demonstrate that
it will wait for all of the first callbacks to complete, and that it can be
nested to wait in different ways.
Let’s see what this looks like:
const whenAllSettled = (fns, callback) => {
const results = [];
let remainingCount = fns.length;
const done = (err, result) => {
let resultObject;
if (err) {
resultObject = { type: 'failure', value: err };
} else {
resultObject = { type: 'success', value: result };
}
results.push(resultObject);
remainingCount -= 1;
if (remainingCount <= 0) {
callback(null, results);
}
};
fns.forEach((fn) => {
fn(done)
});
}
const callbacks = [
(done) => { setTimeout(() => { done(null, '1') }, 500) },
(done) => { setTimeout(() => { done(null, '2') }, 200) },
];
whenAllSettled(callbacks, (err, results) => {
console.log("All done with round 1!", results);
whenAllSettled([
(done) => { setTimeout(() => done(new Error('oops')), 500); }
], (err, results) => {
console.log("All done with round 2!", results);
})
});
Now if we run this code we can watch the output pop up over time:
All done with round 1! [ { type: 'success', value: '2' }, { type: 'success', value: '1' } ]
All done with round 2! [ { type: 'failure',
value:
Error: oops
at Timeout.setTimeout [as _onTimeout] (/Users/machuga/src/book-examples/multipleCallbacksGeneric.js:37:37)
at listOnTimeout (timers.js:324:15)
at processTimers (timers.js:268:5) } ]
So that’s cool - our functions wait till the appropriate time to execute, and both successes and failures get returned properly. But if you are looking carefully, you’ll notice something a bit out of place.
const callbacks = [
(done) => { setTimeout(() => { done(null, '1') }, 500) },
(done) => { setTimeout(() => { done(null, '2') }, 200) },
];
At the top we defined the callbacks
array with two functions, the first
executed done
with 1
, and the second with 2
; however, in our output we
can see that 2
was returned first and 1
came in after. This is because the
timeout for the first callback was longer (500
) than the second (200
). The
fact that the results can be returned out of their executed order can cause
non-deterministic results from our function - we can’t be sure which
data comes from which function.
How to Maintain Order
So we know that if we have callbacks firing whenever they please, and then the results are being inserted into an array at that time, there is no guarantee on what order our callbacks will be in. So we need a way to track the order as we add them to our list of results. Courtesy of indexing into arrays, this is pretty straight forward.
We’re going to change our iteration over the callback functions:
fns.forEach((fn) => {
fn(done);
});
to also pass in the index to the looping function. If you haven’t seen it before,
the function passed to forEach
takes more than one argument. The first argument
is the value, but the second value is the index. So we’re going to take that index
and pass it along to the done
function. Our loop will now look like this:
fns.forEach((fn, i) => {
fn(done(i));
});
If you recall, our done
function only accepts an err
and a result
, so we will
need to modify it to instead return a function that accepts an err
and a result
.
done
will now accept an integer, and instead of running
results.push(resultObject);
we will save directly to the index like this:
results[i] = resultObject;
Now when we rerun our script we’ll see our results are returned in the order in which we passed in their functions.
All done with round 1! [ { type: 'success', value: '1' }, { type: 'success', value: '2' } ]
You can view the final version here.
Wrap Up
So that’s how to make a function that will wait for n
functions to complete
and pass their results into callback in the order they were given. Hope that helps remove
any of the magic feeling behind it.
However, you may not want to write this yourself every time you want to work on some code with callbacks. Good news! There is an awesome library, async that is capable of doing all of the asynchronous magic with callbacks I’m going to demonstrate in this series! It’s been battle tested for years, so it’s ready for you to use in your production code.
You may be wondering: if this all happens asynchronously, how can I
be sure that remainingCount
will ever reach 0
? Or will not go further than 0
?
Can’t a race condition occur?
In the next article I’m going to walk through the basics of the JavaScript event loop. We’ll ensure we have a solid working understanding before we continue refactoring our asychronous code from earlier in the series.