Asynchronous JS

Callbacks, Promises, async/await

What is Asynchronous JavaScript?

JavaScript is single-threaded, meaning it can only do one thing at a time. But async programming allows JavaScript to handle time-consuming tasks (like fetching data from a server) without freezing the entire page.

Think of it like ordering food at a restaurant: you don't stand at the kitchen waiting for your meal (blocking). Instead, you sit down, and the waiter brings it when ready (async).

The Problem: Blocking Code

❌ Synchronous (Blocking)

1. Start
2. Wait 3 seconds ⏳
3. Page frozen!
4. End

✓ Asynchronous (Non-blocking)

1. Start
2. Schedule task (3s)
3. End (immediate)
4. Task completes later
// ❌ Blocking (DON'T DO THIS!)
console.log("Start");
const end = Date.now() + 3000;
while (Date.now() < end) {} // Freezes browser!
console.log("End");

// ✓ Non-blocking
console.log("Start");
setTimeout(() => {
  console.log("After 3 seconds");
}, 3000);
console.log("End"); // Runs immediately!

Three Ways to Handle Async Code

1. Callbacks (Old Way—Avoid)

Pass a function to be called when operation completes:

setTimeout(() => {
  console.log("Callback executed");
}, 1000);

// Real example: Reading a file
fs.readFile('data.txt', (error, data) => {
  if (error) {
    console.error(error);
  } else {
    console.log(data);
  }
});

⚠️ Callback Hell (Pyramid of Doom)

// Nested callbacks = unreadable code
getUser(1, (user) => {
  getPosts(user.id, (posts) => {
    getComments(posts[0].id, (comments) => {
      getLikes(comments[0].id, (likes) => {
        // 😱 This keeps going...
      });
    });
  });
});

2. Promises (ES6—Better)

Promises represent a value that will eventually be available:

Promise States:

Pending
Initial state
Fulfilled
Success ✓
Rejected
Error ✗
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve("✓ Operation successful!");
    } else {
      reject("✗ Operation failed!");
    }
  }, 1000);
});

// Using a Promise
myPromise
  .then(result => {
    console.log(result);  // "✓ Operation successful!"
    return "Next step";
  })
  .then(nextResult => {
    console.log(nextResult);  // "Next step"
  })
  .catch(error => {
    console.error(error);  // Catches any error
  })
  .finally(() => {
    console.log("Always runs");
  });

💡 Chaining Promises (Better than Callbacks!)

fetch('https://api.example.com/user/1')
  .then(response => response.json())
  .then(user => {
    console.log(user.name);
    return fetch(`https://api.example.com/posts?userId=${user.id}`);
  })
  .then(response => response.json())
  .then(posts => console.log(posts))
  .catch(error => console.error("Error:", error));

3. Async/Await (ES2017—Best!) ✨

The modern, cleanest way—makes async code look synchronous:

// Async function automatically returns a Promise
async function fetchUserData() {
  try {
    // await pauses here until promise resolves
    const response = await fetch('https://api.example.com/user/1');
    const user = await response.json();
    console.log(user.name);
    
    const postsResponse = await fetch(`https://api.example.com/posts?userId=${user.id}`);
    const posts = await postsResponse.json();
    console.log(posts);
    
    return posts;  // Returns a Promise!
  } catch (error) {
    console.error("Error:", error);
    throw error;
  }
}

// Using async function
fetchUserData()
  .then(posts => console.log("Got posts:", posts))
  .catch(error => console.error("Failed:", error));

✓ Clean and Readable!

  • • Looks like synchronous code
  • • Easy to read and understand
  • • Error handling with familiar try/catch
  • • No callback hell or .then() chains

Parallel vs Sequential Execution

Understanding when to run tasks in parallel can dramatically speed up your code:

Sequential (Slower) ⏱️

1. Fetch user (1s)
2. Fetch posts (1s)
3. Fetch comments (1s)
Total: 3 seconds
async function sequential() {
  const user = await getUser();    // 1s
  const posts = await getPosts();  // 1s
  const comments = await getComments(); // 1s
  return { user, posts, comments };
}

Parallel (Faster!) ⚡

1. Fetch user
2. Fetch posts
3. Fetch comments
Total: 1 second (all at once!)
async function parallel() {
  // Start all requests at once
  const [user, posts, comments] = await Promise.all([
    getUser(),    // All three run
    getPosts(),   // simultaneously!
    getComments()
  ]);
  return { user, posts, comments };
}

Promise Utility Methods

Promise.all()

Wait for ALL to complete. Fails if ANY fails.

const results = await Promise.all([
  fetch('/api/user'),
  fetch('/api/posts'),
  fetch('/api/comments')
]);
// All or nothing!

Promise.race()

First one to finish wins!

const fastest = await Promise.race([
  fetch('/server1/data'),
  fetch('/server2/data'),
  fetch('/server3/data')
]);
// Returns first response

Promise.allSettled()

Wait for all, get all results (success or failure).

const results = await Promise.allSettled([
  fetch('/api/user'),
  fetch('/api/posts'),
  fetch('/api/invalid') // Won't fail!
]);
// [{status: 'fulfilled'}, ...]

Promise.any()

First SUCCESS wins (ignores failures).

const first = await Promise.any([
  fetch('/api/backup1'),
  fetch('/api/backup2'),
  fetch('/api/backup3')
]);
// First successful response

Common Async Patterns

1. Timeout Helper

function timeout(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function demo() {
  console.log("Start");
  await timeout(2000);
  console.log("2 seconds later");
}

2. Retry with Exponential Backoff

async function fetchWithRetry(url, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return await response.json();
    } catch (error) {
      if (i === retries - 1) throw error;
      // Wait longer each time: 1s, 2s, 4s
      await timeout(1000 * Math.pow(2, i));
    }
  }
}

3. Loading State Pattern

async function loadData() {
  const state = { data: null, loading: true, error: null };
  
  try {
    state.data = await fetch('/api/data').then(r => r.json());
  } catch (e) {
    state.error = e.message;
  } finally {
    state.loading = false;
  }
  
  return state;
}

// Usage in React/Vue
const { data, loading, error } = await loadData();

4. Promise with Timeout

async function fetchWithTimeout(url, timeoutMs = 5000) {
  const timeoutPromise = new Promise((_, reject) =>
    setTimeout(() => reject(new Error('Timeout')), timeoutMs)
  );
  
  const fetchPromise = fetch(url).then(r => r.json());
  
  return Promise.race([fetchPromise, timeoutPromise]);
}

// Fails if takes longer than 5 seconds
const data = await fetchWithTimeout('/api/slow-endpoint', 5000);

⚠️ Common Mistakes

  • ❌ Forgetting await: const data = fetchData(); gives you a Promise, not the data!
  • ❌ Not handling errors: Always use try/catch or .catch()
  • ❌ Sequential when should be parallel: Use Promise.all() for independent operations
  • ❌ Using async/await in loops: Results in sequential execution (use Promise.all with map)
  • ❌ Not returning in async functions: Async functions auto-wrap in Promise, but still need explicit return

💡 Key Concepts to Remember

  • async functions always return a Promise (automatically)
  • await can only be used inside async functions
  • await pauses execution until the Promise resolves
  • • Use Promise.all() for parallel operations (way faster!)
  • • Always handle errors with try/catch or .catch()
  • • The event loop handles async operations—JavaScript stays single-threaded

✓ Best Practices

  • Prefer async/await over .then() chains for readability
  • Always use try/catch with async/await to handle errors
  • Use Promise.all() for independent parallel operations
  • Don't forget await—use a linter to catch this!
  • Handle errors appropriately—don't swallow them silently
  • Add timeout logic for network requests (they can hang forever)
  • Use async IIFE if you need top-level await: (async () => { ... })()