Deep Dive into JavaScript's Asynchronous Mechanism via setTimeout

Published: 2017-05-23

Problem background

In one development task, I needed to implement the following pie-chart animation using canvas. Because I didn't fully understand JavaScript's asynchronous mechanisms in the runtime environment, I encountered a tricky problem I couldn't solve. After discussing it with colleagues I finally had an epiphany. The root cause was the classic JS timer-asynchrony issue. After resolving it, I read a lot of material and summarized practical experience. Now I present a deeper analysis of the asynchronous mechanisms in the JS runtime environment.

The image above shows the final desired effect: each sector should be drawn simultaneously and form a closed circle.Click here to view the code listing. The problem I ran into earlier was that I didn’t extract myLoop as a separate function; instead I wrote all the logic, including the timers, directly inside the for loop. Although the calculations for sector angles and sentinel variables were correct, the circle never closed, which was very frustrating.I want to use this issue to highlight the importance of understanding asynchronous mechanisms in the JS runtime environment, and you don’t need to worry about the canvas drawing implementation. The point is that understanding asynchrony affects the correctness of business logic, not just theoretical interview questions. As for why putting the timer logic inside a function makes it work while writing it directly in the for loop fails, the detailed analysis below will make this clear.


Deep dive into asynchrony

For a deeper look at asynchrony, I will provide as thorough and accurate an analysis as my current knowledge allows. You can further read a blog that captures a debate between two respected experts. One is popular tech blogger Ruan Yifeng, the other is Node pioneer Pu Ling. I have followed both for a long time. This discussion happened some time ago; here I provide only aarticle linkthat includes many of Pu Ling’s annotations in Ruan’s post. Reading it will be highly beneficial and may prompt broader reflections beyond pure technical details.

Synchronous vs asynchronous

First, clarify the two concepts of synchronous and asynchronous.

f1()
f2()

For JavaScript execution, the runtime supports two modes: synchronous execution and asynchronous execution. For the two functions above, synchronous execution means calling f1, waiting for its result, and then calling f2. Asynchronous execution means calling f1 and obtaining the expected result through other operations (for example, network I/O or disk I/O). While those operations run, the program can continue and call f2 without waiting for f1’s result.

Most scripting and programming languages use synchronous programming, which is generally easier for developers to reason about. Why does JS frequently use asynchronous programming? The answer traces back to JS’s original host environment—the browser.

Because JS in browsers manipulates the DOM, it must run single-threaded to guarantee DOM safety (for example, preventing one thread from deleting a DOM node another thread is using). Single-threaded synchronous code that performs long-running operations would make the browser unresponsive and degrade user experience. If long-running tasks like AJAX requests are executed asynchronously, the client rendering won’t be blocked by those tasks.

On the server side, JS’s asynchronous model is even more important because the environment is single-threaded. If all concurrent requests were handled synchronously, responses would be extremely slow and server performance would fall off a cliff. Asynchronous patterns are necessary to handle massive concurrent workloads, unlike Java or PHP which often rely on multithreading. In today’s high-concurrency world, this becomes an advantage for JS, helping Node quickly rise to prominence as a great solution for I/O-bound applications.

Mechanisms for implementing asynchrony

Before discussing how asynchrony is implemented, we need to distinguish two concepts: JavaScript’sexecution engineandruntime environment. We often say Google’s V8 is the JavaScript execution engine; Safari’s JavaScriptCore and Firefox’s SpiderMonkey are also engines. Browsers and Node are JavaScript runtimes. The engine implements the ECMAScript standard, while the runtime implements the concrete asynchronous mechanisms. So when we talk about JS asynchrony today, we meanthe asynchronous mechanisms of the JS runtime, which are unrelated to engines like V8 and are primarily implemented by browser vendors.

There are several ways to implement asynchrony: the Event Loop (which we will detail), polling, and events. Polling is like repeatedly asking a waiter at the counter whether your food is ready after you pay. Events are like paying and then waiting while the waiter notifies you when the food is ready. Most runtimes implement asynchrony via an Event Loop, so the following focuses on the Event Loop.

Event Loop

The Event Loop works as illustrated below. When a program starts, memory is divided into heap and stack. The stack contains the memory needed for the main thread’s execution logic; we abstractly call this the execution stack. Code on the stack calls various Web APIs—DOM operations, AJAX requests, timers, etc. These operations generate events, which are associated with handlers (the callbacks registered) and placed in the callback queue (event queue) in a queue structure. After the execution stack finishes, the main thread reads the callback queue and executes callbacks in order, then starts the next event loop, clearing newly generated event callbacks. Thus,code on the execution stack always runs before items in the callback queue.

Image adapted from Philip Roberts’ talk"Help, I’m stuck in an event-loop"

Callbacks from setTimeout() and setInterval() are typical examples of the Event Loop mechanism. Similarly, when the execution stack finishes, the event loop repeatedly checks system time against preset points; when a preset time is reached it generates a timeout event and places it in the callback queue for the next event loop. In practice, the execution stack may take a long time, so callbacks produced by setTimeout() might not run exactly at the expected time; JS timers cannot guarantee precise timing. Understanding these characteristics lets us optimize at the programming level to keep timer callbacks close to the intended schedule. Since setTimeout() and setInterval() are essentially the same in principle, the examples below analyze asynchronous behavior using setTimeout().

Asynchronous programming

I understand asynchronous programming as implementing overall flow control at the application level on top of the asynchronous mechanisms provided by the JS runtime. Practically, you can write asynchronous code using callbacks like those in setTimeout(), or use event-driven publish/subscribe patterns, or use the unified Promise API introduced in ES6, or try the newer Async/Await in ES7, or use flow-control libraries like Step from the Node community. This section clarifies the concept of asynchronous programming; I won’t delve into specific usages here.


Example analysis

In this section I’ll present several examples to analyze—please compare them with the theory above to understand JS synchronous and asynchronous behavior. We’ll start with a classic JS asynchronous interview question and then go deeper.

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(new Date, i);
    }, 1000);
}
 
console.log(new Date, i);java

The execution result of the code above should be: first immediately print a 5, and then after 1 second print five 5s simultaneously. When the program starts it runs synchronous code on the execution stack and nearly simultaneously creates five timers, then continues executing the synchronous code on line 7. So it first prints 5 to the console, and after 1s the five timers each generate a timeout event and place it in the callback queue. The event loop executes these callbacks in sequence; because of closure behavior each timer’s callback is bound to the for-loop’s i variable, which has become 5, so five 5s are printed.

If we now have a new requirement—print 5 immediately, and after 1s print 0,1,2,3,4 all at once—how should we modify the code above?

// Method 1
for (var i = 0; i < 5; i++) {
    (function(j) {  
        setTimeout(function() {
            console.log(new Date, j);
        }, 1000);
    })(i);
}
 
console.log(new Date, i);

// Method 2
function output (i) {
    setTimeout(function() {
        console.log(new Date, i);
    }, 1000);
};
 
for (var i = 0; i < 5; i++) {
    output(i);  
}
 
console.log(new Date, i);

Both methods above use the same idea: leverage function scope as an independent local scope to preserve a local context and bind it to the setTimeout callback via closures. The first uses an IIFE, the second defines a function and calls it per iteration. At this point you should recognize that the issue mentioned in the introduction is essentially the same problem.

Next, deepen the scenario with a new requirement: print 0 immediately, then print 1,2,3,4 at 1s intervals, and after the loop ends print 5 at roughly the 5-second mark.

Since the 0,1,2,3,4 outputs come from five timers—i.e., five asynchronous operations—this requirement can be abstracted as: after a series of asynchronous operations complete (each iteration creating an async operation), do something else. If you’re familiar with ES6 you probably thought of Promise.

const tasks = []; // store Promises for async operations here
const output = (i) => new Promise((resolve) => {
    setTimeout(() => {
        console.log(new Date, i);
        resolve();
    }, 1000 * i);
});
 
// generate all asynchronous operations
for (var i = 0; i < 5; i++) {
    tasks.push(output(i));
}
 
// after async operations complete, output the final i
Promise.all(tasks).then(() => {
    setTimeout(() => {
        console.log(new Date, i);
    }, 1000);
});

If you’re familiar with Async/Await in ES7, you can also try that solution.

// simulate sleep from other languages; in practice this can be any async operation
const sleep = (timeoutMS) => new Promise((resolve) => {
    setTimeout(resolve, timeoutMS);
});
 
(async () => {  // immediately-invoked async function expression
    for (var i = 0; i < 5; i++) {
        await sleep(1000);
        console.log(new Date, i);
    }
 
    await sleep(1000);
    console.log(new Date, i);
})();

Note you must pay attention to browser support for Async/Await. If your browser version isn’t supported, upgrade the browser or use Babel to transpile.

Description for the image inserted here

If you grasp the series of examples above, you’ll gain new insight into the concept of asynchrony in JS. The next example further examines the timing of callback execution in asynchronous code.

let a = new Promise(
  function(resolve, reject) {
    console.log(1)
    setTimeout(() => console.log(2), 0)
    console.log(3)
    console.log(4)
    resolve(true)
  }
)
a.then(v => {
  console.log(8)
})
 
let b = new Promise(
  function() {
    console.log(5)
    setTimeout(() => console.log(6), 0)
  }
)
 
console.log(7)

First, clarify that Promise is an ES6 API standard for asynchronous programming; the Promise constructor body runs synchronously. Therefore the above code executes synchronous parts top to bottom, printing 1,3,4,5,7. Which runs next: the then callback or the setTimeout callbacks? Remember that the former executes before the latter, so the subsequent output order is 8,2,6.This is because an immediately resolved Promise’s then handler runs at the end of the current event loop cycle, similar to Node’s process.nextTick: it triggers the callback at the tail of the current execution stack before the main thread processes the next task queue. setTimeout(fn, 0) instead appends a task to the end of the current task queue, meaning it will run in the next event loop cycle. This is similar to Node’s setImmediate.

Finally, an example of optimizing setInterval. We know setTimeout callbacks are not precisely timed because when a callback is due the execution stack may still be busy and cannot promptly schedule CPU time for the callback. setInterval also has problems: intervals can skip or the actual interval may be shorter than set. These issues are caused by other code occupying long CPU time slices. Consider the following code:

function click() { 
    // code block1... 
    setInterval(function() { 
        // process ... 
    }, 200); 
    // code block2 ...
}

If the code in process takes too long—longer than 400 ms—the JS runtime will skip a timing interval because only one instance of the process code can be present in the callback queue, causing timing inaccuracies.

To avoid this, we can optimize using recursion. Two implementations are provided below, but the first is recommended. The second uses arguments.callee, which ES5 strict mode forbids. When a function must call itself, avoid arguments.callee: give the function expression a name or use a function declaration instead.See the MDN explanation

// Implementation one
setTimeout(function bar (){ 
    // processing
    foo = setTimeout(bar, 1000); 
}, 1000);

// Implementation two
setTimeout(function(){ 
    // processing 
    foo = setTimeout(arguments.callee, interval); 
}, interval);

clearTimeout(foo) // stop the loop

Last updated