Asynchronous in JavaScript
By Seokhyeon Byun 11 min read
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
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 = [
"/api/data-primary",
"/api/data-secondary",
"/api/data-backup",
];
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 fetchFromMirrors(sources) {
try {
const data = await Promise.any(
sources.map(async (source) => {
const response = await fetch(source);
if (!response.ok) throw new Error(`Mirror failed: ${source}`);
return response.json();
})
);
return data;
} catch (error) {
// AggregateError - all promises rejected
console.error("All sources failed:", error.errors);
throw error;
}
}
// Practical example: API mirror fallback
const profileEndpoints = [
"/api/profile-cache",
"/api/profile-primary",
"/api/profile-backup",
];
fetchFromMirrors(profileEndpoints)
.then((profile) => console.log("Loaded profile:", profile))
.catch((error) => console.error("All profile endpoints failed"));