Understanding Asynchronous Programming in JavaScript: Event Loop, Tasks, and Promise Implementation
The article explains JavaScript’s single‑threaded nature and how asynchronous programming—using callbacks, timers, Ajax, and Promises—relies on the event loop to manage macro‑tasks and micro‑tasks, illustrates execution order, warns against callback hell, and provides a custom Promise implementation.
When learning JavaScript we discover that its execution environment is single‑threaded, meaning only one task can run at a time. If multiple tasks are queued, they are processed sequentially, and a long‑running script (e.g., an infinite loop) can freeze the whole page.
To avoid the problems caused by the single‑threaded model, front‑end developers use asynchronous programming. This article explains the runtime mechanism of browser asynchronous code, covering common async tasks, the event loop, macro‑tasks, micro‑tasks, and the implementation of Promise and a custom promise.
1. Asynchronous Tasks
Typical async tasks in the front‑end include:
Callback functions
Event bindings
Timers ( setTimeout , setInterval )
Ajax requests
Promises
Example: a simple Promise whose executor runs synchronously while the then handler runs asynchronously.
new Promise((resolve, reject) => {
console.log(1);
resolve();
}).then(() => {
console.log(2);
});
console.log(3);The console output is 1 3 2 , showing that the executor runs immediately and the then callback is deferred.
Another example using async/await (syntactic sugar over generators):
async function async1() {
console.log(1);
await 10;
console.log(2);
}
async1();
console.log(3);The output order is 1 3 2 , confirming that code after await is executed asynchronously.
2. Event Loop
The event loop coordinates the execution of synchronous code, macro‑tasks, and micro‑tasks. The process is:
Start executing a task.
Synchronous code runs on the Call Stack. Asynchronous operations register callbacks in the Event Table, which later move to the Event Queue.
When the Call Stack is empty, the Event Loop pulls the next task from the Event Queue into the Call Stack.
After a macro‑task finishes, all pending micro‑tasks are executed before the next macro‑task.
Example with multiple setTimeout calls and a heavy for loop:
setTimeout(() => { console.log(1); }, 50);
setTimeout(() => { console.log(2); }, 0);
console.time('for takes time:');
for (let i = 0; i < 1000000; i++) {}
console.timeEnd('for takes time:');
setTimeout(() => { console.log(3); }, 20);
console.log(4);Depending on the time spent in the loop, the order of 1 , 2 , and 3 changes, illustrating the priority of tasks based on their scheduled delay.
3. Macro‑Task vs. Micro‑Task
JavaScript distinguishes two kinds of asynchronous jobs:
Macro‑tasks : script , setTimeout , setInterval , I/O, UI events, postMessage , MessageChannel , setImmediate (Node.js).
Micro‑tasks : Promise.then/catch/finally , async/await , Object.observe , MutationObserver , process.nextTick (Node.js).
The execution order is: synchronous code → micro‑tasks → macro‑tasks.
4. Callback Hell
When many asynchronous operations are nested, code becomes hard to read and maintain. The following snippet shows a typical “callback hell” scenario for three sequential Ajax calls:
// Get system data
_self.ajaxFn({
url: url1,
success: (res) => {
// Get form category
_self.ajaxFn({
url: url2,
success: (res) => {
// Get form data
_self.ajaxFn({
url: url3,
success: (res) => {
// handle business logic
}
});
}
});
}
});5. Promise Principles and Custom Implementation
Promises provide a cleaner way to handle asynchronous flows. The article presents a hand‑crafted implementation covering:
State transitions ( pending → resolved / rejected ) via resolve , reject , and thrown exceptions.
Storing then / catch callbacks and executing them as micro‑tasks.
Chaining: then returns a new Promise whose outcome depends on the callback’s return value.
Key code fragments:
// Custom Promise constructor
function Promise(executor) {
const _self = this;
_self.status = 'pending';
_self.data = undefined;
_self.callbacks = [];
function resolve(value) { /* ... */ }
function reject(reason) { /* ... */ }
try { executor(resolve, reject); } catch (e) { reject(e); }
}
// then implementation
Promise.prototype.then = function(onResolved, onRejected) {
const _self = this;
onResolved = typeof onResolved === 'function' ? onResolved : v => v;
onRejected = typeof onRejected === 'function' ? onRejected : r => { throw r; };
return new Promise((resolve, reject) => {
function handle(callback) {
try {
const result = callback(_self.data);
if (result instanceof Promise) {
result.then(resolve, reject);
} else {
resolve(result);
}
} catch (e) { reject(e); }
}
if (_self.status === 'resolved') {
nextTick(() => handle(onResolved));
} else if (_self.status === 'rejected') {
nextTick(() => handle(onRejected));
} else {
_self.callbacks.push({
onResolved() { handle(onResolved); },
onRejected() { handle(onRejected); }
});
}
});
};
// catch simply forwards to then
Promise.prototype.catch = function(onRejected) {
return this.then(null, onRejected);
};The implementation uses nextTick (based on MutationObserver or setTimeout ) to schedule micro‑tasks.
Conclusion
The article reviews the JavaScript asynchronous execution model, explains the event loop, distinguishes macro‑tasks and micro‑tasks, demonstrates why callback nesting is problematic, and provides a practical custom Promise implementation.
37 Interactive Technology Team
37 Interactive Technology Center
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.