Asynchronous JavaScript

50 min read Intermediate

Master asynchronous programming with callbacks, Promises, and async/await to handle operations like API calls, timers, and file operations.

Synchronous vs Asynchronous

Synchronous code runs line by line, blocking execution. Asynchronous code allows other operations to run while waiting for long-running tasks.

JavaScript
// Synchronous - blocks execution
console.log("1");
console.log("2");
console.log("3");
// Output: 1, 2, 3 (in order)

// Asynchronous - doesn't block
console.log("1");
setTimeout(() => {
    console.log("2"); // Runs after delay
}, 1000);
console.log("3");
// Output: 1, 3, 2 (3 before 2!)

// Why async matters
// Without async, a slow network request would freeze the UI!
console.log("Fetching data...");
// Imagine this took 5 seconds synchronously
// The entire page would be unresponsive!
fetch("https://api.example.com/data")
    .then(response => console.log("Data received!"));
console.log("Page is still interactive!");
Event Loop

JavaScript is single-threaded but uses an event loop to handle async operations. Callbacks are placed in a queue and executed when the main thread is free.

Callbacks

A callback is a function passed to another function to be executed later, typically when an async operation completes.

JavaScript
// setTimeout with callback
setTimeout(() => {
    console.log("Executed after 2 seconds");
}, 2000);

// setInterval with callback
let count = 0;
const intervalId = setInterval(() => {
    count++;
    console.log(`Count: ${count}`);
    if (count === 5) {
        clearInterval(intervalId); // Stop the interval
    }
}, 1000);

// Simulating async operation
function fetchUserData(userId, callback) {
    console.log(`Fetching user ${userId}...`);
    
    setTimeout(() => {
        // Simulated data
        const user = { id: userId, name: "John Doe" };
        callback(null, user); // Error-first callback pattern
    }, 1500);
}

fetchUserData(123, (error, user) => {
    if (error) {
        console.log("Error:", error);
        return;
    }
    console.log("User:", user);
});

// Callback Hell (pyramid of doom)
// This is why we moved to Promises!
getUserData(userId, (err, user) => {
    if (err) return handleError(err);
    
    getOrders(user.id, (err, orders) => {
        if (err) return handleError(err);
        
        getOrderDetails(orders[0].id, (err, details) => {
            if (err) return handleError(err);
            
            processPayment(details, (err, result) => {
                if (err) return handleError(err);
                console.log("Payment processed!");
                // This is getting hard to read...
            });
        });
    });
});

Promises

A Promise represents the eventual completion or failure of an async operation. It provides a cleaner alternative to callbacks.

JavaScript
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
    const success = true;
    
    setTimeout(() => {
        if (success) {
            resolve("Operation successful!");
        } else {
            reject(new Error("Operation failed!"));
        }
    }, 1000);
});

// Consuming a Promise
myPromise
    .then(result => console.log(result))
    .catch(error => console.log(error.message));

// Promise states: pending → fulfilled OR rejected

// Practical example: fetch data
function fetchUser(id) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (id > 0) {
                resolve({ id, name: `User ${id}` });
            } else {
                reject(new Error("Invalid user ID"));
            }
        }, 1000);
    });
}

fetchUser(1)
    .then(user => {
        console.log("Got user:", user);
        return user.id; // Return value for next .then()
    })
    .then(userId => {
        console.log("User ID:", userId);
    })
    .catch(error => {
        console.log("Error:", error.message);
    })
    .finally(() => {
        console.log("Done!"); // Always runs
    });

// Chaining Promises (solves callback hell)
getUserData(userId)
    .then(user => getOrders(user.id))
    .then(orders => getOrderDetails(orders[0].id))
    .then(details => processPayment(details))
    .then(result => console.log("Payment processed!"))
    .catch(error => console.log("Error:", error.message));

Promise Static Methods

JavaScript
// Promise.resolve() - immediately resolved
const resolved = Promise.resolve("Already done!");
resolved.then(value => console.log(value));

// Promise.reject() - immediately rejected
const rejected = Promise.reject(new Error("Already failed!"));
rejected.catch(error => console.log(error.message));

// Promise.all() - wait for ALL promises (fails if any fails)
const promises = [
    fetch("/api/users"),
    fetch("/api/posts"),
    fetch("/api/comments")
];

Promise.all(promises)
    .then(responses => {
        console.log("All loaded!", responses);
    })
    .catch(error => {
        console.log("One failed:", error);
    });

// Promise.allSettled() - wait for all, never rejects
Promise.allSettled(promises)
    .then(results => {
        results.forEach(result => {
            if (result.status === "fulfilled") {
                console.log("Success:", result.value);
            } else {
                console.log("Failed:", result.reason);
            }
        });
    });

// Promise.race() - first to settle wins
const race = Promise.race([
    new Promise(resolve => setTimeout(() => resolve("Fast"), 100)),
    new Promise(resolve => setTimeout(() => resolve("Slow"), 500))
]);
race.then(winner => console.log(winner)); // "Fast"

// Promise.any() - first to RESOLVE wins (ignores rejections)
const any = Promise.any([
    Promise.reject("Error 1"),
    Promise.resolve("Success"),
    Promise.reject("Error 2")
]);
any.then(value => console.log(value)); // "Success"

Async/Await

Async/await is syntactic sugar over Promises, making async code look and behave like synchronous code.

JavaScript
// async function always returns a Promise
async function greet() {
    return "Hello!";
}
greet().then(msg => console.log(msg)); // "Hello!"

// await pauses execution until Promise resolves
async function fetchUserData(id) {
    console.log("Fetching...");
    
    // await pauses here until resolved
    const response = await fetch(`/api/users/${id}`);
    const user = await response.json();
    
    console.log("Done!");
    return user;
}

// Using the async function
fetchUserData(1)
    .then(user => console.log(user));

// Or with another async function
async function main() {
    const user = await fetchUserData(1);
    console.log(user);
}

// Error handling with try/catch
async function loadData() {
    try {
        const response = await fetch("/api/data");
        
        if (!response.ok) {
            throw new Error(`HTTP error: ${response.status}`);
        }
        
        const data = await response.json();
        return data;
    } catch (error) {
        console.log("Error loading data:", error.message);
        return null;
    }
}

// Sequential vs Parallel execution
async function sequential() {
    // These run one after another - SLOW
    const user = await fetchUser();      // 1 second
    const posts = await fetchPosts();    // 1 second
    const comments = await fetchComments(); // 1 second
    // Total: 3 seconds
}

async function parallel() {
    // These run at the same time - FAST
    const [user, posts, comments] = await Promise.all([
        fetchUser(),
        fetchPosts(),
        fetchComments()
    ]);
    // Total: ~1 second (longest request)
}

// Async arrow functions
const getData = async () => {
    const response = await fetch("/api");
    return response.json();
};

// Async in array methods - be careful!
const ids = [1, 2, 3];

// This won't work as expected!
ids.forEach(async (id) => {
    const user = await fetchUser(id);
    console.log(user); // Order not guaranteed!
});

// Use for...of for sequential
async function loadUsersSequential() {
    for (const id of ids) {
        const user = await fetchUser(id);
        console.log(user);
    }
}

// Or Promise.all for parallel
async function loadUsersParallel() {
    const users = await Promise.all(
        ids.map(id => fetchUser(id))
    );
    console.log(users);
}

Real-World Examples

JavaScript
// 1. Fetch API with async/await
async function getUsers() {
    try {
        const response = await fetch("https://jsonplaceholder.typicode.com/users");
        
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const users = await response.json();
        return users;
    } catch (error) {
        console.error("Failed to fetch users:", error);
        throw error;
    }
}

// 2. POST request with fetch
async function createUser(userData) {
    const response = await fetch("/api/users", {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify(userData)
    });
    
    if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message);
    }
    
    return response.json();
}

// 3. Retry logic
async function fetchWithRetry(url, retries = 3, delay = 1000) {
    for (let i = 0; i < retries; i++) {
        try {
            const response = await fetch(url);
            return response.json();
        } catch (error) {
            if (i === retries - 1) throw error;
            console.log(`Retry ${i + 1}/${retries}...`);
            await new Promise(r => setTimeout(r, delay));
        }
    }
}

// 4. Timeout wrapper
function withTimeout(promise, ms) {
    const timeout = new Promise((_, reject) => {
        setTimeout(() => reject(new Error("Timeout")), ms);
    });
    return Promise.race([promise, timeout]);
}

// Usage
try {
    const data = await withTimeout(fetch("/api/slow"), 5000);
} catch (error) {
    if (error.message === "Timeout") {
        console.log("Request timed out");
    }
}

// 5. Loading state management
async function loadDataWithStatus(setStatus, setData) {
    setStatus("loading");
    try {
        const data = await fetch("/api/data").then(r => r.json());
        setData(data);
        setStatus("success");
    } catch (error) {
        setStatus("error");
    }
}

// 6. Debounced search
function debounce(fn, delay) {
    let timeoutId;
    return function(...args) {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => fn(...args), delay);
    };
}

const searchUsers = debounce(async (query) => {
    if (!query) return;
    const users = await fetch(`/api/search?q=${query}`).then(r => r.json());
    displayResults(users);
}, 300);

Common Async Patterns

JavaScript
// Pattern 1: Async IIFE
(async () => {
    const data = await fetchData();
    console.log(data);
})();

// Pattern 2: Top-level await (ES2022, modules only)
// In a module file:
const config = await loadConfig();
export { config };

// Pattern 3: Convert callback to Promise
function readFilePromise(path) {
    return new Promise((resolve, reject) => {
        fs.readFile(path, 'utf8', (err, data) => {
            if (err) reject(err);
            else resolve(data);
        });
    });
}

// Pattern 4: Async queue/semaphore
class AsyncQueue {
    constructor(concurrency = 1) {
        this.concurrency = concurrency;
        this.running = 0;
        this.queue = [];
    }
    
    async add(fn) {
        if (this.running >= this.concurrency) {
            await new Promise(resolve => this.queue.push(resolve));
        }
        
        this.running++;
        try {
            return await fn();
        } finally {
            this.running--;
            if (this.queue.length > 0) {
                this.queue.shift()();
            }
        }
    }
}

const queue = new AsyncQueue(2); // Max 2 concurrent

// Pattern 5: Async iterator
async function* asyncGenerator() {
    yield await fetchPage(1);
    yield await fetchPage(2);
    yield await fetchPage(3);
}

for await (const page of asyncGenerator()) {
    console.log(page);
}

// Pattern 6: Cancellable fetch with AbortController
const controller = new AbortController();

async function fetchWithCancel(url) {
    try {
        const response = await fetch(url, {
            signal: controller.signal
        });
        return response.json();
    } catch (error) {
        if (error.name === 'AbortError') {
            console.log('Request was cancelled');
        }
        throw error;
    }
}

// Cancel the request
controller.abort();
Common Mistakes
  • Forgetting to await a Promise
  • Not handling Promise rejections
  • Using await in non-async functions
  • Sequential awaits when parallel is possible
  • Not cancelling requests when components unmount

The Event Loop Deep Dive

Understanding the event loop is crucial for mastering async JavaScript. It's the mechanism that enables non-blocking I/O operations.

JavaScript
// The event loop processes tasks in this order:
// 1. Execute synchronous code (call stack)
// 2. Execute all microtasks (Promise callbacks, queueMicrotask)
// 3. Execute one macrotask (setTimeout, setInterval, I/O)
// 4. Repeat

console.log("1. Sync");

setTimeout(() => console.log("2. Macrotask (setTimeout)"), 0);

Promise.resolve().then(() => console.log("3. Microtask (Promise)"));

queueMicrotask(() => console.log("4. Microtask (queueMicrotask)"));

console.log("5. Sync");

// Output order:
// 1. Sync
// 5. Sync
// 3. Microtask (Promise)
// 4. Microtask (queueMicrotask)
// 2. Macrotask (setTimeout)

// Complex example showing task queue behavior
console.log("Start");

setTimeout(() => {
    console.log("Timeout 1");
    Promise.resolve().then(() => console.log("Promise inside Timeout 1"));
}, 0);

setTimeout(() => {
    console.log("Timeout 2");
}, 0);

Promise.resolve()
    .then(() => {
        console.log("Promise 1");
        return Promise.resolve();
    })
    .then(() => console.log("Promise 2"));

console.log("End");

// Output:
// Start
// End
// Promise 1
// Promise 2
// Timeout 1
// Promise inside Timeout 1
// Timeout 2

// Microtasks can starve the macrotask queue!
function recursiveMicrotask() {
    Promise.resolve().then(() => {
        console.log("Microtask");
        recursiveMicrotask();  // DON'T do this! Infinite microtasks
    });
}

// requestAnimationFrame - special timing
// Runs before the next repaint, great for animations
function animate() {
    // Update animation state
    element.style.left = position + "px";
    
    if (position < 300) {
        position++;
        requestAnimationFrame(animate);
    }
}
requestAnimationFrame(animate);

// Yielding to the event loop
async function processLargeArray(items) {
    for (let i = 0; i < items.length; i++) {
        processItem(items[i]);
        
        // Yield every 100 items to allow UI updates
        if (i % 100 === 0) {
            await new Promise(r => setTimeout(r, 0));
        }
    }
}

Microtasks vs Macrotasks

JavaScript has two task queues with different priorities. Understanding the difference prevents subtle timing bugs.

JavaScript
// MICROTASKS (higher priority, run immediately after sync code):
// - Promise.then/catch/finally
// - queueMicrotask()
// - MutationObserver

// MACROTASKS (lower priority, run one at a time):
// - setTimeout / setInterval
// - setImmediate (Node.js)
// - I/O operations
// - UI rendering
// - requestAnimationFrame

// Practical difference
document.body.innerHTML = "Step 1";

// Using microtask - user won't see "Step 1"
Promise.resolve().then(() => {
    document.body.innerHTML = "Step 2";  // Runs before render
});

// Using macrotask - user might see "Step 1" briefly
setTimeout(() => {
    document.body.innerHTML = "Step 3";  // Runs after render
}, 0);

// queueMicrotask for explicit microtask scheduling
queueMicrotask(() => {
    // Useful when you need microtask timing without Promises
    console.log("This is a microtask");
});

// MutationObserver uses microtasks
const observer = new MutationObserver((mutations) => {
    console.log("DOM changed - this is a microtask callback");
});

// Node.js specific: process.nextTick vs setImmediate
// process.nextTick is even higher priority than microtasks!
process.nextTick(() => console.log("nextTick"));  // First
Promise.resolve().then(() => console.log("Promise"));  // Second
setImmediate(() => console.log("setImmediate"));  // Last

// Scheduling for after DOM update
function afterDOMUpdate(callback) {
    // Using double-requestAnimationFrame for reliable post-paint timing
    requestAnimationFrame(() => {
        requestAnimationFrame(callback);
    });
}

Advanced Promise Methods

ES2020+ introduced powerful Promise methods for handling multiple async operations.

JavaScript
// Promise.all - Wait for ALL to resolve (fails fast on rejection)
const urls = ["/api/users", "/api/posts", "/api/comments"];
try {
    const [users, posts, comments] = await Promise.all(
        urls.map(url => fetch(url).then(r => r.json()))
    );
    console.log({ users, posts, comments });
} catch (error) {
    console.error("One request failed:", error);
}

// Promise.allSettled - Wait for ALL to complete (never rejects)
const results = await Promise.allSettled([
    fetch("/api/users"),
    fetch("/api/posts"),
    fetch("/api/fail")  // This might fail
]);

results.forEach((result, index) => {
    if (result.status === "fulfilled") {
        console.log(`Request ${index} succeeded:`, result.value);
    } else {
        console.log(`Request ${index} failed:`, result.reason);
    }
});

// Filter successful results
const successfulData = results
    .filter(r => r.status === "fulfilled")
    .map(r => r.value);

// Promise.race - First to settle (resolve OR reject) wins
const timeout = (ms) => new Promise((_, reject) => 
    setTimeout(() => reject(new Error("Timeout")), ms)
);

try {
    const data = await Promise.race([
        fetch("/api/slow-endpoint").then(r => r.json()),
        timeout(5000)
    ]);
    console.log("Got data:", data);
} catch (error) {
    console.error("Request timed out");
}

// Promise.any - First to RESOLVE wins (ES2021)
const mirrors = [
    "https://mirror1.example.com/data",
    "https://mirror2.example.com/data",
    "https://mirror3.example.com/data"
];

try {
    const fastestResponse = await Promise.any(
        mirrors.map(url => fetch(url))
    );
    console.log("Got response from fastest mirror");
} catch (error) {
    // AggregateError - all promises rejected
    console.error("All mirrors failed:", error.errors);
}

// Promise.withResolvers (ES2024)
// Creates a promise with exposed resolve/reject functions
const { promise, resolve, reject } = Promise.withResolvers();

// Useful for converting callback APIs
function loadImage(src) {
    const { promise, resolve, reject } = Promise.withResolvers();
    
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = () => reject(new Error(`Failed to load: ${src}`));
    img.src = src;
    
    return promise;
}

// Combining patterns
async function fetchWithRetry(url, retries = 3) {
    for (let i = 0; i < retries; i++) {
        try {
            return await Promise.race([
                fetch(url),
                timeout(5000)
            ]);
        } catch (error) {
            if (i === retries - 1) throw error;
            await new Promise(r => setTimeout(r, 1000 * (i + 1)));
        }
    }
}

Async Iterators and Generators

Process async data streams elegantly with async iteration, perfect for paginated APIs and real-time data.

JavaScript
// Async generator function
async function* fetchPages(baseUrl) {
    let page = 1;
    let hasMore = true;
    
    while (hasMore) {
        const response = await fetch(`${baseUrl}?page=${page}`);
        const data = await response.json();
        
        yield data.items;
        
        hasMore = data.hasNextPage;
        page++;
    }
}

// Using for-await-of
async function getAllItems() {
    const allItems = [];
    
    for await (const items of fetchPages("/api/products")) {
        allItems.push(...items);
        console.log(`Fetched ${items.length} items`);
    }
    
    return allItems;
}

// Async iterable object
const asyncIterable = {
    async *[Symbol.asyncIterator]() {
        for (let i = 1; i <= 5; i++) {
            await new Promise(r => setTimeout(r, 1000));
            yield i;
        }
    }
};

for await (const num of asyncIterable) {
    console.log(num);  // 1, 2, 3, 4, 5 (one per second)
}

// Real-time data stream
async function* streamMessages(websocketUrl) {
    const ws = new WebSocket(websocketUrl);
    const messages = [];
    let resolve;
    
    ws.onmessage = (event) => {
        messages.push(JSON.parse(event.data));
        resolve?.();
    };
    
    try {
        while (true) {
            if (messages.length === 0) {
                await new Promise(r => resolve = r);
            }
            yield messages.shift();
        }
    } finally {
        ws.close();
    }
}

// Using the stream
for await (const message of streamMessages("ws://example.com/chat")) {
    console.log("New message:", message);
    if (message.type === "end") break;
}

// Converting callback streams to async iterators
function nodeStreamToAsyncIterator(readableStream) {
    return {
        async *[Symbol.asyncIterator]() {
            for await (const chunk of readableStream) {
                yield chunk;
            }
        }
    };
}

// Parallel async iteration with pool limit
async function* parallelMap(items, fn, concurrency = 3) {
    const executing = new Set();
    
    for (const item of items) {
        const promise = fn(item).then(result => {
            executing.delete(promise);
            return result;
        });
        executing.add(promise);
        
        if (executing.size >= concurrency) {
            yield await Promise.race(executing);
        }
    }
    
    while (executing.size > 0) {
        yield await Promise.race(executing);
    }
}

Async Patterns Cheat Sheet

  • Sequential: for (const item of items) await process(item)
  • Parallel all: await Promise.all(items.map(process))
  • Parallel limited: Use a concurrency pool pattern
  • First wins: await Promise.race([...])
  • All results: await Promise.allSettled([...])

Summary

Callbacks

Functions passed to be called later; can lead to "callback hell"

Promises

Represent eventual completion; chain with .then()/.catch()

async/await

Cleaner syntax; use try/catch for errors

Promise.all

Run multiple promises in parallel

Error Handling

Always .catch() or try/catch async code

AbortController

Cancel fetch requests when needed