JavaScript optimisation

Ramón Saquete

Written by Ramón Saquete

With users moving from desktop to mobile devices, client-side optimisation of websites has become increasingly important, to the point of being just as important as server-side optimisation. Getting a website to work fast on devices that are generally slower and highly dependent on an –oftentimes– wobbly network signal, which at times is plain bad or suffers from total outage, is not easy. It is, however, necessary, because having a speedier website will allow us to have more satisfied users who will visit us more frequently, and better rankings for mobile search.

In this post I am going to tackle some aspects in which we need to pay better attention to optimising JavaScript executed on the client. First we are going to see how to optimise its download and compilation, and after that, how to optimise its execution so that your site’s pages have better performance. We will measure performance using the definition provided by Google’s RAIL model, which stands for:

  • Response: Interface response in less than 100ms.
  • Animation frames each 16ms, which are 60 FPS or 60 images per second.
  • Idle time: when a user doesn’t interact with the page, work performed in the background should take 50ms or less.
  • Load: the full page must be loaded in 1000ms.

These are the times we must strive to achieve in worst-case scenarios (when the website is executed on an old phone), with little memory and processing resources.

Mobile vs. desktop download
Here’s a comparison between the download of a website on a mobile phone and a desktop device, which we got from the Chrome User Experience report public database. The histogram shows the number of users who obtained each response time. As we can see, on mobile it peaks at 2.5s, and on desktop at 1.5s.

In this post I am going to explain 1) actions that are necessary to optimise download and compilation, and 2) actions that are necessary to optimise the code execution, which is a more technical part, albeit no less important.

Tasks needed to optimise JavaScript download and compilation

Browser caching

Here we have two options. The first one is to use the JavaScript Cache API, which can be done by installing a service worker. The second one is to use the HTTP cache. If we use Cache API, our application would have the option to work offline. If we use the HTTP cache, we must configure it using the Cache-control parameter, with public and max-age values, setting a large cache time, for example, of one year. If we ever want to invalidate this cache, we will have to change its file name.

Compressing with Brotli q11 and Gzip

By compressing the JavaScript code we are reducing the bits transmitted over the network, and thus, the time this transmission takes. However, we must keep in mind that we are increasing the processing time both at a server level, as well as client-side, as the first will have to compress the file, and the latter to decompress it. We can save some time on the compression if we have a cache of compressed files on our server, but the decompression time on the client plus the transmission time of the compressed file can end up taking longer than the transmission of a decompressed file, making the download much slower. This will only happen to very small files and with high transmission speeds. We cannot know the user’s transmission speed, but we can prevent our server from compressing very small files, for example, those smaller than 280 bytes. When it comes to high-speed networks, over 100MB/s, this value should be much larger, but right now we are optimising for those, whose mobile networks have a very low signal, where performance loss is much more pronounced, whereas in speedier networks it will only work slightly slower.

The new compression algorithm, Brotli, improves compression by 17%, as opposed to Gzip. If the Internet browser sends the “br” value within the HTTP header’s accept-encoding parameter it will mean the server can send the file in Brotli format instead of Gzip.

Minifying

This consists in using an automatic tool to remove comments, spaces, and tabs, and to replace variables, making the code much lighter. Minified files must be cached in the server, or be generated already in their minimised form when uploaded, because if the server has to minimise them in each request, it will negatively affect the performance.

Unifying JavaScript code

This optimisation technique has little importance if our website works with HTTPS and HTTP2, because this protocol sends files as if they were just one. However, if our website works with HTTP1.1 or we expect to get many customers with older browsers using this protocol, we need to unify our JavaScript to optimise the download. We mustn’t go overboard and unify all our website code in just one file, though, because if we only send the code the user needs for each page, we can significantly reduce the downloaded bytes. To do this, we will separate the base code that is needed for the entire website, from that, which will be executed on each individual page. This way, we will have two JavaScript files for each page: one with the basic libraries, common to all pages, and another one specific to each page.

Using a tool like webpack we can unify and minimise these two file groups in our development project. Make sure the tool you use to do this generates the so-called “source maps“. These are .map files referred to in the final file’s header, and where the relationship between the files for minified code, unified code and the real source code is established. This way, we will be able to purify our code hassle-free.

Unifying everything in one larger file has the perk of caching the website’s JavaScript code in full in the browser in the first session, meaning the user won’t have to download again the same code in the recurring session. I only recommend this option if byte savings are pretty much insignificant, as opposed to the previous technique, and if we have a low bounce rate.

Marking JavaScript as asynchronous

We must include our JavaScript code in the following manner:

<script async src="/code.js" />

This way, we will prevent the script tag from blocking the DOM building stage on our page.

Avoid using inline JavaScript on our pages

When we use the script tag to inline code on a page, we’re also blocking the DOM build, which gets even worse if we use the document.write() function. In other words, doing this is strictly forbidden:

<script>document.write(«Hello world!»);</script>

Load JavaScript in the page header with async

Before the async tag came into existence, the recommendation was putting all script tags at the end of a page, to avoid blocking its build. This is no longer necessary, as a matter of fact, it is best to keep it up within the <head> tag, so that JavaScript starts to download, gets analysed and compiled as soon as possible, because these phases take the longest time. If we don’t use this attribute, then the JavaScript should be at the end.

Remove unused JavaScript

Here we aren’t only reducing transmission time, but also the time it takes for a browser to analyse and compile the code. To do this, we must keep in mind the following points:

  • If we detect a functionality isn’t being used by users, we can remove it and all the JavaScript code associated to it, which will help the website to download faster. Your users will appreciate it.
  • It’s also possible we might have included by mistake a library that is unnecessary, or that we have libraries, the features of which are already natively available in all browsers, and there is no need to use additional code for it to work faster.
  • Finally, if we want to optimise it to the extreme, regardless of the time it may take, we must remove from our libraries all the code we’re not using. I don’t recommend this, though, because we never know when it might become useful again.

Delay unnecessary JavaScript download:

This should be done with features, which aren’t necessary in the initial painting of a page. These are functionalities, for which users must carry out a specific action before they’re executed. This way, we won’t download and compile JavaScript code, which would delay the viewing of the initial screen. Once the page has finished loading, we can begin to load these features too, so that they become available as soon as the user begins to interact with our page. In its RAIL model, Google recommends to carry out this delayed load in blocks of 50ms, to avoid hampering a user’s interaction with the page. If a user interacts with a feature that has not yet been loaded, we must load it then.

Tasks to optimise JavaScript code execution

Avoid using too much memory

It’s not possible to guess how much memory is too much memory, but we can say that we should always strive to never use more memory than necessary, because we don’t know how much memory the device running our website will have. When the browser’s garbage collector is running, JavaScript execution is stopped, and this happens every time our code requests the browser to reserve new memory. If this happens too frequently, the page will be slow.

In the “Memory” tab in Chrome’s developer tools we can see the memory used by each JavaScript function:

Reserved memory
Memory reserved by a function

Prevent memory leaks

If we have a memory leak in a loop, the page will increasingly reserve more and more memory, using up all the available memory on the device. This will make everything go much slower. It’s a common mistake in image sliders and carrousels.

Chrome allows us to analyse whether our site has memory leaks, with a timeline recording, located in the “Performance” tab of its developer tools:

Memory leak
This is what a memory leak looks like in the “Performance” tab in Google Chrome, where we can see a steady growth of DOM nodes and JS Heap.

Memory leaks usually stem from DOM chunks that are removed from a page, but are still referenced by some variable, which results in the garbage collector’s inability to delete them. It also happens when we don’t understand how variables and closures work in JavaScript.

Detached DOM trees
Detached DOM trees taking up memory because there are variables referring to them.

Use web workers when you need to execute code requiring a lot of execution time

All the current processors are multicore and multithread, but JavaScript –traditionally– is a single threaded language, and while it has timers, they are executed on the same thread, which also runs the interface interaction. Therefore, while JavaScript is running, the user interface is blocked, and if it takes longer than 50ms it will be noticeable. Web workers and service workers bring multithreading to JavaScript, but they don’t provide direct access to the DOM, which means we will have to think about how we can delay the access to it, in order to be able to apply web workers in cases where we have code, which takes over 50ms to run.

Use the Fetch API (AJAX)

The use of Fetch API or AJAX is also a good way to create the perception of a faster loading time, but we mustn’t use it during the initial load, leaving it for subsequent browsing, and making sure it’s indexable. The best way to do this is to use a Universal JavaScript framework.

Prioritise access to local variables

The first thing  JavaScript does is look whether the variable exists locally, and it continues to look for it in higher scopes, the last ones being the global variables. JavaScript accesses local variables faster, because it doesn’t have to look for them in higher scopes. For that reason, storing variables from higher levels inside local variables is a good strategy if we’re going to access them multiple times. We also shouldn’t create new scopes with closures or try and catch statements, when they aren’t necessary.

If you access a DOM element several times, store it in a local variable

Access to DOM is slow. So, if we’re going to read the content of an element multiple times, it’s best to store it in a local variable, so that JavaScript doesn’t have to look for the DOM element each time you want to access its content. But pay attention! If you store a DOM chunk inside a variable you’re later going to remove from the page and not use it again, make sure to assign “null” to the variable in which you stored it, to prevent a memory leak.

Group and minify readings and writings of the DOM and CSSOM

When an Internet browser renders a page, it follows a critical path representation, which has the following steps during the first load:

  1. HTML is received.
  2. DOM starts to build.
  3. While DOM is being built, external resources (CSS and JS) are requested.
  4. CCSOM is built (combination of DOM and CSS).
  5. Representation tree is created (CSSOM parts to be painted).
  6. Based on the representation tree, the geometry of each visible part of the tree on a layer is calculated. This stage is called layout or reflow.
  7. During the final painting stage all the layers from step 6 are processed and get composed one on top of the other, to display the page to the user.
  8. If JavaScript has finished compiling, it is executed (this step can actually happen at any point after step 3, it should be as soon as possible).
  9. If during the previous step JavaScript forces a remake of DOM or CSSOM, we go back several steps, which will run until step 7.

 

Representation tree construction
Representation tree construction

Although browsers enqueue representation tree changes and decide when the repainting should take place, if we have a loop during which we read the DOM and/or CSSOM, and we modify it in the following line, it is possible that the browser will be forced to run reflow or to repaint the page several times, especially if the next reading depends on the previous writing. For that reason, it is recommended:

  • To separate all readings to an independent loop and to make all writings at once with the cssText property in CSSOM or innerHTML in DOM. This way the browser will only have to call repaint once.
  • If readings depend on previous writings, find a way of rewriting the algorithm to change this.
  • If you have no other choice than to apply many changes to a DOM element, take it out of DOM, make the changes, and put it back where it was.
  • In Google Chrome we can analyse what happens in the critical path representation with the Lighthouse tool, contained within the “Audits” or “Performance” tabs, which records everything that happens during page loading.
Lighthouse
This Lighthouse performance analysis of Google’s home page allows us to see which resources are blocking the critical path representation.

Use the requestAnimationFrame(callback) function for animations and effects which are scroll-dependent

The requestAnimationFrame() function helps the function passed as a parameter to prevent a repaint, until the next scheduled one. Besides avoiding unnecessary repaints, it pauses animations whilst the user is browsing a different tab, saving CPU and battery life.

Effects depending on scroll are slower, because the following DOM properties force a reflow (read step 7 from the previous point) when they are accessed:

offsetTop, offsetLeft, offsetWidth, offsetHeight
scrollTop, scrollLeft, scrollWidth, scrollHeight
clientTop, clientLeft, clientWidth, clientHeight
getComputedStyle() (currentStyle en IE)

If besides accessing one of these properties, we paint a banner or menu based on them, which follow you with a scroll or a parallax scroll effect, several layers will be repainted each time we use the scroll, negatively affecting interface response time, possibly resulting in a jumpy scroll instead of a smooth one. For that reason, with these effects, we must store in a global variable the latest position of the scroll, in the onscroll event. Then we use the requestAnimationFrame() function, only if the previous animation has finished running.

If there are many similar events, group them

If you have many buttons, which, when clicked, perform the same action, we can assign an event to the parent element of all 300 buttons, instead of assigning 300 events to each. When a button is clicked, the event “bubbles” up to its parent, and from there we will know which button the user tapped, modifying the behaviour correspondingly.

Careful with events getting triggered several times in a row

Events like onmousemove or onscroll get triggered several times in a row when an action is performed. Make sure to control the associated code, so that it doesn’t run more times than it is necessary, as this is a rather frequent mistake.

Avoid execution of code chains with eval(), Function(), setTimeOut() and setInterval()

Entering code into a literal to be analysed and compiled during the execution of the remaining code is a rather slow process, for instance: eval(c = a + b);. We can always reprogram this to avoid it.

Implement optimisations you would apply in any other programming language

  • Always use algorithms computationally or cyclomatically less demanding for the task in question.
  • Use optimal data structures to achieve the previous point.
  • Rewrite the algorithm to obtain the same result with less calculations.
  • Avoid recursive calls changing the algorithm for an equivalent one which uses a stack.
  • Make high-costing functions and repeating calls throughout different blocks of code store the result for the next call in memory.
  • Store calculations and calls to repeated functions in variables.
  • When going through a loop, store the size of the loop into a variable first, to avoid calculating it again once it’s over in each iteration.
  • Factor and simplify mathematical formulas.
  • Replace calculations not depending on variables by constants, and leave the calculation commented.
  • Use search arrays: they are handy for obtaining a value based on another one, instead of using a switch block.
  • Make conditions to be most likely true in all cases, to take better advantage of the speculative execution, as this way the jump prediction will fail less.
  • Simply boolean expressions with boolean logic rules, or even better, with Karnaugh maps.
  • Use bit operators whenever you can to replace certain operations, as said operators use less processing cycles. Using them requires being familiar with binary arithmetic, for example: with x being the value of a full variable, we can write “y=x>>1;” instead of “y=x/2;” or “y=x&0xFF;” instead of “y=x%256;”.

These are some of my favourite ones, with the first three being the most important, as well as those which require more study and practice. The last ones are micro-optimisations, which are only worth it if you make them while you’re writing the code, or if it’s something highly demanding computation-wise, like a video editor or a video game. However, in these cases it’s best to use WebAssembly instead of JavaScript.

Tools for detecting problems

We’ve already seen a few throughout this post. Out of all these tools, Lighthouse is the easiest to interpret, as it simply gives us a series of points to improve, just as the ones provided by Google PageSpeed Insights or many others, like GTmetrix. In Chrome we can also use –through the “More Tools” option in the main menu–  the task manager, to see the memory and CPU used by each tab. For more technical analyses, we have the dev tools in Firefox and Chrome, where there’s the “Performance” tab, allowing us to analyse more thoroughly the times in each phase, memory leaks, etc. Here’s an example:

Performance tab Chrome Dev Tools
Google Chrome’s Performance analysis allows us to simulate a slower CPU and network, where, amongst other things, we can see the images or frames per second (let’s remember they should be below 16ms) and the phases of the critical path representation with colours: file loading time in blue, scripting in in yellow, representation tree build time (including reflows or layout build) in yellow, and painting time in green. Moreover, we can also see the time it took to paint each frame and how it turned out.

All the above information can be recorded while the page is loading, when we execute an action or simply scroll. Afterwards, we can zoom in on any part of the chart to see it in more detail, and in this case, what takes the longest is the JavaScript execution. We can unfold the Main section and click on slowest scripts. This way, the tool will show us the “Bottom-up” tab, where we can see in detail how JavaScript is affecting each critical path representation, and the “Summary” tab will indicate us with a warning if it detects a JavaScript performance issue. If we click on the file it indicates us, we will be taken to the exact line causing the delay.

Summary tab warning
Warning inside the Summary tab in “Performance”, in Chrome DevTools.

Finally, for even finer analyses, we recommend using the JavaScript Navigation Timing API, which allows us to measure in detail how long each part of our code takes.

Final recommendations

As you can see, JavaScript optimisation is not an easy task, and it requires an elaborate process of analysis and optimisation, which can easily surpass the available budget for the development we initially thought it would require. For that reason, there are many famous websites, as well as plugins and themes for the most common content management systems, presenting the issues I describe here.

If your website suffers from these problems too, try to solve first the ones that would affect your performance the most, and see that your optimisations don’t affect maintainability and quality of the code. That’s exactly why I do not recommend the most extreme optimisation techniques, like removing calls to functions, replacing them by the code they’re calling, loop unrolling, or the use of the same variable for everything, so that it loads from the cache or the processor registers. Such techniques tarnish our code, besides the fact that during JavaScript compilation time, some of them are already applied. So, remember:

Performance is not a requirement that should ever go before ease of error detection and adding functionality.

Other interesting reads:

Ramón Saquete
Autor: Ramón Saquete
Web developer at Human Level Communications online marketing agency. He's an expert in WPO, PHP development and MySQL databases.

Leave a comment

Your email address will not be published. Required fields are marked *