DEV Community

Cover image for Putting Asynchronous Code in a Headlock
Kurt Bauer
Kurt Bauer

Posted on

Putting Asynchronous Code in a Headlock

The Simpsons GIF

If only we were all as enlightened as Homie

The Gist

In my last post, I lightly went over what asynchronous functions were and how they related to AJAX, which makes use of it in the synchronous JavaScript universe.

Here I'll take some time to go more in depth into async VS sync, and different patterns that are applied in order to achieve asynchronicity.

The Why

  • It's useful information when attempting to access databases or APIs

The What

Synchronous Example

// An example of an index.js file, running Node, would see this in the terminal
 
console.log('This is synchronous code, or blocking')
console.log('waiting for the first to complete before running')

Asynchronous Example

// An example of an index.js file, running Node, would see this in the terminal
 
console.log('This is synchronous code, or blocking')

setTimeOut(()=> console.log('Waiting to run, not causing a blockage'), 2000)

console.log('waiting for the first to complete before running')

The setTimeOut() function, would be an example of a function that is considered "non-blocking".

  • The code presented above
    • in the async example, the second setTimeOut() call will only run 2 seconds after.
    • The first and last calls would show up in your terminal, and after the allotted time, the middle function.

What Have we learned so far?

  • Synchronous Code (blocking)

    • BLOCKING - finishes work only after it completes
    • Needs 2 or more threads or will cause program to crash/freeze
    • Would see this occur when making calls to a database/api at an external url for example
    • The single thread is focused on completing the first task in the call-stack that it finds, and will put the rest of the tasks in the code on hold until it finishes getting back the requested information to you
  • Asynchronous Code (non-blocking)

    • NON-BLOCKING: returns immediately, later on relays back finished work
    • Only is dependent upon at least 1 thread, and your program will still be functioning safely
    • Accessing something as large as an API can result in slow retrieval of the needed data.
    • Your program is can freely run it's other tasks, and on the event loop, it will return to provide the needed info
    • All in all, async stays out of your way, while a sync call will require all your program's attention.

The How

Now that we have the terminology level covered, we can start making our way towards the common patterns or approaches that engineers use when dealing with making async calls in their code.

  • What we have to keep in mind is that our functions will be attempting to return information as soon as you call them, but if we're reaching outwards and depending on an external source to respond...well we can never be sure of the time we'll be waiting. If we try to return information that we don't have, our program will show us one of those nasty undefined calls. So, what are some steps that we can take to resolve this?

1) Callbacks

  • a CALLBACK function is called, when the result of an async operation is ready.
  • in JS, a function is an object
  • also in JS, functions can take other functions as arguments, and can be returned by other functions
  • THIS IS WHY THEY'RE CALLED HIGHER ORDER FUNCTIONS

"Functions that operate on other functions, either by taking them as arguments or by returning them, are called higher-order functions."

Great link to Eloquent JavaScript reading on HOFs

  • For callbacks, we usually pass a second param to our fist function, which will reference a nested function within our first function.



console.log('This is synchronous code, or blocking');

findSong(1, (song) => {
  console.log('OUR SONG', song);
});

console.log('waiting for the first to complete before running')

function findSong(id, callback) {
   //Simulating a code delay below

    setTimeout(() => {
      console.log('Searching for your song...');
      callback({ id: id, song: 'only 4 u' });
    }, 2000);

}

The Downside?

  • The problem, if not seen from my brief explanation above, is that there's a slipper slope in which you all of sudden find yourself inside...CALL BACK HELL.

  • Callback Hell explanation...
    • as you could see from my entangled explanation above, building out ever more complex callbacks can lead you into... well.. hell. It becomes ever more complicated not only to explain your code easily to other engineers, and in turn, also becomes harder for you to understand what it was that your code was doing in the first place.
    • If you do happen do find yourself in this forsaken place, do remember that using helper functions, or Name Functions, is helpful when trying to read through the code. When you integrate them into your nested callback mess, remember that it won't be called but you'll simply be passing a reference to the function that is located somewhere else in your file.

So, let's keep moving on till we find a solution that's at least more manageable.

2) Promises

  • What are those?
    • The technical definition is that a promise, "holds the eventual result of an async operation"
    • when an async operation completes, it will either error out or produce the value that you were trying to work with.
    • Here, you are being "promised", that you will get the result of an async operation.

  • A 'Promise Object' can come in 3 essential states
    • Pending State
      • kicks off async operation
    • Fulfilled State (resolved)
      • this means that the async operation completed successfully.
    • Rejected State (failed)
      • something went wrong while we were trying to execute our operation

Below is an example of a promise instance. It takes a function with two params, resolve and reject.

//somewhere in the code will want to consume this promise object, which will eventually hold our data that is promised to us in this async operation.
const firstPromise = new Promise((resolve, reject) => {
})
  • Resolve and reject are both functions
    • used to send result of async operation to consumer of this promise.
    • when passing a message inside of the reject function, it's best practice to pass an error object
      reject(new Error('You've been rejected!'))
      

  • .catch / .then methods

    • .then
    • good for continuing to do more work with your data that's been returned.
      .then(result => console.log(result))
      
    • .catch
    • important to use to grab any errors that may occur
    • when you create Error instances, they have message properties you can use to view the warning that you may have included for yourself.
      .catch(err => console.log('You hit an error!',err.message))
      

The key take away from the above explanation, is that anywhere you find a callback, in most cases, you should modify that function to return a promise.

Consuming Prompises

Promises are the consumed by chaining .then methods and traversing nested data until we get to the core of the information we were trying to obtain. We can create promise functions that each do one task, and are more easy to alter and read.

Settled Promises

If working with unit testing, you can easily work with a promise that is resolved by using a promise method.

const completed = Promise.resolve()
completed.then(result => console.log(result))

You can also test with errors

const failed = Promise.reject(new Error('your reason'))
// it is best practice to console log only the message property, instead of the entire error object
failed.catch(error => console.log(error.message))

Running them in parallel

 const promiseOne = new Promise((resolve) => {
 
  setTimeOut(()=>{
   console.log('completed!')
   resolve(1)
}, 2000)

})

 const promiseTwo = new Promise((resolve) => {
 
  setTimeOut(()=>{
   console.log('completed!')
   resolve(1)
}, 2000)

})

//all method will return a new promise once all promises in this array are resolved
Promise.all([promiseOne, promiseTwo]).then(result => console.log(result))
  • Promise.all
    • still only a single thread kicking off multiple operations
    • result will be available as an array
    • What if one of these promises fails?
    • if any of our promises are rejected, our result will be failed, even if there are promises which were fulfilled
  • Promise.race
    • used if you want don't want to wait for all promises to complete
    • result will not be an array, but value of first fulfilled promise

3) Async and Await

async function doSomethingCool(){

const artist = await findArtist(1)  //await keyword released thread to do other work
const album = await findAlbums(artist.albumName)
const song = await findSong(album[0])

console.log(song)
}

doSomethingCool() 
// returns a promise that once fulfilled doesn't result in a value.
  • Async and Await
    • built on top of promises
    • syntactical sugar
    • our code may look synchronous, but will look something like chained promises, using .then()
findArtist(1)
.then(artist => getAlbums(albums.artistName))
.then(album => findSong(album[0]))
.then(songs => console.log('songs', songs))
.catch(err => console.log('Error', err.message))

Try-Catch Block

  • In order to catch our error, would have to wrap our code
async function doSomethingCool(){

try {

const artist = await findArtist(1)  //await keyword released thread to do other work
const album = await findAlbums(artist.albumName)
const song = await findSong(album[0])

console.log(song)
} catch (err) {
  console.log('Error'), err.message
}


}

doSomethingCool() 

TLDR;

Using promises, or async/await to be more abstract, allows our code to keep moving forward and frees up our single thread to take on other tasks. Then once our promise has been resolved, we can use that information with a .then() method to traverse the data or a .catch() method to take a look at how we can approach our bug with a steady head on our shoulders. And although callback/higher-order functions have their benefits, it's best to avoid plummeting into 'callback hell'. Good luck!

Top comments (0)