JavaScript Promises are Kinda Confusing

JavaScript Promises are Kinda Confusing

Here's how I finally wrapped my head around them...

When I first started learning JavaScript (questionable life choice), I spent ages feeling completely baffled by the event loop, Promises, and how callbacks tie everything together. I highly recommend Philip Robert’s JSConf EU talk regarding the JavaScript event loop. In theory, the event loop handles long-running tasks (e.g., network requests) by shunting them off so the main thread can quickly update the UI. This explanation never stuck with me. How does it actually work? I’m writing this blog post about it so I don’t have to rewatch Philips talk every few months for interviews.

China's 50-Lane Traffic Jam Is Every Computer's Worst Nightmare

JavaScript is a Single-Threaded, Non-Blocking, Asynchronous, Concurrent Language

It’s a mouthful. JavaScript is single-threaded. You can think of a thread like a car lane. Multi-threaded languages can utilize multiple car lanes to handle their traffic (i.e., tasks). JavaScript can only utilize one lane, meaning one car can jam up traffic. The Call Stack is how JavaScript organizes and manages active function calls on the main thread.

Sometimes, we need to perform operations which may clog up this Call Stack. Let's say we have a function myTimeout.

function myTimeout(duration, callback) {
  const startTime = Date.now();
  // Just block the thread for the duration
  while (Date.now() - startTime < duration) {}
  // Once the loop finishes, call the callback
  callback();
}

Functions passed in as arguments are called Callback Functions. Whenever myTimeout is called, it will block our main thread for duration ms before calling callback.

Issue #1: Blocking the Main Thread

The main thread of a browser is responsible for updating the UI, ideally aiming for 60 frames per second (fps). That's a frame approximately every 16.67 ms. To maintain a smooth user experience, the main thread must complete all tasks (including rendering and processing user interactions) within each frame's time slot. If we throw a myTimeout into the mix, we clog up the main thread for duration ms. This renders the UI unresponsive within that duration.

You can try this yourself.

<div className="App">
    <h1>Count: {count}</h1>
    <button
        onClick={() => {
            myTimeout(1000, () => setCount((prev) => prev + 1));
        }}
    >
        Increment
    </button>
</div>

The asynchronous version of our myTimeout function would be the setTimeout method in JavaScript. Other asynchronous tasks can include event handlers, timers, and file system operations. One of the most common ones is fetch used to execute HTTP requests.

Let's say we've built a collaborative document editor called "Boogle Docs." Our editor should handle multiple users on one document and show changes in real time. Whenever a user makes a change or moves their cursor, other users should see this. One way to achieve this is by sending HTTP GET requests at intervals to fetch other users' data from a server. This is called polling. We’ll also need to send POST requests to update our own data. Network requests can be slow due to reasons beyond JavaScript’s control. If the main thread froze every time we fetched updates, our website would render at a painfully slow rate, and we'd lose all of our users (if it wasn't already for the lazy branding)!

But do not fear, the event loop is here!

A Beautiful Diagram of the Event Loop by GeeksforGeeks

Solution #1: The Event Loop

The event loop is best described visually, so if this is your first time hearing of it, go watch the talk! Your browser provides the JavaScript runtime with WebAPIs, a rich environment of platform services (e.g., additional threads, system APIs) which asynchronous tasks can utilize. Asynchronous methods (e.g., setTimeout, fetch) offload their heavy lifting onto that environment. This frees up the main thread for high-priority tasks, such as rendering frames.

WebAPIs resolve asynchronous tasks, then queue up their callbacks into the Callback Queue. The Callback Queue constantly checks the main thread to see if it's free. As soon as the main thread is free, the Callback Queue pushes the next callback function onto the main threads Call Stack.

This is known as the Event Loop. The Event Loop prioritizes synchronous operations over asynchronous ones.

Issue #2: Callback Hell

How can we use the results of an asynchronous operation? With myTimeout, we used a callback function. After a set amount of time, our callback function will be executed. What if we need to execute another myTimeout after the first one? Then we pass the next myTimeout as a callback to the first myTimeout.

myTimeout(10000, () => {
    // Maybe some code runs here
    myTimeout(9000, () => {
        // Maybe some code runs here
        myTimeout(8000, () => {
            // Maybe some code runs here
            myTimeout(7000, () => {
                // Maybe some code runs here
                myTimeout(6000, () => {
                    // Maybe some code runs here
                    myTimeout(5000, () => {
                        // Maybe some code runs here
                        myTimeout(4000, () => {
                            // Maybe some code runs here
                            myTimeout(3000, () => {
                                // Maybe some code runs here
                                myTimeout(2000, () => {
                                    // Maybe some code runs here
                                    myTimeout(1000, () => {
                                        console.log('Welcome to hell! 😈');
                                    });
                                });
                            });
                        });
                    });
                });
            });
        });
    });
});

It's more practical to imagine this with fetch requests, where one request relies on another requests data. This pattern is commonly referred to as Callback Hell. Promise me you won't do this at your first job.

Solution #2: Promises

Promises resolve this issue in JavaScript by providing a new way of handling callbacks. When you create a new Promise, you pass a callback function into the constructor. This function has two arguments: resolve and reject, both are callback functions themselves.

const promise = new Promise((resolve, reject) => {
    // TBD...
});

Within this Promise, we might use setTimout. We’ll pass a callback function into the setTimeout that performs some operations after the time is done, then calls the resolve callback at the end. The resolve function takes in the result of the operation, and passes it to the next task.

// Promise Chaining
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve(5);
  }, 1000);
}).then((res) => {
  console.log(res); // 5
  return new Promise((resolve, reject) {
    setTimeout(() => {
      resolve(res + 5);
    }, 1000);
  });
}).then((res) => {
  console.log(res); // 10
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(res + 5);
    }, 1000);
  });
}).then((res) => {
  console.log(res); // 15
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(res + 5);
    }, 1000);
  });
});

The resolve function passes the value to the callback(s) passed to then. In order to understand the system, we’ll begin building our own implementation of the Promise class.

// Example implementation of the resolve function within the Promise class
const resolve = (value) => {
    if (this.state === 'pending') {
        this.state = 'fulfilled';
        this.value = value;
        this.onFulfilledCallbacks.forEach(callback => callback(value));
    }
};

The then method can be called multiple times on one Promise. This is different from promise chaining as it attaches independent callbacks that will all be triggered when the Promise resolves. Each callback passed to then is stored in an array, and resolve iterates through it.

// Not Promise Chaining
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve(5);
  }, 1000);
})

promise.then((res) => {
  console.log(res); // 5
  return new Promise((resolve, reject) {
    setTimeout(() => {
      resolve(res + 5);
    }, 1000);
  });
})

promise.then((res) => {
  console.log(res); // 5
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(res + 5);
    }, 1000);
  });
})

promise.then((res) => {
  console.log(res); // 5
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(res + 5);
    }, 1000);
  });
});

The then method returns a Promise, enabling promise chaining.

then(onFulfilled, onRejected) {
    return new Promise((resolve, reject) => {
        // TBD...
    });
}

A handler function is defined within the then method where the then callback is executed. The return value of the callback is stored in result. If result is not a Promise, it’s passed to the next then method in the chain. Since then returns a Promise, this passing of result is done through it’s resolve function.

// Example implementation of the then method within the Promise class
then(onFulfilled, onRejected) {
    return new Promise((resolve, reject) => {
        const handleFulfilled = (value) => {
            try {
                const result = onFulfilled(value);
                if (result instanceof Promise) {
                    result.then(resolve, reject);
                } else {
                    resolve(result);
                }
            } catch (error) {
                reject(error);
            }
        };
        // ...
    });
}

If result is a Promise, then the resolve and reject callbacks are passed into that returned promise’s then method.

The handler is then appended onto the callbacks array of the Promise. Rejections are handled in a similar fashion to resolutions, with minor changes.

Bringing this all together, we have:

class Promise {
  constructor(executor) {
    this.state = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      if (this.state === 'pending') {
        this.state = 'fulfilled';
        this.value = value;
        this.onFulfilledCallbacks.forEach(callback => callback(value));
      }
    };

    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected';
        this.reason = reason;
        this.onRejectedCallbacks.forEach(callback => callback(reason));
      }
    };

    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }

  then(onFulfilled, onRejected) {
    return new Promise((resolve, reject) => {
      const handleFulfilled = (value) => {
        try {
          const result = onFulfilled(value);
          if (result instanceof Promise) {
            result.then(resolve, reject);
          } else {
            resolve(result);
          }
        } catch (error) {
          reject(error);
        }
      };

      const handleRejected = (reason) => {
        try {
          const result = onRejected ? onRejected(reason) : reason;
          if (result instanceof Promise) {
            result.then(resolve, reject);
          } else {
            reject(result);
          }
        } catch (error) {
          reject(error);
        }
      };

      if (this.state === 'fulfilled') {
        handleFulfilled(this.value);
      } else if (this.state === 'rejected') {
        handleRejected(this.reason);
      } else {
        this.onFulfilledCallbacks.push(handleFulfilled);
        this.onRejectedCallbacks.push(handleRejected);
      }
    });
  }

  catch(onRejected) {
    return this.then(null, onRejected);
  }
}

Promise chaining is a hell of a syntactic upgrade from Callback Hell, but it still leaves some more to be desired. It can be tedious to write, and even more so to read.

Bonus Solution: Async/Await

Async/await was introduced in ES2017 as a form of syntactic sugar over Promises. It provides the same functionality, while encouraging clean and readable code. Here’s an implementation of our setTimeout promise chain using async/await syntax.

async function timeouts() {
  let res = await new Promise((resolve, reject) => {
    setTimeout(() => resolve(5), 1000);
  });
  console.log(res); // 5

  res = await new Promise((resolve, reject) => {
    setTimeout(() => resolve(res + 5), 1000);
  });
  console.log(res); // 10

  res = await new Promise((resolve, reject) => {
    setTimeout(() => resolve(res + 5), 1000);
  });
  console.log(res); // 15

  res = await new Promise((resolve, reject) => {
    setTimeout(() => resolve(res + 5), 1000);
  });
}

timeouts();

Error handling can become a huge pain with async/await’s reliance on try/catch blocks, but that’s out of the scope of this blog post.

Conclusion

Promises are still confusing as hell. I have a higher appreciation for them now that I (sorta) understand how they work. I’ll leave you with this fun exercise I got from this awesome course hosted by Lydia Hallie at FrontendMasters.

// Put the logs in the correct order
Promise.resolve()
    .then(() => console.log(1));

queueMicrotask(() => console.log(2));

setTimeout(() => console.log(3), 0);

console.log(4);

new Promise(() => console.log(5));

(async () => console.log(6))();