Rabu, 30 April 2025

Asynchronous Javascript

| Rabu, 30 April 2025

What is synchronous javascript?

When talking about javascript, you may have come across a term called the javascript interpreter or javascript engine. If you haven't, the interpreter is basically just your browser and how it reads and evaluates javascript (or node if executing javascript on the server). The interpreter parses (fancy word for "reads") your javascript code in a top down order. Think of this in the same way you would read a book. You start reading (at least in most languages) from left to right and top to bottom.

Consider this code snippet:

console.log('hello')
console.log('world')
console.log('!')

// hello
// world
// !

In javascript, the next line of code will not be allowed to run until the previous line has completed and this is referred to as blocking code. So in our example, the log for 'world' will not run before the log 'hello' because of its order.

What is asynchronous javascript?

Asynchronous javascript is code that can run out of order of the original flow of code.

Consider this snippet:

console.log('hello')

setTimeout(() => {
  console.log('world')
}, 1000)

console.log('!')

// hello
// !
// world

setTimeout is an asynchronous function that takes a function as an argument (a callback) and a time (in milliseconds) that will act as the amount of time to wait until it can execute the callback passed.

Looking at the example, the interpreter starts to parse the code from top to bottom. It first logs hello to the console. It moves on to the next line of code and sees that there is a setTimeout which is asynchronous. It determines that we don't need to wait for this line to finish. It registers this callback in a different queue for execution (beyond the scope of this article), so we can move on to the next line to execute. It then logs the ! to the console. Now, at minimum 1000 milliseconds (1 second) has passed. It can now execute the callback in setTimeout and logs world to the console.

Asynchronous code is non-blocking, meaning that the interpreter doesn't have to wait for the async code to execute for it to move to the next line for processing. Think of it like asking someone on a date when you were in high school (might be showing my age here). You say on a note something along the lines of "Will you go out with me?" and a few boxes that say

  • [] yes ✅
  • [] no ❌
  • [] maybe ❓

You pass it off to that person and and go about your day until they give it back with a reply (and hopefully you didn't get rejected 😬). This is, in effect, an asynchronous operation.

Callbacks

To understand what a callback is, you have to understand that functions can take in arguments. These arguments can be any javascript type including other functions. At first, callbacks can be a bit tedious to understand, but once it clicks... you'll have a much easier time with programming in javascript.

Function declarations and expressions

There are a few ways to declare a function. One is called a function declaration where you use the function keyword.

function doSomething(){}

The other is a function expression where it is declared as a variable with let, var, or const

const doSomething = function(){}
//or
const doSomething = () => {}

Parameters can be added to the function to be used as arguments. To be a parameter is when a function declaration or expression is being created (defined).

// they are **parameters** during function declaration
function add(param1,param2){ return param1 + param2 }

To be an argument is when the function is being invoked (called).

// they are **arguments** when the function is called
let arg1 = 1
let arg2 = 3
add(arg1,arg2) // 4

Think of parameters as local variables to the function. Whatever is passed as an argument becomes the value of the parameter that was defined. Essentially a placeholder until the function is invoked.

Defining a function with a callback

Why is it important to know about function declarations and function expressions? That is where you will understand how a callback is created and how arguments are passed to them.

When you declare (define) your function you can add a callback parameter to the function that can be used.

function doSomething(callback){
  callback('hello world')
}

In the snippet above, we are creating a doSomething function declaration. The parameter (callback) is expected to be a function. Here, we've named it callback but could be any name you decide to give it. In the doSomething function body we are calling the passed callback function with a string value as an argument ('hello world'). Now we can use the doSomething function and pass in a callback function as an argument.

// hello could be changed to anything
doSomething((hello) => {
  console.log(hello) // "hello world"
})

Since the string was passed as an argument to the callback function we can use that value inside of the callback function's body. We simply log the hello argument to the console which logs the string "hello world". The hello variable can be named anything you want to name it. It is important to understand here that the value of that argument, in this context, will always evaluate to the string provided in the doSomething function definition's callback ("hello world").

This understanding in callbacks can help you better understand that when some library documents a callback and has arguments to them, there isn't much "magic" actually happening. Somewhere in the function definition someone wrote code to pass an argument to that callback. That argument (or arguments) will always be the same object, number, boolean, or whatever it may be. Though the values may change, the expected type typically won't.

The Old Ways

At some point during the evolution of development, async code was typically handled through a series of callbacks. This created readability issues especially when you started deeply nesting callbacks for asynchronous operations:

doSomething((value) => {
  doSomethingElse(value, (value2) => {
    doSomethingMore(value2,(endResult) => {
      console.log(endResult)
    })
  })
})

Based on the example above imagine that you had some operation that you had to perform where you couldn't complete some task until multiple other tasks were completed before it. This would force you to nest your functions as in the example. If you have 5-10 tasks that need to be completed and calculations that needed to be performed on some of them before passing them on, this could get pretty messy pretty quickly.

Enter Promises

Promises were introduced in ES6 of javascript in 2015. This simplified async operations by providing a more readable way to handle callbacks using method chaining. Some of the methods for the Promise object are .then(), .catch() and .finally().

If you wanted to define a function as a promise we would need to return a promise from it.

function valueAfter2Seconds() {
  return new Promise((resolve) => {
    setTimeout(() => resolve(10), 2000)
  })
}

In the example, we are defining a function valueAfter2Seconds. In the function body we are calling new Promise and returning it. The promise constructor takes a callback function. The arguments to the callback are resolve and reject. Both are callback functions as well. We are creating a delay of 2 seconds and resolve with a value of 10. Whatever value you would like to resolve gets passed as the argument to the resolve callback. Likewise, if using the reject callback, it will raise an error with the rejected value passed to it.

Now we can see how to get the value from the promise.

valueAfter2Seconds()
  .then(value => console.log(value))
  // 10 

When valueAfter2seconds runs, it immediately returns a promise. The state of that promise during initial execution is pending. The .then method waits for the resolved value from the promise. .then accepts a callback function as its first argument. The callback's argument is the value passed to the resolve callback. Once 2 seconds has passed, the .then function will receive the value and log 10 to the console. In the event more operations need to be done we can return a value from .then and pass it to the next one in the chain

valueAfter2seconds()
  .then(value => {
    console.log(value) // 10
    return value + 5
  })
  .then(newValue => {
    console.log(newValue) // 15
    return newValue - 10
  })
  .then(otherValue => console.log(otherValue)) // 5

A bit of a contrived example, but hopefully it makes the point.

You would typically handle any errors within the .catch method. Since promises are asynchronous, async code is taken out of the original execution context (how code is parsed), the original error cannot be handled in synchronous code. The error will be lost. In the context of node, this would raise an uncaught exception error and cause the server code to crash. This is one of many reasons why handling async errors is important.

Alternatives for promise execution

Some alternatives to consider execution order for async code is to use built-in promise methods like Promise.all, generator functions, or using async/await which was introduced to javascript in 2017.

Normal individual promise handling

Lets assume that we have some tasks to run:

function task1() {
  return new Promise(resolve => {
    setTimeout(() => resolve(1),1000)
  })
}

function task2() {
  return new Promise(resolve => {
    setTimeout(() => resolve(2),500)
  })
}

function task3() {
  return new Promise(resolve => {
    setTimeout(() => resolve(3),2000)
  })
}

and we want to run those in a specific order where each one logs a value in numerical order. The first thought would be to do something like this:

task1().then(value => console.log('task 1', value))
task2().then(value => console.log('task 2', value))
task3().then(value => console.log('task 3', value))
// 'task 2' 2
// 'task 1' 1
// 'task 3' 3

The issue above is that the order in which they are resolved may be different based on the time each are completed. Since the time for task 2 is 0.5 seconds it will log first, then task 1, and last is task 3. While this looks somewhat like it should execute in order, because of these times that the promises resolve, they don't. Let's consider this code instead:

task1()
 .then(value => {
  console.log('task 1', value)
  return task2()
 })
 .then(value => {
  console.log('task 2', value)
  return task3()
 })
 .then(value => {
  console.log('task 3', value)
 })

// "task 1" 1
// "task 2" 2
// "task 3" 3 

A promise waits to be resolved before passing the resolved value to the .then method. A promise can, then, be returned from a .then method and will resolve before being passed down the chain in the next .then method call. Above we are calling task1() and chaining a .then. Once the value is resolved it's passed to the callback where it is logged ("task 1" 1). We then return the promise task2() from the first .then. Once task2() is resolved, its value is passed to the next call of .then and the value is logged to the console ("task 2" 2) and so on.

Promise.all

The promise object comes with a method that allows you to return resolved values in order... Promise.all(). The method takes an array of promises as a function. So we can pass the called functions in an array to the method:

function allPromises() {
  return Promise.all([task1(), task2(), task3()])
}

allPromises()
  .then(values => console.log(values))
  // [1,2,3]

When all promises have been resolved, the promise that is returned from allPromises will be an array of values in the order they were passed in which outputs the order we expect. Even though the order is preserved, task2 will still, technically, resolve before the other two functions. One issue with using this method is that if one of those promises rejects (throws an error) then all promises in the array reject. If you want something where you would like any resolved values and handle the rejected value separately, then Promise.allSettled should be considered.

Using Generators

One alternative to running promises in a specific order without chaining a .then to everything is to use generator functions. They follow the iterator protocol by providing a next method and a done value that indicates if the iterations are complete. To define a generator function, you use the function keyword followed by an asterisk (*).

Generators use a keyword yield that stops execution and yields a value to be returned from the generator. When the last yield expression is reached or a return value is reached, the done value of the generator is flipped from false to true and can no longer be called with the next() method of the generator. When a yield keyword is encountered and the value returned from it, the execution of the generator is stopped while preserving the variable bindings for the next execution (basically a fancy way of saying the inner scoped variables aren't reset or lost, but preserved).

We can start by making a generator function that yield promises:

function* generator() {

  const task_1 = yield task1()
  console.log(task_1)

  const task_2 = yield task2()
  console.log(task_2)

  const task_3 = yield task3()
  console.log(task_3)

  return task_3

}

Here we are defining a generator function named generator for lack of a more creative word. Each async function is yielded providing a place for execution to stop and return a value. When the generator's next method is called it will yield the next value and continue on that pattern until a return is reached or there is nothing left to yield.

Now we can create a function that runs the generator and yields all values in the order they were defined in the generator.

function runGenerator(genFn) {
  const gen = genFn()

  function step(arg) {

    const result = gen.next(arg)
    if(result.done) return result.value

    return Promise.resolve(result.value).then((value) => step(value))
  }

  step()
}

We will create a runGenerator function that takes in a generator reference as an argument. We then store the value to the genFn() call in a variable called gen short for generator. Next we create a function called step inside of the runGenerator function body. The step function will take in an argument arg which will be the resolved value of the yielded promise in the generator.

We create a base case checking if the result.done is true. If it is, the generator is done executing and we can return the value from the final execution of the generator. Otherwise, we resolve a promise with the result.value. Remember that the value is a promise. Once the promise is resolved, it will pass the resolved value to the .then method. For clarity, I am passing in a callback to .then which explicitly calls the step function with the resolved value to give it back to the generator. You could just pass the reference to .then ie: .then(step).

We have to pass the resolved value back to the generator so it can pick up execution with the resolved value of the promise. Otherwise it will log undefined. The step function is executed recursively until the result.done value is true.

We then invoke the step function within the runGenerator function. Since this function is void, we can just let it log the results.

runGenerator(generator)

// 1
// 2
// 3

Async and Await

So why learn about generators? You likely don't see them too often. Generators can solve a host of issues with asynchronous javascript. You could use them for pagination, infinite scrolling, or possibly for server code that might require such a thing. Ultimately async and await are kind of an abstraction around generator functions, yet are much cleaner and simpler to implement. Consider this function:

async function actLikeGenerator() {
  const task_1 = await task1()
  console.log(task_1)

  const task_2 = await task2()
  console.log(task_2)

  const task_3 = await task3()
  console.log(task_3)

  // 1
  // 2
  // 3
}

According to the mdn docs,

Using await pauses the execution of its surrounding async function until the promise is settled (that is, fulfilled or rejected). When execution resumes, the value of the await expression becomes that of the fulfilled promise.

What does that mean? When you define an async function with the async keyword. You can use the await keyword inside the function body. The await keyword acts similar to the yield keyword in a generator function. It will pause the execution of the function until the promise is resolved with a value. Once the promise is resolved, the value is unwrapped (like using a .then).

So in the example above, when the first await keyword is encountered, the function pauses execution and waits for task1() to resolve. Once task1() resolves, the value is assigned to the variable task_1 and moves to the next line and logs 1 to the console. After logging, we move to the next line and encounter another await keyword. We pause again until task2() is resolved. We, again, continue execution and assign the resolved value to task_2 and log it to the console (2) and so on. This provides a much cleaner way of reasoning through asynchronous code.

Conclusion

By understanding how functions are defined will help you better understand how callbacks are defined and used. This isn't just useful for asynchronous javascript, but programming in general. The introduction of promises helped to make asynchronous operations in javascript more manageable, while adding async/await helped to make it more readable. By understanding generators, you can better visualize, roughly, how async/await works under the hood. Let me know your thoughts in the comments!


Related Posts

Tidak ada komentar:

Posting Komentar