How to optimize JavaScript for better main thread performance
How the browser uses the event loop and callstack to coordinate execution of JavaScript on the main thread.
JavaScript execution occurs on the main thread and is one of the significant areas of focus with pagespeed optimization. Client-side JavaScript depends on the browser to coordinate the execution of code.
Depending on how the code is crafted and the instructions set for the browser, JS could potentially block the DOM Construction process, delay rendering, or "clog" the main thread, which can increase the initial page load time.
When a page is loaded, JavaScript is initialized as follows:
- The HTML Parser reads the page's html file and constructs the DOM (
Document
Object). We see the Parse HTML task on the timeline, which represents this process. - When the HTML Parser discovers JavaScript, the browser's JS interpreter reads the code. Functions are added to the browser's call stack (a queue) and variables are added to the browser's heap (used to allocate memory). On the timeline, these operations appears as Evaluate Script and Compile Script tasks.
- Since the DOM is constructed incrementally, JavaScript can be queued or executed before the DOM finishes fully loading.
- The browser's event loop continually checks the call stack for any functions that haven't been executed yet and executes the code in order. These functions appear on the main thread timeline under the Evaluate Script task.
Web APIs and the JavaScript Language
The way browsers handle JavaScript is often not well understood.
Browsers use web APIs to connect code from files to the browser. APIs like Document
provide the JavaScript language with a connection to the HTML and styling of a webpage. For example, the Document
method querySelector
can be used to find a specific HTML node. Additionally, the Window
API can be used to discover information about the browser, like the width and height of the viewport. JavaScript can use the Window
API to perform actions like navigating to different URLs or scrolling to a specific area.
It's important to understand this distinction because browser's provide certain features that we can utilize to optimize JavaScript on the main thread.
Event Loop
The event loop is a browser feature that coordinates the execution of functions. The event loop checks several queues containing functions. Functions are called and then removed from the associated queue when the function exits (finishes running).
For our purposes, the event loop will help us understand the function timing and execution order on the main thread timeline. Furthermore, effective utilization of the event loop will help us unblock the main thread.
Call stack
The call stack is the highest priority queue containing functions that are to be executed on the main thread. It's useful to examine the call stack to trace the order and duration of functions.
Since the browser's main thread is single-threaded, functions from the call stack execute one after the other and not simultaneously (in parallel).
Every time a function is called, it's added to the top of the call stack. Every time the function finishes (exits) it is removed from the call stack and the next function is executed. Functions must finish executing before the next function can run. Poorly optimized functions can cause a bottleneck.
Tracing Functions on the Timeline
On our devtools timeline, we can trace the order and timing of functions by reviewing the rows under the Evaluate Script task.
This call tree enables the tracing of function timing and duration. The call tree represents what is occurring in the call stack.
Examining the main thread in the performance tab, we can see how the following functions execute in the expected order.
first();
function first() {
delay(1000);
}
second();
function second() {
delay(1000);
}
third();
function third() {
delay(1000);
}
These same-scoped functions execute one after the other. The timeline represents this by separating these functions into different columns on the same row.
Since the associated JavaScript was included in the <body>
of the page, the functions finish executing before the DOMContentLoaded
event.
Since functions execute one after the other and occur on the main thread, a long execution time can cause a ripple effect of optimization issues.
Nested Execution on the Timeline
Note: Since functions can't execute simultaneously, nested functions in the call tree on the performance timeline can cause some confusion.first();
function first() {
delay(1000);
second();
}
function second() {
delay(1000);
third();
}
function third() {
delay(1000);
}
Nested functions appear in separate rows below the function they originated from.
Non-blocking Operations
In addition to the call stack, modern browsers manage the execution of certain callback functions. These functions do not immediately block the main thread and are deferred until later (around the time the DOM finishes loading).
There are some operations, like ajax and web requests, events, and timers, that trigger a callback function on the main thread.
For example, setTimeout()
is a timer method provided by the browser. It executes a callback function after a defined duration.
// Execute a callback after two seconds
setTimeout(function someCallback() {
console.log("Executed after a delay")
}, 2000)
This timer does not block the main thread, unlike the delay()
function we made. The timer operation takes place in parallel off the main thread and functions from the callstack continue to execute in parallel.
Once the 2 second timer is finished and the current callstack is empty, the setTimeout
callback someCallback()
is added to a queue containing callbacks and then executed on the main thread.
Due to the fact that the call stack takes the highest priority, a setTimeout
with a timer set to 0 is still executed after the functions in the callstack.
// Added to callstack
first();
function first() {
delay(1000);
}
// Timer set to 0
setTimeout(second, 0);
// Added to callback queue
function second() {
delay(1000);
}
// Added to callstack
third();
function third() {
delay(1000);
}
Function execution in order:
first
third
second
Despite a zero delay and this script being included in the <body>
, the second function executes around the DCL
time.
The first()
and third()
functions block the main thread and are added to the callstack and execute before DOMContentLoaded
.
// Invoke a callback in response to a click event
document.addEventListener("click", doSomethingOnClick)
function doSomethingOnClick() {
console.log("Click Event Callback")
}
Incremental JS Execution and DCL
Consider a scenario where an inline script is inserted above a node (.nodeAfter
) that the JS depends on.
<script>
let nodeAfter = document.querySelector(".nodeAfter");
nodeAfter.innerText = "YOU FOUND ME";
</script>
<h1 class="nodeAfter">You can't find me</h1>
This triggers a console error.
The JS isn't able to find the .nodeAfter
selector because it doesn't exist in the DOM tree by the time the script is executed.
Since the MutationObserver
(see .nodeAfter
and since we know that scripts are evaluated when they are found by the parser, it makes sense that the document.querySelector
method can't find the matching node on the Document
because it actually hasn't been added to the Document
yet.
How can JavaScript find nodes added after the script insert location?
There are may ways to handle this situation, but a common way is to use the DOMContentLoaded
event listener, which fires a callback function after the DOM finishes parsing.
<script>
document.addEventListener("DOMContentLoaded", function() {
let nodeAfter = document.querySelector(".nodeAfter");
nodeAfter.innerText = "You Found Me";
})
</script>
<h1 class="nodeAfter">You can't find me</h1>
Although the DOM parser evaluates the <script>
node before the required HTML exists in the Document
object, the JS function doesn't query the DOM until after the DOM parser finishes constructing the Document
object.
An exceptionally long DOMContentLoaded
time impacts the function called by the event listener. In some cases, this might not be optimal. As a result, it's usually important to optimize the overall DCL time.
Conclusion
- Developers should pay attention to JavaScript execution order in order to identify bottlenecks on the main thread.
- The event loop can be used to differ the execution of code that isn't critical for the initial view of the page. This can free up the main thread to focus on displaying the page.
- JS is executed incrementally, often before the DOM finishes loading. JS execution is sometimes differed so that the full DOM is accessed.