Profile image
Jinyoung
Dev

TIL-02: JavaScript Asynchronous Processing and Promises

90% Human
10% AI
TIL-02: JavaScript Asynchronous Processing and Promises
0 views
8 min read

Why Do We Need 'Asynchronous' Processing?

JavaScript is a single-threaded language. This means it can only do one thing at a time.

But in the real world, situations like this happen:

User clicks a button
  → Requests data from the server (takes 3 seconds)
  → The screen completely freezes for those 3 seconds?? ❌

To use a restaurant analogy, it's like having a single chef who refuses to take orders from customers B, C, and D for the entire 30 minutes it takes to cook customer A's steak. That would be ridiculous.

So, a real-world chef puts the steak in the oven and handles other orders in the meantime. They can just take it out when the oven timer rings. JavaScript works the exact same way. A mechanism called the Event Loop delegates time-consuming tasks (network requests, timers, etc.) to the browser or Node.js, and once the task is finished, it receives the result back through the callback queue.

Asynchronous Processing = "Delegate time-consuming tasks and do other work in the meantime."


The Evolutionary History of Asynchronous Processing (Why Promises Were Introduced)

Era 1. Callback Functions - The Original Method

// Fetch data → process it → save it → send notification
fetchUser(userId, function(user) {
  fetchOrders(user.id, function(orders) {
    processOrders(orders, function(result) {
      saveResult(result, function(saved) {
        sendNotification(saved, function(notif) {
          // Callback Hell!
          // We are already 5 indentation levels deep just to get here
        })
      })
    })
  })
})

This is the infamous "Callback Hell" or "Pyramid of Doom".

Problems with Callbacks:

  • Extremely low readability (code keeps indenting to the right)
  • Error handling must be done separately for each callback
  • Very difficult to track the flow of the code
  • Hard to guarantee the execution order

Era 2. Promises - Introduced in ES6 (2015)

A Promise is an object representing a value that will be available in the future.

// Writing the same logic with Promises
fetchUser(userId)
  .then(user => fetchOrders(user.id))
  .then(orders => processOrders(orders))
  .then(result => saveResult(result))
  .then(saved => sendNotification(saved))
  .catch(error => console.error("If an error occurs anywhere, it comes here"));

It's much easier to read. The code appears to flow top-down.

The 3 States of a Promise:

┌────────────────────────────────────────────────────────┐
│                                                        │
│   ⏳ Pending                                           │
│   → Initial state. No result yet.                      │
│           ↙              ↘                             │
│   ✅ Fulfilled          ❌ Rejected                    │
│   → Success! Got a value  → Failure. An error occurred │
│                                                        │
│   * Once Fulfilled or Rejected, the state is Settled.  │
│   * A Settled Promise does not change again.           │
│                                                        │
└────────────────────────────────────────────────────────┘

Basic Promise Creation:

const myPromise = new Promise((resolve, reject) => {
  // Perform asynchronous task
  const success = true;

  if (success) {
    resolve("Success value!");  // Transitions to Fulfilled state
  } else {
    reject(new Error("Fail"));  // Transitions to Rejected state
  }
});

// Usage
myPromise
  .then(value => console.log(value))      // "Success value!"
  .catch(error => console.error(error));  // Error handling

Practical Example - Fetching data with the Fetch API:

// You can copy and paste this directly into your browser console to run it
fetch('https://jsonplaceholder.typicode.com/users/1')
  .then(response => response.json())
  .then(user => console.log(user.name))  // "Leanne Graham"
  .catch(error => console.error("Request failed:", error));

fetch() is a standard Web API that returns a Promise. In the code above, you can see .then() being chained twice — the first converts the response to JSON, and the second uses that data.

Era 3. async/await - ES2017

This is syntactic sugar that most effectively addresses the drawbacks of Promises. It allows you to write Promise-based code as if it were synchronous.

// Promise version (Cons: Hard to share variables, hard to read)
function getOrdersForUser(userId) {
  return fetchUser(userId)
    .then(user => {
      return fetchOrders(user.id)
        .then(orders => ({ user, orders })); // A workaround to pass 'user' down
    })
    .then(({ user, orders}) => {
      return processOrders(user, orders);
    });
}

// async/await version
async function getOrdersForUser(userId) {
  const user = await fetchUser(userId);       // Freely use the 'user' variable
  const orders = await fetchOrders(user.id);  // Freely use the 'orders' variable
  return processOrders(user, orders);         // Just use both naturally!
}

Error handling also becomes much more natural:

async function getOrdersForUser(userId) {
  try {
    const user = await fetchUser(userId);
    const orders = await fetchOrders(user.id);
    return processOrders(user, orders);
  } catch (error) {
    // The stack trace clearly shows where the error occurred
    console.error("Error occurred:", error);
  }
}

Important: async/await does not replace Promises; it is simply a more convenient syntax that runs on top of Promises.


Pros and Cons of Promises

✅ Pros:

ProDescriptionCompared to Callbacks
ChainingSequential expression via .then().then()Escapes Callback Hell
Unified Error HandlingHandles all errors with a single .catch()No need for error handling in every callback
Settled StateOnce a state is decided, it doesn't changePredictable behavior
Parallel ProcessingHandles multiple tasks simultaneously with Promise.all()Faster than sequential processing

❌ Cons:

Con 1. Chaining can still be hard to read

// Difficult to share variables across different 'then' blocks
fetchUser()
  .then(user => {
    return fetchOrders(user.id); // What if we want to use 'user' in the next 'then'?
  })
  .then(orders => {
    // How do we access 'user' here?
    // We have to use workarounds like saving 'user' to an external variable.
  });

Con 2. Debugging difficulties

// The stack trace doesn't clearly show which 'then' threw the error
fetchUser()
    .then(processData)    // Did it happen here?
    .then(saveData)       // Or here?
    .then(notify)         // Or here?
    .catch(e => console.log(e)); // Hard to track the error's origin

Con 3. Cannot be cancelled

const promise = fetchHugeData(); // Started

// The user leaves the page... but we can't stop the request
// promise.cancel() ← No such method exists

Con 4. Complex branching inside then chains

// Readability drops when different logic is needed based on conditions
fetchData()
  .then(data => {
    if (data.type === 'A') {
      return processA(data).then(result => ({result, type: 'A'}));
    } else {
      return processB(data).then(result => ({result, type: 'B'}));
    }
  })
  .then(({result, type}) => {
    // Having to pass 'type' along like this feels hacky.
  })

Useful Tools in Practice

So far, we've looked at the evolution of asynchronous processing from Callbacks → Promises → async/await. Now let's explore some useful supplementary tools within the Promise ecosystem for real-world scenarios.

Promise.all / Promise.allSettled / Promise.race - Parallel Processing

Promise.all - "All must succeed to proceed"

// If done sequentially, 3 seconds each = 9 seconds
// With Promise.all done concurrently, it takes 3 seconds (as long as the slowest one)
const [user, posts, comments] = await Promise.all([
  fetchUser(userId),
  fetchPosts(userId),
  fetchComments(userId)
]);
// However, if even one fails, the entire operation fails

Promise.allSettled - "Waits for all regardless of the result" (ES2020)

// Promise.all behavior: If one fails, the rest are ignored
// Promise.allSettled: Returns the results of all, whether they succeeded or failed
const results = await Promise.allSettled([
  fetchUser(userId),
  fetchPosts(userId),     // ← Even if this fails
  fetchComments(userId)   // This will still return its result
]);

results.forEach(result => {
  if (result.status === 'fulfilled') {
    console.log(result.value);
  }
  if (result.status === 'rejected') {
    console.log(result.reason);
  }
})

Promise.race - "Only the fastest one"

// Useful for implementing timeouts
const result = await Promise.race([
  fetchData(),                    // Actual request
  new Promise((_, reject) => {    // Timeout
    setTimeout(() => reject(new Error("Timeout")), 5000);
  })
]);

AbortController - Solving the Promise Cancellation Problem

const controller = new AbortController();

// Start request
fetch('/api/huge-data', { signal: controller.signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Request was cancelled');  // Cancellation detected
    }
  });

// When the user leaves the page
controller.abort(); // Cancel the request

Note: Promises are one-shot, returning only a single value. If you need to handle real-time continuous data streams (WebSockets, continuous events, etc.), consider looking into separate stream processing libraries like RxJS Observables.

Overall Comparison Summary

FeatureCallbacksPromiseasync/awaitObservable
Readability❌ Low⚠️ Medium✅ High⚠️ Medium
Error Handling❌ Scattered⚠️ .catch✅ try/catch.catch
Cancellable⚠️ (AbortController)⚠️ (Same)
Multiple Values
Debugging❌ Difficult⚠️ Medium✅ Easy⚠️ Medium
Learning Curve✅ Easy⚠️ Medium✅ Easy❌ Difficult

Comments (0)

Checking login status...

No comments yet. Be the first to comment!