Asynchronous in JavaScript
Note: This post is based on my old programming study notes when I taught myself.
Understanding Asynchronous Programming
JavaScript is fundamentally a synchronous, single-threaded programming language that executes code line by line after hoisting. However, asynchronous programming allows us to handle time-consuming operations without blocking the main execution thread.
Why Asynchronous Programming?
- Prevents application freezing during long-running operations
- Enables better user experience with non-blocking UI
- Handles network requests, file operations, and timers efficiently
- Essential for modern web applications
// Synchronous code (blocking)
console.log("Start");
// Imagine a 5-second operation here
for (let i = 0; i < 5000000000; i++) {} // Blocks everything
console.log("End");
// Asynchronous code (non-blocking)
console.log("Start");
setTimeout(() => {
console.log("Async operation complete");
}, 2000);
console.log("End");
// Output: Start, End, Async operation complete (after 2 seconds)
Callbacks
A callback function is passed into another function as an argument and invoked inside the outer function to complete an action.
Synchronous Callbacks
// Synchronous callback - executes immediately
function processData(data, callback) {
const result = data.map((x) => x * 2);
callback(result);
}
function printResult(result) {
console.log("Result:", result);
}
processData([1, 2, 3, 4], printResult); // Executes immediately
// Array methods use synchronous callbacks
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map((num) => num * 2); // Callback executes for each element
const evens = numbers.filter((num) => num % 2 === 0); // Callback for filtering
Asynchronous Callbacks
// Asynchronous callback - executes later
function fetchDataFromServer(callback) {
console.log("Fetching data...");
setTimeout(() => {
const data = { id: 1, name: "John", email: "john@example.com" };
callback(data);
}, 2000);
}
function handleData(data) {
console.log("Received data:", data);
}
fetchDataFromServer(handleData);
console.log("This runs immediately");
// Real-world example: Event handling
document.getElementById("button").addEventListener("click", function (event) {
console.log("Button clicked!", event);
});
// File reading example (Node.js)
const fs = require("fs");
fs.readFile("data.txt", "utf8", function (error, data) {
if (error) {
console.error("Error reading file:", error);
} else {
console.log("File contents:", data);
}
});
Callback Hell Problem
// Callback Hell - nested callbacks become hard to read and maintain
function getUserData(userId, callback) {
setTimeout(() => callback({ id: userId, profileId: 123 }), 1000);
}
function getProfile(profileId, callback) {
setTimeout(() => callback({ name: "John", posts: [1, 2, 3] }), 1000);
}
function getPost(postId, callback) {
setTimeout(() => callback({ title: "Post Title", content: "Content" }), 1000);
}
// This becomes hard to read and maintain
getUserData(1, (user) => {
getProfile(user.profileId, (profile) => {
getPost(profile.posts[0], (post) => {
console.log("User:", user);
console.log("Profile:", profile);
console.log("First Post:", post);
// Error handling becomes complex here
});
});
});
// Problems with callback hell:
// 1. Poor readability (pyramid of doom)
// 2. Difficult error handling
// 3. Hard to maintain and debug
// 4. Testing complexity
Interview Question: What is callback hell and how can it be avoided?
Answer: Callback hell occurs when multiple nested callbacks create deeply indented, hard-to-read code. It can be avoided using Promises, async/await, or breaking callbacks into named functions.
Promises
The Promise object represents the eventual completion (or failure) of an asynchronous operation and its resulting value.
Promise States
// Three states of Promise:
// 1. Pending: initial state, neither fulfilled nor rejected
// 2. Fulfilled: operation completed successfully
// 3. Rejected: operation failed
const promise = new Promise((resolve, reject) => {
// Pending state initially
const success = Math.random() > 0.5;
setTimeout(() => {
if (success) {
resolve("Operation successful!"); // Fulfilled state
} else {
reject(new Error("Operation failed!")); // Rejected state
}
}, 1000);
});
console.log(promise); // Promise { <pending> }
Creating Promises (Producer)
// Basic Promise creation
const fetchUser = new Promise((resolve, reject) => {
console.log("Fetching user data..."); // Executes immediately!
// Simulate network request
setTimeout(() => {
const success = true;
if (success) {
resolve({ id: 1, name: "Alice", email: "alice@example.com" });
} else {
reject(new Error("Failed to fetch user"));
}
}, 2000);
});
// � Important: Promise executor runs immediately upon creation
// Be careful when creating Promises - they execute right away
// Better approach - wrap in function for lazy execution
function createFetchUserPromise() {
return new Promise((resolve, reject) => {
console.log("Now fetching user data...");
setTimeout(() => {
resolve({ id: 1, name: "Alice", email: "alice@example.com" });
}, 2000);
});
}
// Promise-based utility functions
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function randomSuccess() {
return new Promise((resolve, reject) => {
setTimeout(() => {
Math.random() > 0.5 ? resolve("Success!") : reject(new Error("Failed!"));
}, 1000);
});
}
Consuming Promises (Consumer)
// Basic Promise consumption
const userPromise = createFetchUserPromise();
userPromise
.then((user) => {
console.log("User received:", user);
return user.id; // Return value becomes next then() parameter
})
.then((userId) => {
console.log("User ID:", userId);
return fetchUserPosts(userId); // Return another Promise
})
.then((posts) => {
console.log("User posts:", posts);
})
.catch((error) => {
console.error("Error occurred:", error);
})
.finally(() => {
console.log("Promise chain completed");
});
// Error handling examples
function riskyOperation() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const random = Math.random();
if (random > 0.7) {
resolve("Success!");
} else if (random > 0.3) {
reject(new Error("Network error"));
} else {
reject(new Error("Server error"));
}
}, 1000);
});
}
riskyOperation()
.then((result) => console.log("Result:", result))
.catch((error) => {
if (error.message.includes("Network")) {
console.log("Retrying due to network error...");
return riskyOperation(); // Retry
} else {
console.error("Server error, cannot retry:", error);
}
})
.finally(() => console.log("Operation attempt finished"));
Promise Chaining
// Promise chaining example
function fetchNumber() {
return new Promise((resolve) => {
setTimeout(() => resolve(1), 1000);
});
}
fetchNumber()
.then((num) => {
console.log("Initial number:", num); // 1
return num * 2;
})
.then((num) => {
console.log("After multiply by 2:", num); // 2
return num * 3;
})
.then((num) => {
console.log("After multiply by 3:", num); // 6
return new Promise((resolve) => {
setTimeout(() => resolve(num - 1), 1000);
});
})
.then((num) => {
console.log("Final result:", num); // 5
});
// Real-world chaining example
function authenticateUser(credentials) {
return new Promise((resolve) => {
setTimeout(() => resolve({ token: "abc123", userId: 1 }), 1000);
});
}
function fetchUserProfile(token, userId) {
return new Promise((resolve) => {
setTimeout(() => resolve({ name: "John", role: "admin" }), 1000);
});
}
function fetchUserPermissions(role) {
return new Promise((resolve) => {
setTimeout(() => resolve(["read", "write", "delete"]), 1000);
});
}
// Chained authentication flow
const credentials = { username: "john", password: "secret" };
authenticateUser(credentials)
.then((auth) => {
console.log("Authenticated:", auth);
return fetchUserProfile(auth.token, auth.userId);
})
.then((profile) => {
console.log("Profile loaded:", profile);
return fetchUserPermissions(profile.role);
})
.then((permissions) => {
console.log("Permissions loaded:", permissions);
})
.catch((error) => {
console.error("Authentication flow failed:", error);
});
Async/Await
Async/await provides a more readable and synchronous-looking way to write asynchronous code, built on top of Promises.
Basic Async Functions
// Promise-based function
function fetchUser() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ id: 1, name: "Alice" });
}, 1000);
});
}
// Async function automatically returns a Promise
async function fetchUserAsync() {
// This function automatically wraps return value in a Promise
return { id: 1, name: "Alice" };
}
// Both are equivalent:
fetchUser().then((user) => console.log(user));
fetchUserAsync().then((user) => console.log(user));
// Async function with await
async function getUserData() {
console.log("Fetching user...");
try {
const user = await fetchUser(); // Wait for Promise to resolve
console.log("User received:", user);
return user;
} catch (error) {
console.error("Error fetching user:", error);
throw error; // Re-throw to caller
}
}
// Call async function
getUserData()
.then((user) => console.log("Final user:", user))
.catch((error) => console.error("Failed:", error));
Await with Error Handling
// Async functions for demo
async function getApple() {
await delay(1000);
return "<N";
}
async function getBanana() {
await delay(1000);
return "<L";
}
async function getOrange() {
await delay(500);
throw new Error("Orange not available");
}
// Sequential execution with error handling
async function getFruitsSequential() {
try {
console.log("Getting fruits sequentially...");
const apple = await getApple();
console.log("Got apple:", apple);
const banana = await getBanana();
console.log("Got banana:", banana);
// This will throw an error
const orange = await getOrange();
console.log("Got orange:", orange);
return `${apple} + ${banana} + ${orange}`;
} catch (error) {
console.error("Error getting fruits:", error.message);
return "Some fruits unavailable";
} finally {
console.log("Fruit fetching attempt completed");
}
}
getFruitsSequential().then((result) => console.log("Result:", result));
// Multiple error handling strategies
async function robustDataFetching() {
let result = {};
// Strategy 1: Individual try-catch blocks
try {
result.user = await fetchUser();
} catch (error) {
console.warn("User fetch failed:", error.message);
result.user = null;
}
try {
result.posts = await fetchPosts();
} catch (error) {
console.warn("Posts fetch failed:", error.message);
result.posts = [];
}
return result;
}
// Strategy 2: Promise-based error handling
async function fetchWithFallback() {
const user = await fetchUser().catch((error) => {
console.warn("Using cached user data");
return getCachedUser();
});
return user;
}
Parallel vs Sequential Execution
// Sequential execution (slower - waits for each)
async function getFruitsSequential() {
console.time("Sequential");
const apple = await getApple(); // Wait 1 second
const banana = await getBanana(); // Wait another 1 second
console.timeEnd("Sequential"); // ~2 seconds total
return `${apple} + ${banana}`;
}
// Parallel execution (faster - starts all at once)
async function getFruitsParallel() {
console.time("Parallel");
// Start both operations immediately
const applePromise = getApple();
const bananaPromise = getBanana();
// Wait for both to complete
const apple = await applePromise;
const banana = await bananaPromise;
console.timeEnd("Parallel"); // ~1 second total
return `${apple} + ${banana}`;
}
// Even better: using Promise.all for parallel execution
async function getFruitsWithPromiseAll() {
console.time("Promise.all");
const [apple, banana] = await Promise.all([getApple(), getBanana()]);
console.timeEnd("Promise.all"); // ~1 second total
return `${apple} + ${banana}`;
}
// Compare execution times
async function compareApproaches() {
await getFruitsSequential(); // ~2 seconds
await getFruitsParallel(); // ~1 second
await getFruitsWithPromiseAll(); // ~1 second
}
Promise Utility Methods
Promise.all()
// Promise.all - wait for all promises to resolve
async function fetchAllUserData(userIds) {
try {
const users = await Promise.all(userIds.map((id) => fetchUser(id)));
return users;
} catch (error) {
console.error("At least one user fetch failed:", error);
throw error; // If any promise rejects, entire operation fails
}
}
// Practical example: Loading dashboard data
async function loadDashboard() {
try {
const [user, posts, notifications, stats] = await Promise.all([
fetchCurrentUser(),
fetchRecentPosts(),
fetchNotifications(),
fetchUserStats(),
]);
return {
user,
posts,
notifications,
stats,
};
} catch (error) {
console.error("Dashboard loading failed:", error);
throw error;
}
}
Promise.race()
// Promise.race - returns first resolved/rejected promise
async function fetchWithTimeout(url, timeout = 5000) {
const fetchPromise = fetch(url);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Request timeout")), timeout);
});
try {
const response = await Promise.race([fetchPromise, timeoutPromise]);
return await response.json();
} catch (error) {
if (error.message === "Request timeout") {
console.error("Request took too long");
}
throw error;
}
}
// Race between multiple servers
async function fetchFromFastestServer(urls) {
const requests = urls.map((url) => fetch(url));
try {
const fastestResponse = await Promise.race(requests);
return await fastestResponse.json();
} catch (error) {
console.error("All servers failed:", error);
throw error;
}
}
// Example usage
const servers = [
"https://api1.example.com/data",
"https://api2.example.com/data",
"https://api3.example.com/data",
];
fetchFromFastestServer(servers)
.then((data) => console.log("Got data from fastest server:", data))
.catch((error) => console.error("All servers failed:", error));
Promise.allSettled()
// Promise.allSettled - waits for all promises to settle (resolve or reject)
async function fetchMultipleUsersRobust(userIds) {
const promises = userIds.map(async (id) => {
try {
const user = await fetchUser(id);
return { status: "fulfilled", value: user };
} catch (error) {
return { status: "rejected", reason: error };
}
});
const results = await Promise.allSettled(promises);
const successful = results
.filter((result) => result.status === "fulfilled")
.map((result) => result.value);
const failed = results
.filter((result) => result.status === "rejected")
.map((result) => result.reason);
console.log(`Successfully fetched ${successful.length} users`);
console.log(`Failed to fetch ${failed.length} users`);
return { successful, failed };
}
// Graceful degradation example
async function loadPageWithGracefulDegradation() {
const [userResult, postsResult, adsResult] = await Promise.allSettled([
fetchUser(),
fetchPosts(),
fetchAds(),
]);
return {
user: userResult.status === "fulfilled" ? userResult.value : null,
posts: postsResult.status === "fulfilled" ? postsResult.value : [],
ads: adsResult.status === "fulfilled" ? adsResult.value : [],
};
}
Promise.any()
// Promise.any - returns first fulfilled promise (ignores rejections)
async function fetchFromMultipleSources(sources) {
try {
// Returns first successful response, ignores failures
const data = await Promise.any(sources.map((source) => fetch(source)));
return await data.json();
} catch (error) {
// AggregateError - all promises rejected
console.error("All sources failed:", error.errors);
throw error;
}
}
// Practical example: CDN fallback
const cdnUrls = [
"https://cdn1.example.com/library.js",
"https://cdn2.example.com/library.js",
"https://cdn3.example.com/library.js",
];
fetchFromMultipleSources(cdnUrls)
.then((response) => console.log("Loaded from fastest available CDN"))
.catch((error) => console.error("All CDNs failed"));
Real-World Examples
API Request with Retry Logic
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
const { timeout = 5000, ...fetchOptions } = options;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`Attempt ${attempt} for ${url}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error(`Attempt ${attempt} failed:`, error.message);
if (attempt === maxRetries) {
throw new Error(
`All ${maxRetries} attempts failed. Last error: ${error.message}`
);
}
// Exponential backoff
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
console.log(`Waiting ${delay}ms before retry...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
// Usage
fetchWithRetry("https://api.example.com/data", { timeout: 3000 }, 3)
.then((data) => console.log("Data received:", data))
.catch((error) => console.error("Final failure:", error.message));
Concurrent Task Management
// Process items concurrently with concurrency limit
async function processConcurrentlyWithLimit(items, processor, concurrency = 3) {
const results = [];
const inProgress = [];
for (let i = 0; i < items.length; i++) {
const promise = processor(items[i], i).then((result) => {
const index = inProgress.indexOf(promise);
inProgress.splice(index, 1);
return result;
});
results.push(promise);
inProgress.push(promise);
// Wait if we've hit the concurrency limit
if (inProgress.length >= concurrency) {
await Promise.race(inProgress);
}
}
// Wait for all remaining tasks
return Promise.all(results);
}
// Example: Process user uploads with limited concurrency
async function processUploads(files) {
async function uploadFile(file, index) {
console.log(`Starting upload ${index + 1}/${files.length}: ${file.name}`);
// Simulate upload
await delay(Math.random() * 3000);
console.log(`Completed upload ${index + 1}: ${file.name}`);
return { file: file.name, success: true };
}
return processConcurrentlyWithLimit(files, uploadFile, 2);
}
Common Patterns and Best Practices
Converting Callbacks to Promises
// Promisifying callback-based functions
function promisify(callbackFunction) {
return function (...args) {
return new Promise((resolve, reject) => {
callbackFunction(...args, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
};
}
// Example: Promisifying Node.js fs.readFile
const fs = require("fs");
const readFileAsync = promisify(fs.readFile);
async function readConfigFile() {
try {
const data = await readFileAsync("config.json", "utf8");
return JSON.parse(data);
} catch (error) {
console.error("Failed to read config:", error);
return {};
}
}
Event-Driven Async Patterns
// Converting events to Promises
function waitForEvent(emitter, eventName, timeout = 10000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Event ${eventName} timeout after ${timeout}ms`));
}, timeout);
emitter.once(eventName, (data) => {
clearTimeout(timer);
resolve(data);
});
emitter.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
}
// Usage example
async function connectToDatabase() {
const connection = new DatabaseConnection();
try {
connection.connect();
await waitForEvent(connection, "connected", 5000);
console.log("Database connected successfully");
return connection;
} catch (error) {
console.error("Database connection failed:", error);
throw error;
}
}