Asynchronous in JavaScript

· Seokhyeon Byun

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;
  }
}