Alpine: Performance issues when used with lots of DOM elements

Created on 11 Jun 2020  路  24Comments  路  Source: alpinejs/alpine

Update after some digging of different people (thanks!) into the issue:
Just by embedding Alpine.js (without even using it) the performance gets slow when using "many" DOM elements. "Many" depends on the computer's performance - in my case the rendering took 11s (with Alpine) vs. 3s (without Alpine) for 3,000 DOM elements on an old i5-3210M I had available.
This is caused by Alpine using querySelectorAll and iterating over all elements.
A workaround is possible by using deferLoadingAlpine.


I run into strange issues when using Alpine with bpmn-js (https://github.com/bpmn-io/bpmn-js). Just by embedding Alpine the performance for loading a tree model goes down from 2s to 8s (on a slow computer).

I created two examples:
https://nanuc.io/alpine-performance/with-alpine.html
https://nanuc.io/alpine-performance/without-alpine.html

The only difference is that at the first example Alpine is embedded (see below).

I know that this is a very specific use case that probably no one else on this world has encountered yet... I'm reporting it anyway because it might be the case that Alpine affects the performance of other libraries too.

Unfortunately the only solution for me seems to replace Alpine by something else for now because my knowledge to dive into this and solve this is far too little.

Sebastian

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/assets/diagram-js.css">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/assets/bpmn-font/css/bpmn.css">

    <!-- THE NEXT LINE KILLS THE PERFORMANCE -->
    <script src="https://cdn.jsdelivr.net/gh/alpinejs/[email protected]/dist/alpine.min.js" defer></script>
</head>
<body>
<div>
    <div id="canvas" style="height: 100vh"></div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.2/axios.min.js" integrity="sha256-T/f7Sju1ZfNNfBh7skWn0idlCBcI3RwdLSS4/I7NQKQ=" crossorigin="anonymous"></script>
<script src="https://unpkg.com/[email protected]/dist/bpmn-modeler.production.min.js"></script>


<script>
    modeler = new BpmnJS({
        container: '#canvas',
    });

    axios.get('example.xml')
        .then(function (response) {
            modeler.importXML(response.data);
        });
</script>
</body>
</html>

Most helpful comment

Seems to me a svg element on the page has around 1333 child nodes which cause const rootEls = (el || document).querySelectorAll('[x-data]'); to check all of them to find out if any node with x-data attribute exists. I also thought the PR i made while ago (#395) could be helpful at this point to ignore those nodes which have a huge amount of child nodes but i guess it wont.

@calebporzio at #395

  • The issue mentions x-ignore saving resources, and this is only true for x-ignore used within an Alpine component if it's used outside of one, that DOM will never get walked anyways

I think Alpine.start() will still check all those nodes to initialize nodes with the x-data attribute. #395 did have a check as

        const rootEls = document.querySelectorAll('[x-data]');

        rootEls.forEach(rootEl => {
            // Detect ignore element and return if we hit one
            if (rootEl.parentElement.closest('[x-ignore]:not([x-ignore=false])')) return

            callback(rootEl)
        })

if node has an x-ignore attribute or not but i think querySelectAll will still check all those nodes to figure out if there is any x-data attribute. So in this case x-ignore wouldn't help. I think we should consider querySelectAll and maybe an alternative way to it?

All 24 comments

Hi @SebastianSchoeps,

Have you tried placing the Alpine script in the <head> tag of your document?

Thanks @ryangjchandler for your quick reply!

I updated the initial post and moved Alpine to the end of <head> (where it belongs) - however, performance is still the same.

It might be worth looking at the waterfall in DevTools to see which part is taking the longest.

It's not a request issue, the responses are back quickly. However, if Alpine is used, the diagram takes much longer to render.
When looking at the DevTools performance, I now saw that there is Alpine's "discoverUninitializedComponents" running for >4s. Is there any way to have Alpine ignore a div or something?

There's no way of ignoring an element at the moment. There have been various discussions about an x-ignore or similar directive, which I would be fully up for.

OK, I found https://github.com/alpinejs/alpine/pull/395. @calebporzio writes "The issue mentions x-ignore saving resources, and this is only true for x-ignore used within an Alpine component if it's used outside of one, that DOM will never get walked anyways" - isn't that what I experience right now? It's not in an Alpine component but still gets walked.
He continues "I'd be open to an x-pre tag that skips walking a subsection of a components DOM in the future but I don't see an immediate need" - here! I do! :-)

Yeah, I hadn't noticed in your case you don't actually have any components at all.

I'm curious now. It could be that Alpine's querySelector is causing the performance issue, since the DOM would be quite large so searching it might take a little while.

I didn't notice much of a difference on my MacBook between each example, but on slower machines I'm not sure.

Yes, I didn't see it on my Mac neither, but users complained. Now on an old Windows laptop I have 3s (without Alpine) vs. 11s (with Alpine). The problem is that I completely rebuilt an existing version of the software with Livewire/Alpine - and now it feels much slower for the user. Kinda hard to convince them that "everything is better now".

Might well be the querySelector you mentioned - when doing the DevTools performance check it's in the tree at the top.

image

can you defer alpine after the graph has shown? would it help?

<script>
  window.deferLoadingAlpine = function (callback) {
    let modeler = new BpmnJS({
        container: '#canvas',
    });

    axios.get('https://nanuc.io/alpine-performance/example.xml') 
        .then(function (response) {
            modeler.importXML(response.data);
            callback();
        });
  }
</script>

can you defer alpine after the graph has shown? would it help?

<script>
  window.deferLoadingAlpine = function (callback) {
    let modeler = new BpmnJS({
        container: '#canvas',
    });

    axios.get('https://nanuc.io/alpine-performance/example.xml') 
        .then(function (response) {
            modeler.importXML(response.data);
            callback();
        });
  }
</script>

I don't think will make a difference, since that querySelectorAll will still run either way.

yeah but it will run after (hopefully) you get the response and show the graph so you won't perceive the slowness

Be careful using the deferLoadingAlpine, since Livewire depends on this too.

Actually for this particular use case using deferLoadingAlpine seems to avoid the issue! The performance is now the same.

I will try this in the actual (and much more complex) application (with around 30 different Livewire components being active at the same time...) and see what happens there...

My only other and very ugly idea is to outsource the tree diagram to an iFrame - it sends cold shivers up and down my spine just saying it...

I'm also seeing this, and I think closing #395 was a mistake. Having to walk a large DOM tree when you know there are no Alpine components is wasteful.

I'm also seeing this, and I think closing #395 was a mistake. Having to walk a large DOM tree when you know there are no Alpine components is wasteful.

Sure, but the problem isn't Alpine walking the DOM. It will only do that when x-data elements are found.

The performance problems appear to be coming from querySelectorAll, which is of course out of Alpine's control really.

Seems to me a svg element on the page has around 1333 child nodes which cause const rootEls = (el || document).querySelectorAll('[x-data]'); to check all of them to find out if any node with x-data attribute exists. I also thought the PR i made while ago (#395) could be helpful at this point to ignore those nodes which have a huge amount of child nodes but i guess it wont.

@calebporzio at #395

  • The issue mentions x-ignore saving resources, and this is only true for x-ignore used within an Alpine component if it's used outside of one, that DOM will never get walked anyways

I think Alpine.start() will still check all those nodes to initialize nodes with the x-data attribute. #395 did have a check as

        const rootEls = document.querySelectorAll('[x-data]');

        rootEls.forEach(rootEl => {
            // Detect ignore element and return if we hit one
            if (rootEl.parentElement.closest('[x-ignore]:not([x-ignore=false])')) return

            callback(rootEl)
        })

if node has an x-ignore attribute or not but i think querySelectAll will still check all those nodes to figure out if there is any x-data attribute. So in this case x-ignore wouldn't help. I think we should consider querySelectAll and maybe an alternative way to it?

Actually for this particular use case using deferLoadingAlpine seems to avoid the issue! The performance is now the same.

I will try this in the actual (and much more complex) application (with around 30 different Livewire components being active at the same time...) and see what happens there...

My only other and very ugly idea is to outsource the tree diagram to an iFrame - it sends cold shivers up and down my spine just saying it...

Just a quick feedback from my side: deferLoadingAlpine has too many side effects in my "real" application. I will open the tree diagram in a popup window. That totally kills the SPA feeling, but I see no other possibility currently.

I would really like if an approach would be found to have Alpine ignore certain parts of the applications completely. Unfortunately my JavaScript skills are way to little to dive into this myself (that's why I went for Livewire and therefore Alpine initially) - but I'm absolutely willing to test.

@SebastianSchoeps Like I mentioned, Livewire also relies on the deferLoadingAlpine, it's also not a publicly documented API.

@ryangjchandler Yes - I just thought it was worth a shot.

@ryangjchandler Yes - I just thought it was worth a shot.

You could work around it still, using the deferLoadingAlpine method. This is exactly how Spruce works: https://github.com/ryangjchandler/spruce/blob/e844442214eb9aee61f37cc11c0ac21104a3d1c7/src/index.js#L102

Simply check for an existing deferLoadingAlpine and call that after running your own piece of code.

What's the latest on this?

Have you been able to use deferLoadingAlpine successfully?

In order to not overwrite other libraries attempting to defer Alpine initialisation, you could do (as per Ryan's Spruce example)

<script>
  const deferrer = window.deferLoadingAlpine || function (callback) { callback() }
  window.deferLoadingAlpine = function (callback) {
    let modeler = new BpmnJS({
        container: '#canvas',
    });

    axios.get('https://nanuc.io/alpine-performance/example.xml') 
        .then(function (response) {
            modeler.importXML(response.data);
            deferrer(callback);
        });
  }
</script>

If the issue is with querySelectorAll's performance, we could maybe add a guard clause that checks whether there are any [x-data] elements on the page before querySelectorAll-ing them... then again, if you don't have Alpine components why include the Alpine script tag.

Have this issue when switching pages between a large page and small swupjs the large page takes an age 11s+ compared to 3ms without Alpinejs tried the deferLoadingAlpine without any success anyone had any other luck ?

If the issue is with querySelectorAll's performance, we could maybe add a guard clause that checks whether there are any [x-data] elements on the page before querySelectorAll-ing them... then again, if you don't have Alpine components why include the Alpine script tag.

What if you have a single Alpine element? Would a slow-device-user loading a page with +3k dom elements have to wait for querySelectorAll run it's course to eventually get to the Alpine element?

@sebastianschoeps Two questions for you:

  1. In which browser are you seeing this performance problem? It sounds like the old client machine (with which you're working) may also be running an ancient browser which can't do attribute queries quickly. It's possible that a more recent browser may do the trick: see this blog post on attribute query performance (which, frustratingly, also neglects to specify the browser used).

  2. What are the side effects that prevent you from using the deferLoadingAlpine method? It may be that deferring is the right idea, but the callback (Alpine.start()) is being called at the wrong time, probably too early, and is interfering with other JS in the page.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

haipham picture haipham  路  4Comments

zaydek picture zaydek  路  3Comments

imliam picture imliam  路  5Comments

BernhardBaumrock picture BernhardBaumrock  路  3Comments

allmarkedup picture allmarkedup  路  4Comments