Asynchronous Control Flow in JavaScript
In single-threaded JavaScript, asynchronous execution is managed via callback functions. Consider how operating systems handle interrupts: while a peripheral device completes I/O, the CPU continues other work until an interrupt signals completion. Callbacks operate on a similar principle—the runtime registers a function to execute once an external operation finishes.
Example using setTimeout:
console.log("Initiating...");
setTimeout(() => {
console.log("Deferred task");
}, 2000);
console.log("Terminating...");
Output sequence:
Initiating...
Terminating...
Deferred task
The arrow function passed to setTimeout registers a deferred callback. After approximately two seconds, the event loop pushes the callback onto the call stack. Because the operation is non-blocking, the interpreter proceeds immediately to the final log statement.
Event listeners and HTTP requests follow an identical pattern:
document.querySelector('#submit').addEventListener('click', (evt) => {
evt.preventDefault();
// handle interaction
});
fetch('/api/data')
.then(resp => resp.json())
.then(payload => console.log(payload));
When subsequent logic depends on prior asynchronous results, callbacks nest within callbacks:
console.log("Initiating...");
setTimeout(() => {
console.log('Phase 1');
setTimeout(() => {
console.log('Phase 2');
}, 1000);
}, 2000);
console.log("Terminating...");
This guarantees that "Phase 2" executes only after "Phase 1" completes. However, deeply nested clalbacks create the infamous "callback pyramid," rendering code brittle and difficult to reason about.
Illustrating the readability problem with a hypothetical workflow:
database.connect(config, (err, connection) => {
if (err) return console.error(err);
connection.query('SELECT id FROM users', (err, rows) => {
if (err) return connection.release(), console.error(err);
rows.forEach((row) => {
filesystem.readFile(`./assets/${row.id}.json`, (err, buffer) => {
if (err) return console.error(err);
const metadata = JSON.parse(buffer);
resizeAsset(metadata.path, (err, thumbnail) => {
if (err) return console.error(err);
cache.store(thumbnail, (err) => {
if (err) console.error('Cache write failed:', err);
});
});
});
});
});
});
Promises
ES6 standardized Promise objects to flatten asynchronous chains. The core concepts involve separating operation declaration from result handling and enabling sequential composition through chaining.
console.log("Initiating...");
const delay = (ms, value) => new Promise((fulfill) => {
setTimeout(() => fulfill(value), ms);
});
console.log("Operations queued...");
delay(1000, 'Alpha')
.then(result => {
console.log(`First: ${result}`);
return delay(5000, 'Beta');
})
.then(result => {
console.log(`Second: ${result}`);
});
console.log("Terminating...");
Execution yields:
Initiating...
Operations queued...
Terminating...
First: Alpha
Second: Beta
Each then handler returns a new Promise, allowing vertical rather than horizontal growth. The first delay resolves after one second; the second resolves five seconds later.
For parallel execution, Promise.all aggregates multiple asynchronous tasks:
const remoteCall = fetch('/api/config').then(r => r.json());
const localConstant = Promise.resolve(42);
const timeoutValue = new Promise((resolve) => {
setTimeout(resolve, 100, 'dynamic');
});
Promise.all([remoteCall, localConstant, timeoutValue])
.then(([config, constant, dynamic]) => {
console.log({ config, constant, dynamic });
});
Conversely, Promise.race settles as soon as the first participant resolves or rejects:
const slow = new Promise((resolve) => setTimeout(resolve, 800, 'tortoise'));
const fast = new Promise((resolve) => setTimeout(resolve, 100, 'hare'));
Promise.race([slow, fast]).then((winner) => {
console.log(winner); // "hare"
});
Async and Await
Biulding on generator functions introduced in ES6, ECMAScript later standardized async and await as syntactic sugar over Promises. This allows asynchronous code to read like synchronous routines.
const delay = (ms, value) => new Promise((resolve) => {
setTimeout(() => resolve(value), ms);
});
async function executeWorkflow() {
console.log("Initiating...");
await delay(3000, 'checkpoint');
console.log("Checkpoint reached");
console.log("Finishing...");
}
executeWorkflow();
Without await, "Finishing..." would log before the deferred value. Inside an async function, however, await pauses execution until the Promise settles, yielding:
Initiating...
Checkpoint reached
Finishing...
Key constraints:
- The
awaitkeyword is legal only withinasyncfunctions. - Every
asyncfunction implicitly returns a Promise, even if the return value is a primitive. - Sequential
awaitstatements execute in series; avoid unnecessary serialization of independent operations to prevent performance degradation.