Lately, I've been tinkering around with my blog to see if I could get a better score in Google Web Vitals. The "Avoid Excessive DOM Size" alert attracted my attention. The details showed that my code fields were to blame. I use Google Prettify as it is lightweight and does a pretty good highlighting job, but... it renders way too many span elements. Let's see if we can make it spit out less.

  1. Intro
  2. Avoid Excessive DOM size?
    1. Identification
  3. Analysis
    1. C# code
    2. Python code
    3. JavaScript code
    4. C# a larger class
    5. Conclusion
  4. Improving Google Prettify rendering
    1. Which code elements to style?
    2. Improving Google Prettify HTML
    3. Caveat: Catastrophic Backtracking
  5. Say When: Lazy Initialization
  6. Final thoughts
  7. Further reading
  8. Improvements
  9. Comments

Avoid Excessive DOM size?

Lighthouse flags pages with DOM trees that have:

  • more than 1.500 nodes in total.
  • a depth greater than 32 nodes.
  • a parent node with more than 60 child nodes.

A large DOM tree might impact runtime performance, especially when styles need to be calculated.

These are some clear restrictions I can work with.

Identification

You can paste the following JavaScript into the console debugger to see if and where you are hitting these limits:

var getParents = function* (elem) {
  for ( ; elem && elem !== document; elem = elem.parentNode )
    yield elem;
};

var all = [... document.querySelectorAll("*")];
var p60 = all.filter(x => x.childElementCount > 60);
var de = all.map(e => ({ "e": e, "l" : [... getParents(e)].length - 1 })).sort((a,b)=> b.l-a.l)[0];

console.log("# DOM elements:", all.length);
console.log("# Parent nodes with 60+ child nodes:", p60.length);
console.log("  - Elements:", p60);
console.log("# Top element depth:", de.l);
console.log("  - Element: ", de.e);

This gives you a nice overview without having to run Lighthouse over and over again.

Analysis

Let's look at some C#, Python, and JavaScript code. I took the same code sample, to see how much DOM nodes we can save if we improve the code that is rendered by Google Prettify. I will also look to a larger C# code sample that's been giving warnings.

C# code

Here is an example of a simple documented C# class:

/// <summary>
/// Simple calculator that supports add and subtract.
/// </summary>
public class Calculator
{
    /// <summary>
    /// Adds both operands together.
    /// </summary>
    /// <param name="a">Operand a.</param>
    /// <param name="b">Operand b.</param>
    /// <returns>The result.</returns>
    public int Add(int a, int b)
    {
        return a + b;
    }

    /// <summary>
    /// Subtracts operand b from operand a.
    /// </summary>
    /// <param name="a">Operand a.</param>
    /// <param name="b">Operand b.</param>
    /// <returns>The result.</returns>
    public int Subtract(int a, int b)
    {
        return a - b;
    }
}

When you look at the code that is generated, you'll see that 87 child elements are created in our DOM.

What if we would not style the .pun-elements and the .pln-elements with only white-spaces? The highlighting could still look the same - as we can set the color of the code field to color them. When we remove these elements from the DOM, we only have 38 elements left.

We can go even further by merging nodes that have the same CSS class! Remember: we're only styling to highlight, we're not styling for semantic meaning. When we merge spans with the same class together we end up with 23 elements.

We went from 87 to 23, so that's a win of 64 nodes!

Python code

Let's look at the code in Python:

class Calculator:
    """Simple calculator that supports add and subtract."""

    def add(self, a:int, b:int ) -> int:
        """Adds both operands together.

        Args:
            a (int): Operand a.
            b (int): Operand b.

        Returns:
            int: The result.
        """
        return a+b


    def subtract(self, a:int, b:int) -> int:
        """Subtracts operand b from a.

        Args:
            a (int): Operand a.
            b (int): Operand b.

        Returns:
            int: The result.
        """
        return a-b

This code generates 59 child elements. When we remove the .pun and .pln-elements we have 27 elements left. When we merge same-class spans together we have 26. We've saved 33 nodes in our DOM.

JavaScript code

The same code example in JavaScript looks like this:

/**
 * Simple calculator that supports add and subtract.
 * 
 * @class Calculator
 */
class Calculator {

    /**
     * Adds both operands together.
     * 
     * @param {any} a Operand a.
     * @param {any} b Operand b.
     * @returns The result.
     * 
     * @memberOf Calculator
     */
    add(a, b) {
        return a + b;
    }

    /**
     * Subtracts operand b from a.
     * 
     * @param {any} a Operand a.
     * @param {any} b Operand b.
     * @returns The result.
     * 
     * @memberOf Calculator
     */
    subtract(a, b) {
        return a - b;
    }
}

When you look at the code that is generated, you'll see that 46 child elements are created in our DOM. What happens when you remove .pun and .pln elements? We only have 17 elements left. There are no elements that have the same class that could be merged together. We just saved 30 elements.

C# a larger class

Let's look at one more piece of C# code. This code actually came up as a problem in Lighthouse. Here is a ToString() implementation for Roman Numerals:

public override string ToString()
{
    return ToString(RomanNumeralNotation.Substractive);
}

public string ToString(RomanNumeralNotation notation)
{
    if (Number == 0)
    {
        return NULLA;
    }

    // check notation for right set of characters
    string[] numerals;
    switch (notation)
    {
        case RomanNumeralNotation.Additive:
            numerals = ADDITIVE_NOTATION;
            break;
        default:
            numerals = SUBTRACTIVE_NOTATION;
            break;
    }

    var resultRomanNumeral = "";

    // start with the M and iterate back
    var position = 0;

    // substract till the number is 0
    var value = Number;

    do
    {
        var numeral = numerals[position];
        var numeralValue = VALUES[numeral];

        // check if the value is in the number
        if (value >= numeralValue)
        {
            // substract from the value
            value -= numeralValue;

            // add the numeral to the string
            resultRomanNumeral += numeral;

            // subtractive numeral?
            // advance position because IVIV does not exist
            bool isSubtractiveNumeral = numeral.Length > 1;
            if(isSubtractiveNumeral)
            {
                position++;
            }

            continue;
        }

        position++;
    }
    while (value != 0);

    return resultRomanNumeral;
}

It generates 218 nodes. When we remove and merge elements we have 75 nodes, which is still too much! Remember, we are trying to avoid 60 or more child elements. So we need to wrap every 60 nodes with a <span></span>-element. This will introduce an extra node, but it makes sure that the code element will pass the test. We'll end up with 76 nodes in total. We still saved 142 nodes.

Conclusion

By processing the code that is generated by Google Prettify we can save many nodes in our DOM. How many nodes are saved is heavily dependent upon your actual code. We should:

  • Remove .pun-elements, as they don't convey extra meaning.
  • Remove .pln-elements with only space, as spaces generally have no highlighting.
  • Merge span-elements with the same class that succeed each other.
  • Wrap elements to avoid the 60 or more child elements rule.

Improving Google Prettify rendering

Let's see what we can do to improve the way Google Prettify renders.

Which code elements to style?

There are two ways code elements are used on my blogs: inline and in blocks. Blocks of code are surrendered with pre-elements in my blogs (as is the default in WordPress). By default, the Google Prettify loader will replace all code elements. I don't need my inline code elements highlighted, that's why parser only replaces code elements that have a pre-element as its parent:

document.addEventListener("DOMContentLoaded", function () {
    for (let cde of [...document.getElementsByTagName("code")]) {
        if (cde.parentNode.nodeName != "PRE")
            continue;

        // add code here
        cde.innerHTML = PR.prettyPrintOne(cde.innerHTML);
    }
});

Note: to make things as fast as possible I'm adding the script to the bottom of my body. That's why the DomContentLoaded works for me. You might need something else in your specific use-case.

The main benefit of highlighting is to provide meaning in a context. Most inline fields do not need that extra clarification as the phrase itself provides the context. Highlighting might actually negatively impact the readability.

Improving Google Prettify HTML

I've used some regular expressions to parse the result of the prettyPrintOne method and generate less DOM nodes before replacing the HTML of each code field:

if (cde.parentNode.nodeName != "PRE")
    continue;

cde.className = "prettyprint prettyprinted";
let txt = PR.prettyPrintOne(cde.innerHTML);

if (!cde.parentElement.classList.contains("do-not-parse")) {

    // remove pun classes
    txt = txt.replace(/<span class="pun">(.*?)</span>/gms, "$1");

    // remove pln clases with only whitespace elements
    txt = txt.replace(/<span class="pln">(s*)</span>/gms, "$1");

    // smush same style classes together
    txt = txt.replace(/<span class="([^"]+)">([^<>]+?)</span>((s*)<span class="1">([^<>]+?)</span>s*)+/gms, function (str) {

        // match class of the span
        var spanRx = /^<span class="([^"]+)">/g
        var spanCls = spanRx.exec(str)[1];

        // remove all opening and closing spans
        str = str.replace(/<span class="[^"]+">/g, '');
        str = str.replace(/</span>/g, '');

        return '<span class="' + spanCls + '">' + str + '</span>';
    });


    // wrap 60 elements with single span
    txt = txt.replace(/((<s[^<]*<[^<]*){60})/gms, "<span>$1</span>");
}

cde.innerHTML = txt;

Note: I took some shortcuts, as I don't use line numbers. I also added the do-not-parse class, so it skips the optimizations, although that will probably only be handy for this blog.

Caveat: Catastrophic Backtracking

This is more a note on "what not to do". You might look at the regular expression /((<s[^<]*<[^<]*){60})/gms, and think: "How did he come up with that!?". It turns out that this regex gave me the best performance. Let me walk you through the thought process:

  1. I first thought I need to match all span-elements and succeeding text nodes, select 60 of them and wrap them in a span.
  2. So I created the following regex ((<span[^>]+>.*?</span>[^<]*){60}). Which on paper should do what I want.
  3. When I tested this, I did not use 60, but 10. And when I deployed this to production, my Chrome tried to blow the roof of the CPU! 😳
  4. When I tested the code on regex101.com and replaced the 10 with 60, I got a catastrophic backtracking error label. 🤔
    Note: I used the PCRE (PHP) flavor, which gives more feedback than the JavaScript version.
  5. So, I need something more CPU friendly. I came up with ((<span[^>]+>[^<]*?</span>[^<]*){60}), which takes 3.744 steps. It does not try to blow up my CPU.
  6. But when you think about it, it is enough to look for the opening of a tag <, scan for the closing tag (which also starts with <) and then take until you find a new opening tag (also marked by <).
  7. So that results in ((<[^<]*<[^<]*){60}) which takes 1.880 steps. 😁
  8. Further optimization results in the addition of the letter s: ((<s[^<]*<[^<]*){60}), which takes only 1.397 steps. 💪

Say When: Lazy Initialization

We've started out by loading on the DOMContentLoaded, but is this the smart thing to do? Most of my code appears under the fold: you'll need to scroll to see it. We can leverage this, by lazy initializing our code. Remember: syntax highlighting is for humans, Google and other search engines can't do anything with the highlighting as the span elements don't convey any semantic meaning.

There are some situations I need to process the code fields:

  • Wen a viewport is large enough so that my first code field is visible when the page is loaded.
  • When a hash is present so the user lands somewhere in the middle of my blog post. I'm 90 % sure that a code field is visible.
  • When a user scrolls.

Let's do two things:

  1. Process the code on DOMContentLoaded if the first code-field was visible (due to scrolling or a large viewport).
  2. Process the code on the first window.scroll, as the change is high that our user will see our field and we would like it to be a styled one.

This results in the following code:

function processCodeFields() {

    // it's nice, but don't run it twice
    if (window.CODE_INITIALIZED) return;
    window.CODE_INITIALIZED = true;

    // add code prettify code here
}

document.addEventListener("DOMContentLoaded", function () {
    let codes = document.querySelectorAll("article pre code");
    if (codes.length > 0) {
        let visible = codes[0].getBoundingClientRect().y < window.scrollY + window.innerHeight;
        if (visible) {
            processCodeFields();
        }
    }
});

function firstScroll() {
    window.removeEventListener("scroll", firstScroll, { passive: true });
    processCodeFields();
}

window.addEventListener("scroll", firstScroll, { passive: true });

Another win is that this code does not use any CPU until the user interacts with your blog. This will give you a better Lighthouse score.

Final thoughts

Fewer elements in your DOM should lead to better performance. We've seen how Google Prettify generates lots of elements that are not strictly needed. With some extra JavaScript, the number of nodes can easily be decreased. Things can be improved further by lazy initializing your syntax highlighting.

Further reading

While working on this article I've found some interesting reads:

Improvements

2020-07-06 Added the Lazy Initialization section.