Skip to content

feat(blog): refresh asynchronous-flow-control.md #7972

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
298 changes: 185 additions & 113 deletions apps/site/pages/en/learn/asynchronous-work/asynchronous-flow-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ async1(function (input, result1) {
async3(function (result3) {
async4(function (result4) {
async5(function (output) {
// do something with output
// Do something with output.
});
});
});
Expand All @@ -30,7 +30,37 @@ async1(function (input, result1) {

Of course, in real life there would most likely be additional lines of code to handle `result1`, `result2`, etc., thus, the length and complexity of this issue usually results in code that looks much more messy than the example above.

**This is where _functions_ come in to great use. More complex operations are made up of many functions:**
## The Modern Solution: [async/await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function)

To solve callback hell, modern JavaScript introduced async and await. This is syntactic sugar built on top of a concept called Promises, allowing you to write asynchronous code that looks and behaves like synchronous code. It makes complex flows much easier to reason about.

- An async function is a function that implicitly returns a Promise.

- The await keyword can only be used inside an async function. It pauses the function's execution and waits for a Promise to be resolved, then resumes with the resolved value.

Let's rewrite the "callback hell" example using async/await. Notice how flat and readable it becomes:

```js
async function performOperations() {
try {
const result1 = await async1(input);
const result2 = await async2(result1);
const result3 = await async3(result2);
const result4 = await async4(result3);
const output = await async5(result4);
// Do something with output.
} catch (error) {
// Handle any error that occurred in the chain.
console.error(error);
}
}
```

This code is executed sequentially from top to bottom, just like synchronous code, but without blocking the main thread.

**Thinking in Functions**

Even with async/await, it's useful to structure complex operations as a series of distinct functions. This improves modularity and reusability. A common pattern involves:

1. initiator style / input
2. middleware
Expand All @@ -46,24 +76,27 @@ Network requests can be incoming requests initiated by a foreign network, by ano

A middleware function will return another function, and a terminator function will invoke the callback. The following illustrates the flow to network or file system requests. Here the latency is 0 because all these values are available in memory.

Here’s how we can structure a simple flow using async functions. Each function passes its result to the next.

```js
function final(someInput, callback) {
callback(`${someInput} and terminated by executing callback `);
async function final(someInput) {
return `${someInput} and terminated.`;
}

function middleware(someInput, callback) {
return final(`${someInput} touched by middleware `, callback);
async function middleware(someInput) {
const processedInput = `${someInput}touched by middleware, `;
return await final(processedInput);
}

function initiate() {
const someInput = 'hello this is a function ';
middleware(someInput, function (result) {
console.log(result);
// requires callback to `return` result
});
async function initiate() {
const someInput = "hello this is a function, ";
// We await the result of the entire chain.
const result = await middleware(someInput);
console.log(result);
}

initiate();
// Output: hello this is a function, touched by middleware, and terminated.
```

## State management
Expand All @@ -83,12 +116,10 @@ If an object is available in memory, iteration is possible, and there will not b

```js
function getSong() {
let _song = '';
let _song = "";
let i = 100;
for (i; i > 0; i -= 1) {
_song += `${i} beers on the wall, you take one down and pass it around, ${
i - 1
} bottles of beer on the wall\n`;
_song += `${i} beers on the wall, you take one down and pass it around, ${i - 1} bottles of beer on the wall\n`;
if (i === 1) {
_song += "Hey let's get some more beer";
}
Expand All @@ -111,13 +142,11 @@ However, if the data exists outside of memory the iteration will no longer work:

```js
function getSong() {
let _song = '';
let _song = "";
let i = 100;
for (i; i > 0; i -= 1) {
setTimeout(function () {
_song += `${i} beers on the wall, you take one down and pass it around, ${
i - 1
} bottles of beer on the wall\n`;
_song += `${i} beers on the wall, you take one down and pass it around, ${i - 1} bottles of beer on the wall\n`;
if (i === 1) {
_song += "Hey let's get some more beer";
}
Expand All @@ -132,138 +161,181 @@ function singSong(_song) {
console.log(_song);
}

const song = getSong('beer');
const song = getSong("beer");
// this will not work
singSong(song);
// Uncaught Error: song is '' empty, FEED ME A SONG!
```

Why did this happen? `setTimeout` instructs the CPU to store the instructions elsewhere on the bus, and instructs that the data is scheduled for pickup at a later time. Thousands of CPU cycles pass before the function hits again at the 0 millisecond mark, the CPU fetches the instructions from the bus and executes them. The only problem is that song ('') was returned thousands of cycles prior.
Why did this happen? setTimeout (like file system or network requests) instructs the Node.js event loop to schedule the provided function for execution at a later time. The for loop completes almost instantly, and \_song (which is still an empty string) is returned immediately. The functions scheduled by setTimeout run much later, long after singSong has attempted to use the empty \_song.

The same situation arises in dealing with file systems and network requests. The main thread simply cannot be blocked for an indeterminate period of time-- therefore, we use callbacks to schedule the execution of code in time in a controlled manner.
The main thread cannot be blocked indefinitely while waiting for I/O or other asynchronous tasks. Fortunately, Promises and async/await provide the mechanisms to explicitly wait for these operations to complete before continuing, allowing us to manage asynchronous control flow effectively.

You will be able to perform almost all of your operations with the following 3 patterns:
You will be able to perform almost all of your operations with the following 3 patterns using async/await:

1. **In series:** functions will be executed in a strict sequential order, this one is most similar to `for` loops.

```js
// operations defined elsewhere and ready to execute
// Simulate an asynchronous operation and return a Promise.
const simulateAsyncOp = (id, durationMs) =>
new Promise((resolve) => {
console.log(`[${id}] Starting operation.`);
setTimeout(() => {
console.log(`[${id}] Finished operation.`);
resolve(`Operation ${id} complete.`);
}, durationMs);
});

const operations = [
{ func: function1, args: args1 },
{ func: function2, args: args2 },
{ func: function3, args: args3 },
() => simulateAsyncOp(1, 500),
() => simulateAsyncOp(2, 200),
() => simulateAsyncOp(3, 300),
];

function executeFunctionWithArgs(operation, callback) {
// executes function
const { args, func } = operation;
func(args, callback);
// Executes an array of asynchronous functions in series.
async function executeInSeries(asyncFunctions) {
console.log("\n--- Starting In Series Execution ---");
for (const fn of asyncFunctions) {
const result = await fn(); // 'await' pauses here until the Promise resolves.
console.log(` Result: ${result}`);
}
console.log("--- All In Series operations completed ---");
}

function serialProcedure(operation) {
if (!operation) process.exit(0); // finished
executeFunctionWithArgs(operation, function (result) {
// continue AFTER callback
serialProcedure(operations.shift());
});
(async () => {
await executeInSeries(operations);
})();
```

**Applying "In Series": The Beer Song Solution:** The "In Series" pattern is precisely what's needed to fix the song generation, it makes sure that each line is created then added in the correct order.

```js
async function getSong() {
const _songParts = [];
for (let i = 100; i > 0; i -= 1) {
// Await for each line.
const line = await new Promise((resolve) => {
setTimeout(() => {
let currentLine = `${i} beers on the wall, you take one down and pass it around, ${
i - 1
} bottles of beer on the wall\n`;
if (i === 1) {
currentLine += "Hey let's get some more beer";
}
resolve(currentLine);
}, 0);
});
_songParts.push(line);
}
return _songParts.join("");
}

serialProcedure(operations.shift());
function singSong(songContent) {
if (!songContent) throw new Error("Song is empty, cannot sing!");
console.log("\n--- Singing the Song ---");
console.log(songContent);
console.log("--- Song Finished ---");
}

(async () => {
const fullSong = await getSong();
singSong(fullSong);
})();
```

2. **Limited in series:** functions will be executed in a strict sequential order, but with a limit on the number of executions. Useful when you need to process a large list but with a cap on the number of items successfully processed.

```js
let successCount = 0;
// Simulate an asynchronous task.
const processItem = (id) =>
new Promise((resolve) => {
const delay = Math.random() * 500 + 50;
console.log(`[Item ${id}] Starting.`);
setTimeout(() => {
console.log(`[Item ${id}] Finished.`);
resolve(`Item ${id} processed.`);
}, delay);
});

function final() {
console.log(`dispatched ${successCount} emails`);
console.log('finished');
}
// An array of samples
const itemsToProcess = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

function dispatch(recipient, callback) {
// `sendMail` is a hypothetical SMTP client
sendMail(
{
subject: 'Dinner tonight',
message: 'We have lots of cabbage on the plate. You coming?',
smtp: recipient.email,
},
callback
);
}
// Processes items within a limit.
async function processLimitedInSeries(items, limit) {
const queue = [...items];
const active = new Set(); // Tracks currently running promises.

function sendOneMillionEmailsOnly() {
getListOfTenMillionGreatEmails(function (err, bigList) {
if (err) throw err;
console.log(
`\n--- Starting Limited In Series Execution (Limit: ${limit}) ---`,
);

function serial(recipient) {
if (!recipient || successCount >= 1000000) return final();
dispatch(recipient, function (_err) {
if (!_err) successCount += 1;
serial(bigList.pop());
});
while (queue.length > 0 || active.size > 0) {
while (active.size < limit && queue.length > 0) {
const item = queue.shift();
const promise = processItem(item);
active.add(promise);
promise.finally(() => active.delete(promise)); // Remove the Promise from active when done.
}

serial(bigList.pop());
});
// If all tasks are done or none active to wait for, break.
if (active.size === 0 && queue.length === 0) break;

// Wait for at least one active promise to finish.
if (active.size > 0) {
await Promise.race(active);
}
}
console.log("--- All Limited In Series operations completed ---");
}

sendOneMillionEmailsOnly();
(async () => {
await processLimitedInSeries(itemsToProcess, 3); // Process 3 items at a time.
})();
```

3. **Full parallel:** when ordering is not an issue, such as emailing a list of 1,000,000 email recipients.
3. **Full parallel:** when ordering is not an issue, such as firing 5 tasks at a time.

> The name here is a bit misleading because the tasks are fired sequentially and are being handled concurrently, therefore, it is not in parallel.

```js
let count = 0;
let success = 0;
const failed = [];
const recipients = [
{ name: 'Bart', email: 'bart@tld' },
{ name: 'Marge', email: 'marge@tld' },
{ name: 'Homer', email: 'homer@tld' },
{ name: 'Lisa', email: 'lisa@tld' },
{ name: 'Maggie', email: 'maggie@tld' },
];
// A function that returns takes an id and returns a promise.
const processItem = (id) =>
new Promise((resolve) => {
const delay = Math.random() * 500 + 50;
console.log(`[Item ${id}] Starting.`);
setTimeout(() => {
console.log(`[Item ${id}] Finished.`);
resolve(`Item ${id} processed.`);
}, delay);
});

function dispatch(recipient, callback) {
// `sendMail` is a hypothetical SMTP client
sendMail(
{
subject: 'Dinner tonight',
message: 'We have lots of cabbage on the plate. You coming?',
smtp: recipient.email,
},
callback
);
}
// A array of samples.
const itemsToProcess = [1, 2, 3, 4, 5];

function final(result) {
console.log(`Result: ${result.count} attempts \
& ${result.success} succeeded emails`);
if (result.failed.length)
console.log(`Failed to send to: \
\n${result.failed.join('\n')}\n`);
}
async function processInParallel(items) {
console.log("\n--- Starting Full Parallel Execution ---");

recipients.forEach(function (recipient) {
dispatch(recipient, function (err) {
if (!err) {
success += 1;
} else {
failed.push(recipient.name);
}
count += 1;
// Creating an array of promises using processItem().
const promises = items.map((item) => processItem(item));

if (count === recipients.length) {
final({
count,
success,
failed,
});
}
});
});
// Await for all the promises to finish.
const results = await Promise.all(promises);

console.log("--- All Full Parallel operations completed ---");
console.log("Results:", results);
}
// Create an async function to await for processInParallel() aside.
(async () => {
await processInParallel(itemsToProcess);
})();
```

Each has its own use cases, benefits, and issues you can experiment and read about in more detail. Most importantly, remember to modularize your operations and use callbacks! If you feel any doubt, treat everything as if it were middleware!

### Choosing the Right Pattern

- **In Series:** Use when order is critical, or when each task depends on the previous one (e.g., chained database operations).

- **Limited in Series:** Use when you need concurrency but must control resource load or adhere to external rate limits (e.g., API calls to a throttled service).

- **Full Parallel:** Use when operations are independent and can run simultaneously for maximum throughput (e.g., fetching data from multiple unrelated sources).
Loading