Are you sure you know Promises?
András Tóth
Posted on June 29, 2022
I am reviewing other developers' code for a long time by now and saw lots of little mistakes and misunderstandings about how async
code works in the language.
I think most people learning javascript
eventually gets Promises
, but there are lots of tiny edge-cases and nitty-gritty details. There is the high level understanding of the syntax and semantics of it, but since it's no trivial matter, it requires the "gut instinct" as well to be developed. You need to train your natural neural network with a long list of examples to develop this ability.
So here it is! An exhaustive list of many different cases (trivial and not so trivial) for people who learnt or are already familiar with Promises
and are looking for nailing them.
How you should use the exercises
First, I will define a very simple function, that creates a new "simulated process", that outputs its ID and its start and finish. Afterwards it returns its id
, that can be used for later processing.
Copy that into a browser console
and execute it.
👀 Then look at the example.
🤔 Try to guess in what order will be the logs arriving to the output.
✅ When you made your guess copy-paste the example to the console and run it.
Found something that confused you? You have found a blind-spot! If you cannot work out what happened, you can ask me in the comments and I will explain! 😊
Note on guessing first
It is important to not just copy-paste these examples but to guess the results beforehand! The research is quite steady on Test-enhanced learning; so first you work your brain and then see if you were right.
function createProcess(id, timeout) {
console.log(`🏃️ #${id}: Process started!`);
return new Promise(resolve => {
function runTinyFakeProcess() {
resolve(id);
console.log(`✅ #${id}: Process finished!`);
}
setTimeout(runTinyFakeProcess, timeout);
});
}
Examples
Example 1
Which process will finish first? Which will be the last?
Look for the ✅!
createProcess(1, 3000);
createProcess(2, 2000);
createProcess(3, 1000);
Example 2
createProcess(1, 3000);
await createProcess(2, 2000);
createProcess(3, 1000);
Example 3
await createProcess(1, 3000);
await createProcess(2, 2000);
await createProcess(3, 1000);
Example 4
await createProcess(1, 3000).then(() => {
createProcess(2, 2000);
createProcess(3, 1000);
});
Example 5
await createProcess(1, 3000)
.then(() => createProcess(2, 2000))
.then(() => createProcess(3, 1000));
});
Example 6
await createProcess(1, 3000)
.then(() => {
createProcess(2, 2000);
})
.then(() => createProcess(3, 1000));
Confused about the result and can't see the difference? Check for the {}
around the second call!
Example 7
await Promise.all([
createProcess(1, 3000),
createProcess(2, 2000),
createProcess(3, 1000)
]);
Example 8
await Promise.all([
createProcess(1, 3000)
.then(() => createProcess(2, 2000)),
createProcess(3, 1000)
]);
Example 9
await Promise.all([
await createProcess(1, 3000),
await createProcess(2, 2000),
await createProcess(3, 1000)
]);
Example 9b
Got confused about example #9? Let's break the previous one down:
// But how??? Think about initialization!
const processes1 = [
await createProcess(1, 3000),
await createProcess(2, 2000),
await createProcess(3, 1000)
];
console.log('Are the processes already done?');
// At this point all 3 processes will already be awaited
// and this call will immediately return the already
// processed results.
await Promise.all(processes1);
Example 10
Let's do something with the returned values!
const processes2 = [
createProcess(1, 3000),
createProcess(2, 2000),
createProcess(3, 1000)
];
await Promise.all(processes2.map((item) => item * 2));
Example 11
How did we get NaN
s? 🤔 And how do get the proper answer?
Look!
const processes3 = [
createProcess(1, 3000),
createProcess(2, 2000),
createProcess(3, 1000)
];
// first we wait for the processes to finish
(await Promise.all(processes3))
// then we just toy with the results
.map(result => result * 2));
Example 11b
Could we have done just this?
const processes4 = [
createProcess(1, 3000),
createProcess(2, 2000),
createProcess(3, 1000)
];
await Promise.all(processes4.map(async (promise) => {
const result = await promise;
return result * 2;
}));
Actually, yes, we could! But check for the sublety!
In #11
, we waited for all promises to first run to completion and then we processed the result.
In #11b
we are immediately acting upon the promises: we await them, process their result. Then Promise.all
will wait for the new promises defined by the async
.map
function which in turn would yield the processed results.
Example 11c
Does creating processes and awaiting them in a .map()
would make them run in series instead of in parallel? 🤔
const values = [
{ id: 1, time: 3000 },
{ id: 2, time: 2000 },
{ id: 3, time: 1000 }
];
// try it!
await Promise.all(
values.map(async ([id, time]) =>
await createProcess(id, time)
)
);
No, don't worry! During runtime when await
is encountered, the "processing" of the code block will be suspended and another block can be processed. In other words, after createProcess
started processing at the moment of await
, we switch to another iteration of .map
, which will in turn start another process.
Example 12
Let's check on Promise.race!
await Promise.race([
createProcess(1, 2000),
createProcess(2, 1000)
]);
Who would "win"?
Note
Pay attention to the logs as well!
See anything strange?
Even though #2
was the first and the result of #1
is ignored, #1
also ran to completion! In case process #1
must stop when process #2
"won" the race, then you need
something like a cancellable promise! (There are other ways... You can do your own research by searching for "Why can't you cancel promises?").
Example 13
await Promise.race([
createProcess(1, 2000)
.then(() => createProcess(3, 3000)),
createProcess(2, 1000)
]);
See my previous point? The entire chain ran (defined in the first item in the array), even though process #2
already "won" the race.
Example 14
What will be the last message? For this exercise I am going to pass in some arrays for IDs (I know it's not clean code), because createProcess
merely returns the first parameters.
Therefore this time we can use the awaited return values to start and wait for even more processes.
So what this really should do is waiting for some array data and then run processes for each item in each arrays.
And after everything ran we would like to have have a success message. But did we succeed? 🧐
const processes5 = [
createProcess([1, 2, 3], 1000),
createProcess([4, 5, 6], 2000),
createProcess([7, 8, 9], 3000)
];
function runPromiseAll1() {
return Promise.all(
processes5.map(async process => {
const ids = await process;
await Promise.all(
ids.map((id, index) => {
createProcess(`${id}->${index}`, Math.random() * 5000)
})
);
})
);
}
await runPromiseAll1();
console.log('👆 Must be the last message! We waited for everything!');
Is it though?
14b - making order
Let's see if we can make that last console.log
fired at the right time:
function runPromiseAll2() {
return Promise.all(
processes5.map(async process => {
const ids = await process;
return await Promise.all(
ids.map(async (id, index) => {
return await createProcess(`${id}->${index}`, Math.random() * 5000);
})
);
})
);
}
await runPromiseAll2();
console.log('👆 This _really_ should be the last message after we waited for everything!');
14c: the flatMap
version
And now let's see a "smart" version:
function runPromiseAll3() {
return Promise.all(
processes5
.flatMap(async process => await process)
.map((id, index) => createProcess(`${id}->${index}`, Math.random() * 5000))
);
}
await runPromiseAll3();
console.log('👆 Finished!');
Wait... What? The problem is that async
code always wraps the result in a Promise
, so even you return an array it will be always a Promise
.
14d: the "Let's unpack the array first" version
Let me confess that I needed to try out a couple of things before I could fix this. Async programming combined with arrays in JS is not an easy task.
async function runPromiseAll4() {
// we unpack the array from the Promise
const ids = await Promise.all(processes5);
// and now we can use whatever array
const newPromises = ids
.flatMap(id => id)
.map(item => { console.log(item); return item; })
.map((id, index) => createProcess(`${id}->${index}`, Math.random() * 5000));
return Promise.all(newPromises);
}
await runPromiseAll4();
console.log('😅 Finished!');
Summary
If you find async programming hard, you are right! I think most of us gets the simple notion of some code blocks are running in parallel, however using a simple document to express it is a harder task. There are no visual aids on which blocks would run to completion, which can run in parallel. You have to really-really pay attention to the fine details of the code.
And I think it is a language design problem as well. Until we find a better to express it, we should hone the sharpness of our wits and eyes!
Posted on June 29, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024