← Back to Blogs

Asynchronous JavaScript: Callbacks, Promises & Async/Await

Master the Event Loop, Promises, and Modern Async/Await Patterns

JavaScript handles real-time data, network APIs, and user gestures through its asynchronous event loop. Learn how to write elegant, performant asynchronous code and conquer complex network workflows.


Part 1 - The Single-Threaded Event Loop Architecture

JavaScript is a single-threaded language, meaning it has only one call stack and can only execute one line of code at a time. Yet, websites effortlessly handle network requests, timers, audio playback, and user interactions simultaneously without freezing the user interface.

How is this possible? Through the browser's Concurrency Model & Event Loop:

  • Call Stack: Keeps track of currently executing functions (LIFO: Last In, First Out).
  • Web APIs: Background threads provided by the browser (such as fetch(), setTimeout(), and DOM events).
  • Callback / Task Queue: Holds macro-tasks ready to execute once the call stack is clear.
  • Microtask Queue: High-priority queue reserved for Promise callbacks (.then, .catch, await continuations).
  • Event Loop: Constantly monitors the call stack; when it empties, it pushes queued tasks into the stack.

Part 2 - Callbacks & "Callback Hell"

In early JavaScript, asynchronous operations required passing a callback function to run when the task finished. When tasks needed to run sequentially, code quickly nested deeply into the notorious "Pyramid of Doom":

// The Nightmare of Callback Hell
getUser(userId, function(user) {
    getOrders(user.id, function(orders) {
        getOrderDetails(orders[0].id, function(details) {
            calculateDiscount(details, function(finalPrice) {
                console.log("Total: $" + finalPrice);
            }, handleError);
        }, handleError);
    }, handleError);
}, handleError);

This pattern made error handling, code readability, and maintenance extremely difficult.


Part 3 - Promises: Resolution, Rejection & Chaining

Introduced in ES6, a Promise represents the eventual completion (or failure) of an asynchronous operation and its resulting value.

Promise Lifecycle States

  • Pending: Initial state, neither fulfilled nor rejected.
  • Fulfilled: The operation completed successfully (triggers .then()).
  • Rejected: The operation failed (triggers .catch()).
// Creating a Promise from scratch
function fetchWeatherData(city) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (city.toLowerCase() === 'tokyo') {
                resolve({ city: 'Tokyo', temp: '22°C', weather: 'Sunny' });
            } else {
                reject(new Error('City not found in database'));
            }
        }, 1000);
    });
}

// Consuming the Promise with chaining
fetchWeatherData('Tokyo')
    .then(data => {
        console.log(`Weather in ${data.city}: ${data.temp}`);
        return data.weather;
    })
    .then(weatherCondition => {
        console.log(`Conditions: ${weatherCondition}`);
    })
    .catch(err => {
        console.error('Failed to load weather:', err.message);
    })
    .finally(() => {
        console.log('Request completed.');
    });

Part 4 - Async / Await: Clean, Synchronous-Looking Code

Introduced in ES2017, async/await is syntactic sugar built on top of Promises. It allows you to write asynchronous code that reads sequentially like synchronous code.

  • An async function always returns a Promise automatically.
  • The await keyword pauses execution of the async function until the Promise settles, unpacking its resolved value.
async function getProfile() {
    try {
        const response = await fetch('https://api.github.com/users/octocat');
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = await response.json();
        console.log('GitHub Username:', data.name);
        console.log('Public Repos:', data.public_repos);
    } catch (error) {
        console.error('Fetch failed:', error.message);
    } finally {
        console.log('Finished fetching user data.');
    }
}

getProfile();

Part 5 - Promise Combinators: Concurrent Execution

When you have multiple independent asynchronous operations, running them sequentially wastes valuable time. Use Promise combinators to run them concurrently:

1. Promise.all() - Fail Fast

Runs all promises in parallel. Fulfills when ALL fulfill; rejects immediately if ANY single promise rejects.

const [users, posts, comments] = await Promise.all([
    fetch('/api/users').then(r => r.json()),
    fetch('/api/posts').then(r => r.json()),
    fetch('/api/comments').then(r => r.json())
]);

2. Promise.allSettled() - Complete Inspection

Waits for all promises to settle (either fulfill or reject), returning an array of objects describing the outcome of each.

const results = await Promise.allSettled([
    fetchAnalytics(),
    fetchUserBadges(),
    fetchRecommendations()
]);

results.forEach(result => {
    if (result.status === 'fulfilled') {
        console.log('Success:', result.value);
    } else {
        console.warn('Failed:', result.reason);
    }
});

3. Promise.race() & Promise.any()

  • Promise.race(): Settles as soon as the first promise settles (whether fulfilled or rejected). Great for network timeouts!
  • Promise.any(): Fulfills as soon as the first promise fulfills, ignoring rejections unless ALL reject.

Part 6 - Async Best Practices & Pro Tips

  • Always handle errors: Never leave a naked await without a wrapping try...catch block or top-level catch handler.
  • Avoid sequential waterfalls: If Task B doesn't depend on the output of Task A, trigger both with Promise.all() rather than awaiting them one after another.
  • Handle Network Timeouts: Use AbortController to abort fetch requests that take longer than 5 seconds on slow cellular connections.
// Implementing request timeouts with AbortController
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);

try {
    const res = await fetch('/api/data', { signal: controller.signal });
    const data = await res.json();
} catch (err) {
    if (err.name === 'AbortError') {
        console.error('Request timed out after 5 seconds!');
    }
} finally {
    clearTimeout(timeoutId);
}