Please support this book: buy it (PDF, EPUB, MOBI) or donate

24. Asynchronous programming (background)

This chapter explains foundations of asynchronous programming in JavaScript. It provides background knowledge for the next chapter on ES6 Promises.

24.1 The JavaScript call stack

When a function f calls a function g, g needs to know where to return to (inside f) after it is done. This information is usually managed with a stack, the call stack. Let’s look at an example.

  1. function h(z) {
  2. // Print stack trace
  3. console.log(new Error().stack); // (A)
  4. }
  5. function g(y) {
  6. h(y + 1); // (B)
  7. }
  8. function f(x) {
  9. g(x + 1); // (C)
  10. }
  11. f(3); // (D)
  12. return; // (E)

Initially, when the program above is started, the call stack is empty. After the function call f(3) in line D, the stack has one entry:

  • Location in global scope After the function call g(x + 1) in line C, the stack has two entries:

  • Location in f

  • Location in global scope After the function call h(y + 1) in line B, the stack has three entries:

  • Location in g

  • Location in f
  • Location in global scope The stack trace printed in line A shows you what the call stack looks like:
  1. Error
  2. at h (stack_trace.js:2:17)
  3. at g (stack_trace.js:6:5)
  4. at f (stack_trace.js:9:5)
  5. at <global> (stack_trace.js:11:1)

Next, each of the functions terminates and each time the top entry is removed from the stack. After function f is done, we are back in global scope and the call stack is empty. In line E we return and the stack is empty, which means that the program terminates.

24.2 The browser event loop

Simplifyingly, each browser tab runs (in) a single process: the event loop. This loop executes browser-related things (so-called tasks) that it is fed via a task queue. Examples of tasks are:

  • Parsing HTML
  • Executing JavaScript code in script elements
  • Reacting to user input (mouse clicks, key presses, etc.)
  • Processing the result of an asynchronous network request Items 2–4 are tasks that run JavaScript code, via the engine built into the browser. They terminate when the code terminates. Then the next task from the queue can be executed. The following diagram (inspired by a slide by Philip Roberts [1]) gives an overview of how all these mechanisms are connected.

24. Asynchronous programming (background) - 图1

The event loop is surrounded by other processes running in parallel to it (timers, input handling, etc.). These processes communicate with it by adding tasks to its queue.

24.2.1 Timers

Browsers have timers. setTimeout() creates a timer, waits until it fires and then adds a task to the queue. It has the signature:

  1. setTimeout(callback, ms)

After ms milliseconds, callback is added to the task queue. It is important to note that ms only specifies when the callback is added, not when it actually executed. That may happen much later, especially if the event loop is blocked (as demonstrated later in this chapter).

setTimeout() with ms set to zero is a commonly used work-around to add something to the task queue right away. However, some browsers do not allow ms to be below a minimum (4 ms in Firefox); they set it to that minimum if it is.

24.2.2 Displaying DOM changes

For most DOM changes (especially those involving a re-layout), the display isn’t updated right away. “Layout happens off a refresh tick every 16ms” (@bz_moz) and must be given a chance to run via the event loop.

There are ways to coordinate frequent DOM updates with the browser, to avoid clashing with its layout rhythm. Consult the documentation on requestAnimationFrame() for details.

24.2.3 Run-to-completion semantics

JavaScript has so-called run-to-completion semantics: The current task is always finished before the next task is executed. That means that each task has complete control over all current state and doesn’t have to worry about concurrent modification.

Let’s look at an example:

  1. setTimeout(function () { // (A)
  2. console.log('Second');
  3. }, 0);
  4. console.log('First'); // (B)

The function starting in line A is added to the task queue immediately, but only executed after the current piece of code is done (in particular line B!). That means that this code’s output will always be:

  1. First
  2. Second

24.2.4 Blocking the event loop

As we have seen, each tab (in some browers, the complete browser) is managed by a single process – both the user interface and all other computations. That means that you can freeze the user interface by performing a long-running computation in that process. The following code demonstrates that.

  1. <a id="block" href="">Block for 5 seconds</a>
  2. <p>
  3. <button>This is a button</button>
  4. <div id="statusMessage"></div>
  5. <script>
  6. document.getElementById('block')
  7. .addEventListener('click', onClick);
  8.  
  9. function onClick(event) {
  10. event.preventDefault();
  11.  
  12. setStatusMessage('Blocking...');
  13.  
  14. // Call setTimeout(), so that browser has time to display
  15. // status message
  16. setTimeout(function () {
  17. sleep(5000);
  18. setStatusMessage('Done');
  19. }, 0);
  20. }
  21. function setStatusMessage(msg) {
  22. document.getElementById('statusMessage').textContent = msg;
  23. }
  24. function sleep(milliseconds) {
  25. var start = Date.now();
  26. while ((Date.now() - start) < milliseconds);
  27. }
  28. </script>

Whenever the link at the beginning is clicked, the function onClick() is triggered. It uses the – synchronous – sleep() function to block the event loop for five seconds. During those seconds, the user interface doesn’t work. For example, you can’t click the “Simple button”.

24.2.5 Avoiding blocking

You avoid blocking the event loop in two ways:

First, you don’t perform long-running computations in the main process, you move them to a different process. This can be achieved via the Worker API.

Second, you don’t (synchronously) wait for the results of a long-running computation (your own algorithm in a Worker process, a network request, etc.), you carry on with the event loop and let the computation notify you when it is finished. In fact, you usually don’t even have a choice in browsers and have to do things this way. For example, there is no built-in way to sleep synchronously (like the previously implemented sleep()). Instead, setTimeout() lets you sleep asynchronously.

The next section explains techniques for waiting asynchronously for results.

24.3 Receiving results asynchronously

Two common patterns for receiving results asynchronously are: events and callbacks.

24.3.1 Asynchronous results via events

In this pattern for asynchronously receiving results, you create an object for each request and register event handlers with it: one for a successful computation, another one for handling errors. The following code shows how that works with the XMLHttpRequest API:

  1. var req = new XMLHttpRequest();
  2. req.open('GET', url);
  3.  
  4. req.onload = function () {
  5. if (req.status == 200) {
  6. processData(req.response);
  7. } else {
  8. console.log('ERROR', req.statusText);
  9. }
  10. };
  11.  
  12. req.onerror = function () {
  13. console.log('Network Error');
  14. };
  15.  
  16. req.send(); // Add request to task queue

Note that the last line doesn’t actually perform the request, it adds it to the task queue. Therefore, you could also call that method right after open(), before setting up onload and onerror. Things would work the same, due to JavaScript’s run-to-completion semantics.

24.3.1.1 Implicit requests

The browser API IndexedDB has a slightly peculiar style of event handling:

  1. var openRequest = indexedDB.open('test', 1);
  2.  
  3. openRequest.onsuccess = function (event) {
  4. console.log('Success!');
  5. var db = event.target.result;
  6. };
  7.  
  8. openRequest.onerror = function (error) {
  9. console.log(error);
  10. };

You first create a request object, to which you add event listeners that are notified of results. However, you don’t need to explicitly queue the request, that is done by open(). It is executed after the current task is finished. That is why you can (and in fact must) register event handlers after calling open().

If you are used to multi-threaded programming languages, this style of handling requests probably looks strange, as if it may be prone to race conditions. But, due to run to completion, things are always safe.

24.3.1.2 Events don’t work well for single results

This style of handling asynchronously computed results is OK if you receive results multiple times. If, however, there is only a single result then the verbosity becomes a problem. For that use case, callbacks have become popular.

24.3.2 Asynchronous results via callbacks

If you handle asynchronous results via callbacks, you pass callback functions as trailing parameters to asynchronous function or method calls.

The following is an example in Node.js. We read the contents of a text file via an asynchronous call to fs.readFile():

  1. // Node.js
  2. fs.readFile('myfile.txt', { encoding: 'utf8' },
  3. function (error, text) { // (A)
  4. if (error) {
  5. // ...
  6. }
  7. console.log(text);
  8. });

If readFile() is successful, the callback in line A receives a result via the parameter text. If it isn’t, the callback gets an error (often an instance of Error or a sub-constructor) via its first parameter.

The same code in classic functional programming style would look like this:

  1. // Functional
  2. readFileFunctional('myfile.txt', { encoding: 'utf8' },
  3. function (text) { // success
  4. console.log(text);
  5. },
  6. function (error) { // failure
  7. // ...
  8. });

24.3.3 Continuation-passing style

The programming style of using callbacks (especially in the functional manner shown previously) is also called continuation-passing style (CPS), because the next step (the continuation) is explicitly passed as a parameter. This gives an invoked function more control over what happens next and when.

The following code illustrates CPS:

  1. console.log('A');
  2. identity('B', function step2(result2) {
  3. console.log(result2);
  4. identity('C', function step3(result3) {
  5. console.log(result3);
  6. });
  7. console.log('D');
  8. });
  9. console.log('E');
  10.  
  11. // Output: A E B D C
  12.  
  13. function identity(input, callback) {
  14. setTimeout(function () {
  15. callback(input);
  16. }, 0);
  17. }

For each step, the control flow of the program continues inside the callback. This leads to nested functions, which are sometimes referred to as callback hell. However, you can often avoid nesting, because JavaScript’s function declarations are hoisted (their definitions are evaluated at the beginning of their scope). That means that you can call ahead and invoke functions defined later in the program. The following code uses hoisting to flatten the previous example.

  1. console.log('A');
  2. identity('B', step2);
  3. function step2(result2) {
  4. // The program continues here
  5. console.log(result2);
  6. identity('C', step3);
  7. console.log('D');
  8. }
  9. function step3(result3) {
  10. console.log(result3);
  11. }
  12. console.log('E');

More information on CPS is given in [3].

24.3.4 Composing code in CPS

In normal JavaScript style, you compose pieces of code via:

  • Putting them one after another. This is blindingly obvious, but it’s good to remind ourselves that concatenating code in normal style is sequential composition.
  • Array methods such as map(), filter() and forEach()
  • Loops such as for and while The library Async.js provides combinators to let you do similar things in CPS, with Node.js-style callbacks. It is used in the following example to load the contents of three files, whose names are stored in an Array.
  1. var async = require('async');
  2.  
  3. var fileNames = [ 'foo.txt', 'bar.txt', 'baz.txt' ];
  4. async.map(fileNames,
  5. function (fileName, callback) {
  6. fs.readFile(fileName, { encoding: 'utf8' }, callback);
  7. },
  8. // Process the result
  9. function (error, textArray) {
  10. if (error) {
  11. console.log(error);
  12. return;
  13. }
  14. console.log('TEXTS:\n' + textArray.join('\n----\n'));
  15. });

24.3.5 Pros and cons of callbacks

Using callbacks results in a radically different programming style, CPS. The main advantage of CPS is that its basic mechanisms are easy to understand. But there are also disadvantages:

  • Error handling becomes more complicated: There are now two ways in which errors are reported – via callbacks and via exceptions. You have to be careful to combine both properly.
  • Less elegant signatures: In synchronous functions, there is a clear separation of concerns between input (parameters) and output (function result). In asynchronous functions that use callbacks, these concerns are mixed: the function result doesn’t matter and some parameters are used for input, others for output.
  • Composition is more complicated: Because the concern “output” shows up in the parameters, it is more complicated to compose code via combinators. Callbacks in Node.js style have three disadvantages (compared to those in a functional style):

  • The if statement for error handling adds verbosity.

  • Reusing error handlers is harder.
  • Providing a default error handler is also harder. A default error handler is useful if you make a function call and don’t want to write your own handler. It could also be used by a function if a caller doesn’t specify a handler.

24.4 Looking ahead

The next chapter covers Promises and the ES6 Promise API. Promises are more complicated under the hood than callbacks. In exchange, they bring several significant advantages and eliminate most of the aforementioned cons of callbacks.

24.5 Further reading

[1] “Help, I’m stuck in an event-loop” by Philip Roberts (video).

[2] “Event loops” in the HTML Specification.

[3] “Asynchronous programming and continuation-passing style in JavaScript” by Axel Rauschmayer.