Ehsaan’s blog

Ehsaan's photo
Still learning JS Promises

Still learning JS Promises

by Ehsaan Forghani

Caution: JavaScript ahead!

A few weeks ago, I did a presentation before a group of my colleagues in Mobiquity about Functional Reactive Programming. I was trying to demonstrate how well the developer can implement a very common pattern: buffer processing.

To give it a short introduction, buffer processing tries to collect as many events in an interval as possible. In other words, it tries to collect maximum n iteratee in an interval of t in a collection, however, if the collection reaches n items in a shorter interval, therefore the whole collection is returned and a new interval begins.

This pattern is especially useful when you're persisting data in the hot paths, because it drastically decreases the network messages round-trips and other pre-statement inefficiencies.

Making Promises

This is the code I demoed at the end of my presentation:

const Redis = require('ioredis');
const {
  fromEvent,
  scan,
  bufferTime,
  map,
  from,
  filter,
} = require('rxjs');
const { persist } = require('./db');

const redis = new Redis();
const CHANNEL = 'chn';

async function main() {
  await redis.subscribe(CHANNEL);

  // create an observable from "message" events
  const messages = fromEvent(redis, 'message').pipe(
    map(([_, message]) => message), // "pluck" message part of the tuple
    filter((msg) => !!msg), // filter out the truthy values
  );

  // creates a buffered observable from messages with an interval of 3 seconds, 3 messages max
  const buffered = messages.pipe(bufferTime(3000, undefined, 3));
  // buffered.subscribe((msgs) => console.log('Buffered:', msgs));

  // creates promises from each batch
  const promises = buffered.pipe(map(
    (msgs) => Promise.allSettled(msgs.map(persist))
  ));

  // at the end, consume the promises to trigger the event loop
  promises.subscribe(async (promise) => {
    const results = await promise;
    console.dir({ results }, { depth: null });
  });
}

main();

It's fairly simple:

  1. a Redis connection is established, and we're subscribing to a particular channel
  2. we create an Observable based on the coming messages from our subscription
  3. we create a new Observable based on the previous one, but piping it through bufferTime operator, this observable emits 3 messages in a 3-second interval
  4. we create yet another new Observable, mapping the collections we're receiving and creating Promises by mapping the collection items to persist function (persist looks like (item) => Promise<{ msg: string; ok: boolean }>)
  5. in the end, we subscribe to the last Observable, we wait for the Promise results to come back and simply display them. It'll look like something like this:
{
    "results": [
         { "status": "fulfilled", "value": { "msg": "test", "ok": true } },
         { "status": "fulfilled", "value": { "msg": "test", "ok": true } }
    ]
}

In theory, the above piece of code is working perfectly. One of the audience of the presentation asked a question:

What happens if we remove the last Observer, or we remove await keyword? Will promises get neglected?

The answer is that promises still get be fulfilled and resolved , and they won't get neglected, we just won't know the fulfillment value. I mistakenly gave another answer at the time, replying that the promise will be fulfilled but won't be resolved.

This is not true, and I had a feeling about it. So I decided to dig in deeper, and learned a couple of things I didn't know before.

fulfill vs. resolve

Let's begin with reviewing the definition of a Promise and its possible states:

A Promise is an object that is used as a placeholder for the eventual results of a deferred (and possibly asynchronous) computation. Any Promise object is in one of three mutually exclusive states: fulfilled, rejected, and pending:

  • A promise p is fulfilled if p.then(f, r) will immediately enqueue a Job to call the function f.
  • A promise p is rejected if p.then(f, r) will immediately enqueue a Job to call the function r.
  • A promise is pending if it is neither fulfilled nor rejected.

A promise is said to be settled if it is not pending, i.e. if it is either fulfilled or rejected. A promise is resolved if it is settled or if it has been “locked in” to match the state of another promise. An unresolved promise is always in the pending state. A resolved promise may be pending, fulfilled or rejected.

ECMAScript specs define two possibilities for a promise resolution values: either it's a Thenable, or it's anything else. If it's the value is Thenable, then it's defined as locked in, which means now the promise state is dependent on Thenable.

No, don't read that again. We'll see it in a moment.

All this rambling to say that in order to determine a promise state, we have to look at its fulfillment value type. Is it a Thenable (a promise is a Thenable, or whatever else that implements then(resolve, reject))? Then, it's fulfilled, but not resolved. Is it something else (a Number, Boolean, Function)? Then, it's fulfilled and resolved.

Let's say your friend owes you some money. You ask for your money back, your friend makes you a Promise that he'll be back with your money. Your money is still pending, so your friend's return, he's also not fulfilled because he's not back yet, your debt is not settled.

Your friend comes back, so he fulfilled, but he makes another Promise, telling you than he'll have it by 5 PM. Now you're mad, you don't care about the fact that he returned (fulfilled) but it didn't resolve anything, your money is still pending because of his new promise and, of course, your debt is not settled. In ECMA terms, his initial promise is now locked-on.

Take a look at this example:

const p1 = new Promise((res, rej) => {
  setTimeout(() => {
    res('Hello, I am a String');
  }, 1);
});

Fairly simple, isn't it? Let's take a look at another one:

const p2 = new Promise((res) => {
  setTimeout(() => {
    res(new Promise((_, rej) => {
      rej(new Error('gotcha!'));
    }));
  }, 1);
});

p2 is fulfilled with another Promise (which is also a Thenable), therefore its state is now dependent on that Promise. See for yourself:

Promise.allSettled([p1, p2]);
// Promise {
//   [
//     { status: 'fulfilled', value: 'Hello, I am a String' },
//     {
//       status: 'rejected',
//       reason: Error: gotcha!
//     }
//    ]
// }

See that p2 is settled and rejected as well. This locked-in state makes Promise chaining possible.

Task and Microtask

Look again at ECMA specifications:

  • A promise p is fulfilled if p.then(f, r) will immediately enqueue a Job to call the function f.

A Job is a term to describe executions with higher priority (e.g. promise callbacks) compared to regular Tasks (e.g. I/O and timers). ECMAScript concept of "jobs" is similar to microtasks that can be found in V8.

I'm not going to get into more details about this, because in V8 dev blog, there's an eloquent summary of microtasks role in deferred executions for async/await and promises:

On a high level there are tasks and microtasks in JavaScript. Tasks handle events like I/O and timers, and execute one at a time. Microtasks implement deferred execution for async/await and promises, and execute at the end of each task. The microtask queue is always emptied before execution returns to the event loop.

I'd highly recommend reading the V8 blog post Faster async functions and promises to get a deep grasp of how V8 engine evolved and how is it handling async/await and promises.

The task model of promises in Node.js is similar to browsers. Check out Jake Archibald's fantastic explanation of Tasks, microtasks, queues and schedules. You don't want to miss it. ;)

Final thoughts

I've been using JavaScript professionally every day for almost 6 years now, and there's constantly something new to learn about it. There's still a lot more to the event loop than tasks and microtasks. Promise states are often confused or mistaken, but I hope I could shed a little light into them.

There are a few links I'd like to share, if you're interested to read more about JavaScript:

Image credit goes Mahdi Bafande