Lesson 04-Asynchronous Programming

Concepts

The single-threaded model means JavaScript runs on a single thread, executing one task at a time while others wait in a queue. Although JavaScript operates on a single main thread, the JavaScript engine uses multiple threads, with background threads assisting. JavaScript employs an “event loop” mechanism, suspending waiting threads.

JavaScript tasks are divided into two types: synchronous tasks (executed sequentially on the main thread) and asynchronous tasks (placed in a task queue, not entering the main thread immediately).

Task Queue and Event Loop

Besides the main thread, the JavaScript runtime includes a task queue. The main thread executes all synchronous tasks first. Once synchronous tasks are complete, it checks the task queue for asynchronous tasks. If conditions are met, an asynchronous task enters the main thread, becoming synchronous. This process repeats until the task queue is empty, ending the program.

The JavaScript engine continuously checks if asynchronous tasks are ready to enter the main thread. This repetitive checking mechanism is called the event loop.

Asynchronous Operation Modes

  • Callbacks: The most basic method for asynchronous operations.
function doSomethingAsync(callback) {
  setTimeout(() => {
    callback("Task done");
  }, 2000);
}

doSomethingAsync(result => {
  console.log(result); // "Task done" (after 2 seconds)
});
  • Event Listeners: Uses an event-driven model where task execution depends on events, not code order.
  • Publish/Subscribe: Tasks publish signals to a “signal center,” and others subscribe to these signals to know when to execute. This is also known as the observer pattern.

Asynchronous Flow Control

  • Serial Execution: Executes asynchronous tasks sequentially, one after another.
  • Parallel Execution: Executes all asynchronous tasks simultaneously, waiting for all to complete before proceeding.
  • Hybrid Approach: Limits parallel execution to a threshold (e.g., n tasks at a time) to avoid overloading system resources.

Timers

JavaScript provides timer functions for scheduled code execution, primarily through setTimeout() and setInterval(), which add timed tasks to the task queue.

setTimeout(): Executes a function once after a delay

var timerId = setTimeout(func|code, delay);

setInterval(): Repeatedly executes a function at specified intervals

var i = 1;
var timer = setInterval(function() {
  console.log(2);
}, 1000);

Synchronous vs. Asynchronous

Differences Between Synchronous and Asynchronous

In JavaScript, synchronous code executes line-by-line, with each line waiting for the previous one to complete. This is straightforward but can block the program during time-consuming operations.

// Synchronous code example
console.log('First step');
console.log('Second step');
console.log('Third step');
// Output: First step, Second step, Third step

Asynchronous code allows operations to run in the background without blocking the main thread. Results are handled via callbacks, Promises, or async/await when the operation completes.

// Asynchronous code example
console.log('First step');
setTimeout(() => {
  console.log('Second step');
}, 0);
console.log('Third step');
// Output: First step, Third step, Second step

JavaScript’s Single-Threaded Model

JavaScript’s single-threaded model ensures only one task runs at a time, simplifying programming by avoiding race conditions and deadlocks common in multi-threading. However, long-running tasks can block subsequent tasks.

// Single-threaded blocking example
function longRunningTask() {
  let sum = 0;
  for (let i = 0; i < 1000000000; i++) {
    sum += i;
  }
  console.log(sum);
}

console.log('Start');
longRunningTask();
console.log('End');
// 'End' outputs after longRunningTask completes

Event Loop Basics

The event loop is the core mechanism for handling asynchronous operations. It checks for completed asynchronous tasks after synchronous code executes, running their callbacks. Key components include:

  1. Call Stack: Tracks currently executing functions.
  2. Task Queue: Stores pending callbacks.
  3. Event Loop: Checks if the call stack is empty, then processes tasks from the queue.
// Event loop example
console.log('1'); // Synchronous task

setTimeout(() => {
  console.log('2'); // Asynchronous callback
}, 0);

console.log('3'); // Synchronous task
// Output: 1, 3, 2

Asynchronous Implementation Methods

Callbacks (Callback Hell Issue)

Callbacks are the simplest asynchronous approach, passing a function to another to handle results after an operation completes. However, nested callbacks lead to “callback hell,” making code hard to read and maintain.

// Callback hell example
fs.readFile('file1.txt', 'utf8', function(err, data1) {
  if (err) return console.error(err);
  fs.readFile('file2.txt', 'utf8', function(err, data2) {
    if (err) return console.error(err);
    fs.readFile('file3.txt', 'utf8', function(err, data3) {
      if (err) return console.error(err);
      console.log(data1 + data2 + data3);
    });
  });
});

Promises (Constructor, then/catch/finally)

Promises represent the eventual completion or failure of an asynchronous operation, offering then, catch, and finally methods for handling results or errors. Chaining improves code linearity.

// Promise basic usage
function readFilePromise(filename) {
  return new Promise(function(resolve, reject) {
    fs.readFile(filename, 'utf8', function(err, data) {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

readFilePromise('file1.txt')
  .then(function(data1) {
    return readFilePromise('file2.txt');
  })
  .then(function(data2) {
    return readFilePromise('file3.txt');
  })
  .then(function(data3) {
    console.log(data1 + data2 + data3); // Note: data1 is inaccessible here
  })
  .catch(function(err) {
    console.error(err);
  });

Fixed Promise chain (storing intermediate results):

// Fixed Promise chain
let data1, data2;

readFilePromise('file1.txt')
  .then(function(data) {
    data1 = data;
    return readFilePromise('file2.txt');
  })
  .then(function(data) {
    data2 = data;
    return readFilePromise('file3.txt');
  })
  .then(function(data3) {
    console.log(data1 + data2 + data3);
  })
  .catch(function(err) {
    console.error(err);
  });

Promises act as proxies between asynchronous operations and callbacks, allowing asynchronous code to resemble synchronous flow without nested callbacks. A Promise has three states:

  • Pending: Operation not completed.
  • Fulfilled: Operation succeeded.
  • Rejected: Operation failed.
var p1 = new Promise(function(resolve, reject) {
  if (/* async operation successful */) {
    resolve(value);
  } else {
    reject(new Error());
  }
});

p1.then(console.log, console.error);
// "Success"

var p2 = new Promise(function(resolve, reject) {
  reject(new Error('Failure'));
});
p2.then(console.log, console.error);

Promise.all

const promise1 = new Promise(resolve => setTimeout(() => resolve("Task 1"), 1000));
const promise2 = new Promise(resolve => setTimeout(() => resolve("Task 2"), 2000));

Promise.all([promise1, promise2]).then(results => {
  console.log(results); // ["Task 1", "Task 2"] (after 2 seconds)
});

Promise.race

const promise1 = new Promise(resolve => setTimeout(resolve, 2000, "Task 1"));
const promise2 = new Promise(resolve => setTimeout(resolve, 1000, "Task 2"));

Promise.race([promise1, promise2]).then(result => {
  console.log(result); // "Task 2" (after 1 second)
});

async/await Syntax Sugar

async/await is syntactic sugar for Promises, making asynchronous code resemble synchronous code for improved readability and maintainability.

// async/await basic usage
async function readFiles() {
  try {
    const data1 = await readFilePromise('file1.txt');
    const data2 = await readFilePromise('file2.txt');
    const data3 = await readFilePromise('file3.txt');
    console.log(data1 + data2 + data3);
  } catch (err) {
    console.error(err);
  }
}

readFiles();

Generator Functions (yield and next)

ES6 Generator functions pause and resume execution, combining with Promises to mimic async/await.

// Generator function basic usage
function* readFilesGenerator() {
  try {
    const data1 = yield readFilePromise('file1.txt');
    const data2 = yield readFilePromise('file2.txt');
    const data3 = yield readFilePromise('file3.txt');
    console.log(data1 + data2 + data3);
  } catch (err) {
    console.error(err);
  }
}

// Manual Generator execution
const generator = readFilesGenerator();
generator.next().value
  .then(data1 => generator.next(data1).value)
  .then(data2 => generator.next(data2).value)
  .then(data3 => generator.next(data3))
  .catch(err => generator.throw(err));

// Auto-execute with co library
const co = require('co');
co(readFilesGenerator);

Microtasks and Macrotasks (Promise, setTimeout, setImmediate)

JavaScript tasks are categorized as microtasks (e.g., Promise callbacks, MutationObserver) and macrotasks (e.g., setTimeout, setInterval, setImmediate in Node.js, I/O). Microtasks execute before macrotasks in the event loop.

// Microtask vs. macrotask example
console.log('1'); // Synchronous task

setTimeout(() => {
  console.log('2'); // Macrotask
}, 0);

Promise.resolve().then(() => {
  console.log('3'); // Microtask
});

setImmediate(() => {
  console.log('4'); // Macrotask (Node.js)
});

console.log('5'); // Synchronous task
// Browser output: 1, 5, 3, 2
// Node.js output: 1, 5, 3, 4, 2 (may vary)

Common Asynchronous Scenarios

AJAX and Fetch API

AJAX enables partial page updates without reloading. The modern Fetch API replaces XMLHttpRequest for cleaner asynchronous HTTP requests.

// Fetch API for AJAX requests
// GET request
fetch('https://api.example.com/data')
  .then(response => {
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    return response.json();
  })
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error('Fetch error:', error);
  });

// POST request
fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({key: 'value'})
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));

// async/await approach
async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error('Fetch error:', error);
  }
}

fetchData();

Timers (setTimeout, setInterval)

Timers schedule code execution after a delay or at intervals.

// setTimeout basic usage
console.log('Start');
setTimeout(() => {
  console.log('Timeout callback');
}, 1000);
console.log('End');
// Output: Start, End, Timeout callback

// setInterval basic usage
let counter = 0;
const intervalId = setInterval(() => {
  counter++;
  console.log(`Interval ${counter}`);
  if (counter >= 5) {
    clearInterval(intervalId);
  }
}, 1000);

// Promise-wrapped setTimeout
function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function delayedLog() {
  console.log('Start');
  await delay(1000);
  console.log('After 1 second');
  await delay(2000);
  console.log('After 3 seconds total');
}

delayedLog();

File Reading (Node.js fs Module)

Node.js file operations are typically asynchronous, handled via callbacks, Promises, or async/await.

const fs = require('fs');
const fsPromises = require('fs').promises;

// Callback-based file read
fs.readFile('example.txt', 'utf8', (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(data);
});

// Promise-based file read
fsPromises.readFile('example.txt', 'utf8')
  .then(data => console.log(data))
  .catch(err => console.error(err));

// async/await file read
async function readFileAsync() {
  try {
    const data = await fsPromises.readFile('example.txt', 'utf8');
    console.log(data);
  } catch (err) {
    console.error(err);
  }
}

readFileAsync();

Database Operations (Node.js Database Drivers)

Most Node.js database drivers support asynchronous operations via callbacks, Promises, or async/await.

// MongoDB example (official driver)
const { MongoClient } = require('mongodb');

// Connection URL
const url = 'mongodb://localhost:27017';
const client = new MongoClient(url);

// Callback approach
client.connect(function(err) {
  if (err) {
    console.error(err);
    return;
  }
  console.log('Connected successfully to server');

  const db = client.db('testdb');
  const collection = db.collection('testcollection');

  collection.find({}).toArray(function(err, docs) {
    if (err) {
      console.error(err);
      return;
    }
    console.log('Found documents:', docs);

    client.close();
  });
});

// Promise approach
client.connect()
  .then(() => {
    console.log('Connected successfully to server');
    const db = client.db('testdb');
    const collection = db.collection('testcollection');

    return collection.find({}).toArray();
  })
  .then(docs => {
    console.log('Found documents:', docs);
    return client.close();
  })
  .catch(err => {
    console.error(err);
  });

// async/await approach
async function queryDatabase() {
  try {
    await client.connect();
    console.log('Connected successfully to server');

    const db = client.db('testdb');
    const collection = db.collection('testcollection');

    const docs = await collection.find({}).toArray();
    console.log('Found documents:', docs);
  } catch (err) {
    console.error(err);
  } finally {
    await client.close();
  }
}

queryDatabase();

Concurrency Control (Promise.all, Promise.race)

Managing multiple asynchronous operations often requires waiting for all to complete or just the first. Promise provides methods like Promise.all and Promise.race.

// Promise.all - Wait for all Promises to complete
const promise1 = Promise.resolve(3);
const promise2 = 42;
const promise3 = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, 'foo');
});

Promise.all([promise1, promise2, promise3])
  .then(values => {
    console.log(values); // [3, 42, 'foo']
  })
  .catch(error => {
    console.error(error);
  });

// Real-world example - Fetch multiple APIs in parallel
async function fetchMultipleUrls(urls) {
  try {
    const promises = urls.map(url => fetch(url).then(res => res.json()));
    const results = await Promise.all(promises);
    console.log('All data fetched:', results);
    return results;
  } catch (error) {
    console.error('Error fetching data:', error);
    throw error;
  }
}

// Promise.race - Return first completed Promise
const fastPromise = new Promise(resolve => setTimeout(resolve, 100, 'fast'));
const slowPromise = new Promise(resolve => setTimeout(resolve, 500, 'slow'));

Promise.race([fastPromise, slowPromise])
  .then(value => {
    console.log(value); // 'fast'
  });

// Real-world example - Timeout
function fetchWithTimeout(url, timeout) {
  const fetchPromise = fetch(url);
  const timeoutPromise = new Promise((_, reject) => 
    setTimeout(() => reject(new Error('Request timeout')), timeout)
  );

  return Promise.race([fetchPromise, timeoutPromise])
    .then(response => response.json())
    .catch(error => {
      console.error('Fetch error:', error);
      throw error;
    });
}

// Promise.allSettled - Wait for all Promises (success or failure)
const promises = [
  Promise.resolve(1),
  Promise.reject(new Error('Error 1')),
  Promise.resolve(3),
  Promise.reject(new Error('Error 2'))
];

Promise.allSettled(promises)
  .then(results => {
    results.forEach(result => {
      if (result.status === 'fulfilled') {
        console.log('Fulfilled:', result.value);
      } else {
        console.log('Rejected:', result.reason.message);
      }
    });
  });

// Promise.any - Return first fulfilled Promise
const promises2 = [
  Promise.reject(new Error('Error 1')),
  Promise.reject(new Error('Error 2')),
  new Promise(resolve => setTimeout(resolve, 100, 'Success'))
];

Promise.any(promises2)
  .then(value => {
    console.log('First success:', value); // 'Success'
  })
  .catch(errors => {
    console.error('All promises rejected:', errors);
  });

Advanced Asynchronous Patterns

Async Iterators and for-await-of

ES2018 introduced async iterators and for-await-of, simplifying handling of asynchronous data streams.

// Async iterator example
async function* asyncGenerator() {
  let i = 0;
  while (i < 3) {
    await new Promise(resolve => setTimeout(resolve, 100));
    yield i++;
  }
}

(async function() {
  for await (const num of asyncGenerator()) {
    console.log(num); // 0, 1, 2 (every 100ms)
  }
})();

// Real-world example - Paginated API data
async function* fetchPaginatedData(url) {
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    const response = await fetch(`${url}?page=${page}`);
    const data = await response.json();

    yield data.items;

    hasMore = data.hasMore;
    page++;
  }
}

(async function() {
  const dataGenerator = fetchPaginatedData('https://api.example.com/items');
  for await (const items of dataGenerator) {
    console.log('Got items:', items);
    // Process items...
  }
})();

Canceling Asynchronous Operations

Native Promises don’t support cancellation, but patterns like AbortController enable similar functionality.

// Cancel fetch with AbortController
const controller = new AbortController();
const signal = controller.signal;

fetch('https://api.example.com/data', { signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('Fetch aborted');
    } else {
      console.error('Fetch error:', err);
    }
  });

// Cancel request
setTimeout(() => {
  controller.abort();
}, 1000); // Abort after 1 second

// Custom cancelable Promise
function makeCancelable(promise) {
  let hasCanceled_ = false;

  const wrappedPromise = new Promise((resolve, reject) => {
    promise.then(
      val => hasCanceled_ ? reject({ isCanceled: true }) : resolve(val),
      error => hasCanceled_ ? reject({ isCanceled: true }) : reject(error)
    );
  });

  return {
    promise: wrappedPromise,
    cancel() {
      hasCanceled_ = true;
    }
  };
}

// Usage example
const somePromise = new Promise(resolve => {
  setTimeout(() => resolve('Done'), 2000);
});

const cancelablePromise = makeCancelable(somePromise);

cancelablePromise.promise
  .then(val => console.log(val))
  .catch(err => {
    if (err.isCanceled) {
      console.log('Promise was canceled');
    } else {
      console.error('Error:', err);
    }
  });

// Cancel promise
setTimeout(() => {
  cancelablePromise.cancel();
}, 1000); // Cancel after 1 second

Best Practices for Asynchronous Error Handling

Robust error handling is critical for reliable asynchronous code. Common patterns include:

// 1. Error handling in Promise chain
fetch('https://api.example.com/data')
  .then(response => {
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    return response.json();
  })
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error('Error in fetch chain:', error);
  });

// 2. try/catch in async/await
async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error('Error in fetchData:', error);
  }
}

// 3. Error handling in parallel operations
async function fetchMultipleWithFallback(urls) {
  try {
    const promises = urls.map(url => 
      fetch(url)
        .then(res => res.json())
        .catch(error => {
          console.error(`Error fetching ${url}:`, error);
          return null; // Fallback to null
        })
    );

    const results = await Promise.all(promises);
    const validResults = results.filter(result => result !== null);
    console.log('Valid results:', validResults);
    return validResults;
  } catch (error) {
    console.error('Unexpected error in fetchMultipleWithFallback:', error);
    throw error;
  }
}

// 4. Global unhandled Promise rejection
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // Application-specific logging, alerting, etc.
});

// 5. Timeout pattern
function withTimeout(promise, timeoutMs) {
  return new Promise((resolve, reject) => {
    const timeoutId = setTimeout(() => {
      reject(new Error(`Timeout after ${timeoutMs}ms`));
    }, timeoutMs);

    promise
      .then(resolve)
      .catch(reject)
      .finally(() => clearTimeout(timeoutId));
  });
}

// Usage example
withTimeout(fetch('https://api.example.com/data'), 3000)
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error));

Share your love