Software Engineering   Observability  

ES7 await/async and superagent

By Chris Toshok  |   Last modified on January 11, 2019

TL;DR await/async are awesome, and you should use them instead of callbacks wherever you can (which is everywhere.)

Async functions for ECMAScript is a stage 3 (“candidate”) proposal for inclusion in the next version of the ECMAScript spec. Babel supports them. You really have no reason to not use them if you have a reasonably modern JS toolchain.

Why await/async?

Everyone who’s done any amount of JS async programming has been up against callback hell. Do a google image search for “pyramid of doom” and you’ll see a lot of really contrived examples, nested 20 deep.

The normal way of dealing with these nested callbacks is to hoist the callbacks out into separate functions. This makes the individual callbacks easier to deal with, but completely destroys apparent control flow, and debugging is a serious chore.

Promises are a bit better, in that you chain things together linearly (as opposed to nesting) to express that one action follows another.

request.doSomething()
    .then(() => doSomethingElse())
    .then(() => doSomethingForTheThirdTime())

Both approaches get really bogged down if there’s any branching control flow. And error handlingreallysucks in both approaches.

Async functions fix all that:

  • You write your code in an imperative style, and decorate calls to async functions (that you want the result from) with await. You decorate the surrounding function with async.
  • No callbacks.
  • You can use try/catch in your async code. You can use loops without recursion.
  • Stack traces actually work.

A bit about the state of our frontend

We use babel to translate es2015 (we haven’t jumped on the typescript bandwagon yet :) and our React code down to es5.

We use superagent in our JS to trigger requests from our frontend (the most complex case is driving our query interface), and superagent’s functionality for aborting requests comes in handy.

We got to a point a while back where the normal callback flow became intolerable, something like:

const foo = function() {
  request.executeQuery(params, (err, res) => {
    if (err) {
      // handle the error
      return;
    }
    if (_inFlightQueryMetadata) {
      _inFlightQueryMetadata.abort();
    }
    _inFlightQueryMetadata = request.queryMetadata(res.queryId, (err, res) => {
      if (err) {
        return;
      }
      if (res.metadata.something) {
        request.optionalThirdStep(res.metadata, (err, res) => {
          if (err) {
            return;
          }
          // do something with third step res
        });
      } else {
          // do something with second step res
      }
    });
  });
};

Some of you might look at that and think “big deal?”—but by the time I’m nesting the second callback, or even writing the second callback, it’s intolerable. It’s 2017. I’ve been writing code for a long time, and I don’t want to be writing complex code in continuation-passing style anymore. (And you shouldn’t either!)

We looked at async/await and really liked await’s built-in support for Promises (and the way it handled non-Promises). Doing let foo = await expression basically evaluates expression, calls Promise.resolve on it, and does the assignment to foo when the resulting Promise resolves.

Superagent doesn’t directly return promises, but its request object does expose a then method which returns a Promise. Through the semantics of Promise.resolve, this is as good as being a Promise to begin with. And we still have the superagent request object hanging around, if we want it. (Spoiler: we do.)

Here’s what the above example looks like using async/await:

const foo = async function() {
  // the function containing this code would 
  try {
    let res = await request.executeQuery(params);
    if (_inFlightQueryMetadata) {
      _inFlightQueryMetadata.abort();
    }
    _inFlightQueryMetadata = request.queryMetadata(res.queryId);
    res = await _inFlightQueryMetadata;
    if (res.metadata.something) {
      res = await request.optionalThirdStep(res.metadata);
      // do something with third step res
    } else {
      // do something with second step res
    }
  } catch (err) {
    // handle the error (we can handle all errors for all requests in this one handler if we choose)
    return;
  }
};

Babel translates that very compact piece of code into something barely resembling it (turning it into a pretty large state machine).

Each await expression is turned into a suspend point, where essentially the compiler generates a function that resumes the state machine for the code “after” the await, and sends that to Promise.then.


Okay, so await will work with a superagent request object. But we also rely on superagent’s abort functionality. What’s the story there? Can we abort a Promise?

Turns out the answer is “no.” There was a proposal for including Cancelable Promises in a future ECMAScript standard, but it was withdrawn in December 2016.

Cancelation is complicated, and there are different ways to do it (each with their own pros and cons). None of them are clearly “the best”, which may have led to death-by-bikeshedding. We may never know, but at least for us it doesn’t matter.

We don’t need general Promise cancelation. We don’t care if the request actually hit the server or if it was aborted before it got sent. We need less “abort” and more “I don’t care.”

And, fortunately, it turns out that superagent’s abort actually acts just like this.

In the callback case, an aborted request’s callback is just never called. Aborting is not an error, so the callback isn’t invoked for it. You just have this closure lying around (your callback) waiting to be GC’ed.

In the await case the behavior is similar. Those “suspend points” I mentioned above? Well, abort ensures that the state machine never resumes after that suspend point. Things get GC’ed just the same as in the callback case.

Tighter code. Easier to understand control flow. Hurray!

Thanks babel, tc39, and superagent. And thanks for reading!

 

Related Posts

OpenTelemetry   Observability  

Real User Monitoring With a Splash of OpenTelemetry

You're probably familiar with the concept of real user monitoring (RUM) and how it's used to monitor websites or mobile applications. If not, here's the...

Observability   Customer Stories  

Transforming to an Engineering Culture of Curiosity With a Modern Observability 2.0 Solution

Relying on their traditional observability 1.0 tool, Pax8 faced hurdles in fostering a culture of ownership and curiosity due to user-based pricing limitations and an...

Observability   LLMs  

Honeycomb + Google Gemini

Today at Google Next, Charity Majors demonstrated how to use Honeycomb to find unexpected problems in our generative AI integration. Software components that integrate with...