Async Await Chaining

Creating async await chaining is difficult. Nathaniel Inman walks though a variety of steps in order to achieve the ultimate solution and explain the process.

Async Await Chaining
Photo by Matthew Lancaster / Unsplash

Creating synchronously chained functions is straight forward. Codepen.

const UserCollectionSync = {
  find(id=''){
    document.writeln('1');
    return this;
  },
  update(){
    document.writeln('2');
    return this;
  },
  save(){
    document.writeln('3');
    return this;
  }
}
UserCollectionSync
  .find()
  .update()
  .save();

But when we decide to just throw some 'async' 'await' flags on those functions everything doesn't work. FOLLOWING CODE IS WRONG: Codepen.

// delay is used to represent some asynchronous work
function delay(ms){
  return new Promise(resolve => setTimeout(resolve, ms));
} //end delay()

const UserCollectionAsync = {
  async find(id=''){
    await delay(1000)
    document.writeln('1');
    return this;
  },
  async updateFor(options={}){
    await delay(50);
    document.writeln('2');
    return this;
  },
  async save(){
    await delay(50);
    document.writeln('3');
    return this;
  }
}
UserCollectionAsync
  .find()
  .updateFor('')
  .save();

The reason it fails is because when the 'find()' is called it returns a promise that hasn't resolved instead of the 'UserCollectionAsync' object (Factory pattern.) Therefor you can't find a function 'update()' on a promise. One common way of solving the issue is by allowing the factory to maintain a queue and force the user to add 'done()' at the end of the chain: Codepen.

// delay is used to represent some asynchronous work
function delay(ms){
  return new Promise(resolve => setTimeout(resolve, ms));
} //end delay()

const UserCollectionAsync = {
  _queue: [],
  find(id=''){
    this._queue.push(async ()=>{
      await delay(500);
      document.writeln('1');
    });
    return this;
  },
  updateFor(options={}){
    this._queue.push(async ()=>{
      await delay(500);
      document.writeln('2');
    })
    return this;
  },
  save(){
    this._queue.push(async ()=>{
      await delay(500);
      document.writeln('3');
    });
    return this;
  },
  done(){
    Promise.all(this._queue.map(fn=>fn()));
  }
}
UserCollectionAsync
  .find()
  .updateFor('')
  .save()
  .done();

Although this works, most api designers don't want to lock their consumers into requiring the 'done()' call. An easy way to drop that is to allow the factory to actually manage the queue itself: Codepen.

// delay is used to represent some asynchronous work
function delay(ms){
  return new Promise(resolve => setTimeout(resolve, ms));
} //end delay()

const UserCollectionAsync = {
  _queue: [],
  _working: [],
  _processing: null,
  async _process(){
    if(!this._working.length){
      this._working.push(...this._queue.splice(0,this._queue.length));
      this._processing = Promise.all(this._working.map(fn=>fn()));
      await this._processing;
      this._processing = null;
      this._working.splice(0,this._working.length);
    }else if(this._queue.length){
      await this._processing;
      this._process();
    }
  },
  find(id=''){
    this._queue.push(async ()=>{
      await delay(500);
      document.writeln('1');
    });
    this._process();
    return this;
  },
  updateFor(options={}){
    this._queue.push(async ()=>{
      await delay(500);
      document.writeln('2');
    });
    this._process();
    return this;
  },
  save(){
    this._queue.push(async ()=>{
      await delay(500);
      document.writeln('3');
    });
    this._process();
    return this;
  }
}
UserCollectionAsync
  .find()
  .updateFor('')
  .save();

The problem will be that each of those functions are actually atomic and don't actually pass data to each other so if you're expecting to find a user, then update just that user then save that user, it's not going to work. A simple way to fix that is to actually extend a Promise itself and drop the whole idea of a queue: Codepen.

// delay is used to represent some asynchronous work
function delay(ms){
  return new Promise(resolve => setTimeout(resolve, ms));
} //end delay()

class UserCollectionAsyncApi extends Promise {
  find(...args){
    return UserCollectionAsync.find.call(this,...args);
  }
  update(...args){
    return UserCollectionAsync.update.call(this,...args);
  }
  save(...args){
    return UserCollectionAsync.save.call(this,...args);
  }
}

const UserCollectionAsync = {
  find(id=''){
    return new UserCollectionAsyncApi(async (resolve, reject)=>{
      await delay(500);
      const user = new User();
      document.writeln(`found ${user.print}`);
      return resolve(user);
    });
  },
  update(options={}){
    return new UserCollectionAsyncApi(async (resolve, reject)=>{
      const user = await this;
      
      await user.update(options);
      document.writeln('(update)');
      return resolve(user);
    });
  },
  save(){
    return new UserCollectionAsyncApi(async (resolve, reject)=>{
      const user = await this;
      
      await delay(500);
      document.writeln('(save)');
      return resolve(user);
    });
  }
}
class User{
  constructor({firstName='Bob',lastName='Dole'}={}){
    this.firstName = firstName;
    this.lastName = lastName;
  }
  async update({firstName='Bob',lastName='Dole'}={}){
    await delay(500);
    this.firstName = firstName;
    this.lastName = lastName;
    document.writeln(`(updated ${this.print})`);
    return this;
  } 
  get print(){
    return `"${this.firstName} ${this.lastName}"`;
  }
}

UserCollectionAsync
  .find()
  .update({firstName: 'Larry', lastName:'Bird'})
  .save();

At the end of the day you're fighting between library complexity and library ease of use with additional weights of performance sneaking if you're not careful.