38

We are running in a blocking performance issue on a page that we built using Lightning Web Components.

Our page has many LWC components, each calling various apex methods via an imperative method call. The apex methods in turn make callouts to a third-party API.

We found that the page had terrible performance taking 30+ seconds to load even though each of the third-party API calls would only take 1-2 seconds each.

After some investigation, we found this article: https://jsforce.github.io/blog/posts/20150620-lightning-boxcarred-action-behavior.html which seems to explain our issue: Lightning framework automatically bundles our Apex calls into one and runs each method in the bundle sequentially (instead of in parallel), which leads to the terrible performance we are seeing. This bundling is called boxcarring.

In Aura, there is a way to turn off this boxcarring by calling action.setBackground() before calling $A.enqueueAction(action);

How can we achieve the same in LWC? This is pretty much a deal breaker for us so I would say that it is critical to provide this ability in LWC. Or to turn OFF boxcarring altogether in LWC as it destroys performance and does not seem to offer any advantage (as pointed out by the article).

I posted an idea for this, please vote for it if you ran into the same problem: https://success.salesforce.com/ideaView?id=0873A000000CZogQAG

UPDATE: We ended up creating our own service LWC component to handle apex calls. It features a priority queue so that we can specify which calls should be handled first (because they are visible first) as well as a limit on the number of concurrent calls to avoid having too many boxcarred calls taking a long time. This workaround improved performance enough for us until Salesforce can hopefully improve their boxcarring and handle calls in parallel instead of sequentially. Here is the code for our apexService.js:

const MAX_CONCURRENT_CALLS = 6;
const PRIORITY_DELAY = 1000;

let priorityQueue = [];
let ongoingCallCount = 0;

const processQueue = () => {
    if (priorityQueue.length === 0) {
        return;
    }
    //this function is used below in the loop, when the apex promise resolves
    const processCall = (result, callback) => {
        ongoingCallCount--;
        callback(result);
        processQueue();  //this will restart the queue processing in case it was halted because the max number of concurrent calls was reached
    }
    while (priorityQueue.length > 0) {
        if (ongoingCallCount >= MAX_CONCURRENT_CALLS) {
            //we reached the max number of concurrent calls, so abort! When an ongoing call finishes, it will restart the queue processing
            break;
        }
        ongoingCallCount++;
        const item = priorityQueue.shift();
        item.apexPromise(item.params)
            .then(result => {
                processCall(result, item.callback);
            })
            .catch(error => {
                processCall(error, item.handleError);
            });
    }
}

export const enqueueApex = (priority = 1, apexPromise, params, callback, handleError) => {
    const item = { priority: priority, apexPromise: apexPromise, params: params, callback: callback, handleError: handleError };

    //iterate through the priorityQueue to insert our new item before any items of later priority
    let wasInserted = false;
    for (let i = 0; i < priorityQueue.length; i++) {
        if (item.priority < priorityQueue[i].priority) {
            priorityQueue.splice(i, 0, item);
            wasInserted = true;
            break;
        }
    }
    if (!wasInserted) { //if we didn't find any items of later priority in the queue, the new item is added at the end
        priorityQueue.push(item);
    }
    if (priority === 1) {
        processQueue();
    }
    else {
        // introduces a delay that is proportional to the priority
        // eslint-disable-next-line @lwc/lwc/no-async-operation
        setTimeout(processQueue, PRIORITY_DELAY * (priority - 1));
    }
}

This can then be called from other components as such:

enequeueApex(1, apexControllerMethod, paramsToTheApexMethod, 
    result => {
        //do something here with the results from the apex call
    },
    error => {
        //handle error here
    }
);
1

3 Answers 3

15

First--very well constructed question, and good investigation. I was not aware of this issue with boxcarring in LWC. I'm going to focus on a workaround rather than an actual setting, since I'm sure you've searched for that already.

What happens if you put your apex invocations inside setTimeout calls? I know it's needlessly adding time, but you could add small delays like 50 msec or possibly even 0 mSec just to throw it on the stack.

The idea here is that Salesforce Lightning would have no place to gather all the simultaneous calls in one hidden object only to submit them all at once. When the active thread is building the page with your components, it's all happening in one thread. Each imperative call is captured for a subsequent boxcar call. However, if you start stacking calls, I don't see how boxcarring could intervene. The initial thread would run to execution, and then presumably the boxcar thread would be called, and finally your setTimeouts.

I'm very anxious to hear if this approach works.

Update: Mixed results I tried this out and given any number of apex method callouts, this approach un-boxed the first one or two callouts, but then all the rest got boxed up again. This obviously made the biggest difference if the first callout was the longest, but without my code, all of the callouts ALWAYS were serially boxed.

Now, as it turns out delaying the call with the embedded setTimeout didn't cause this effect. It seems that simply calling a separate then-able ("sleeper()") in the Promise handler method was enough to disrupt the boxcarring of at least the first couple of apex callouts, regardless of whether there was an active setTimeout call.

Conclusion: This approach can definitely disrupt the boxcarring of the first two apex callouts, but is probably not useful since all the others remain boxed up. A more reliable solution may be to execute the callouts from Lightning/Javascript rather than via the Apex methods.

Here's the console log when each of the 4 callouts was set to a 1 second delay:
Call 1 Elapsed =1360 
Call 2 Elapsed =1379 
Call 3 Elapsed =2515 
Call 4 Elapsed =2515 
Total Elapsed =2515

Here's the console when with the longest calls starting first:
Call 2 Elapsed =3361 (3 second call)
Call 3 Elapsed =3527 (2 second call)
Call 4 Elapsed =3528 (1 second call)
Call 1 Elapsed =4354 (4 second call)
Total Elapsed =4354

In this best-case example, the shortest 2 calls were boxed up giving us the best possible improvement.

Here's the relevant code:

sleeper(ms) {
    if (this.background === true) {
        console.log('background=true');
        return function (x) {
            return new Promise(resolve => setTimeout(() => resolve(x), ms));
        };
    } else {
        console.log('background=false');
        return Promise.resolve('hello');
    }
}

connectedCallback() {
    console.log(this.startTime);
    Promise.all( [
        Promise.resolve('hello').then(()=> this.sleeper(1)).then(()=> requestWithSleep({sleepSeconds : 4})).then( ()=> console.log(`Call 1 Elapsed =${Date.now() - this.startTime}`)),
        Promise.resolve('hello').then(()=> this.sleeper(1)).then(()=> requestWithSleep({sleepSeconds : 3})).then( ()=> console.log(`Call 2 Elapsed =${Date.now() - this.startTime}`)),
        Promise.resolve('hello').then(()=> this.sleeper(1)).then(()=> requestWithSleep({sleepSeconds : 2})).then( ()=> console.log(`Call 3 Elapsed =${Date.now() - this.startTime}`)),
        Promise.resolve('hello').then(()=> this.sleeper(1)).then(()=> requestWithSleep({sleepSeconds : 1})).then( ()=> console.log(`Call 4 Elapsed =${Date.now() - this.startTime}`)),
    ])
        .catch(error => {
        console.log('error loading page data:');
        console.log(error);
    })
        .finally(() => {
            console.log(`Total Elapsed =${Date.now() - this.startTime}`);
    });

}
13
  • 1
    That sounds like a good plan... keen to hear if this works also. May 28, 2019 at 19:56
  • 1
    Wasn't me. Looks like I forgot to upvote you so at least I can undo it... ;) May 29, 2019 at 7:03
  • 2
    Thank you for your very thorough response and investigation! We ended up doing something similar. We used a timeout to delay and stagger our apex calls to avoid some of the boxcarring. We found that it worked best with a 500ms delay, and it seems we can make 3 calls in each delayed batch before the boxcarring is triggered. This workaround is obviously not ideal so we hope Salesforce will provide a better solution.
    – pdebaty
    May 30, 2019 at 17:20
  • 1
    I need to execute multiple calls to a single function IN PARALLEL with different inputs and collate the values returned each time. I can see this boxcarring totally destroying that necessary concurrency. Do you know if this happens for wired function calls as well? Since each invocation will receive different input parameter values, I'm hoping the caching of wired function calls will stay out of the way!
    – Phil W
    Jun 5, 2019 at 16:38
  • 1
    Hi @DaveS, was wondering how to handle when we have multiple lwc components in App Builder? do we have individuall set delay or app builder framework will handle this for us? Jun 10, 2019 at 20:16
14

A 30 second page load is definitely not good. To fix it I recommend stepping back to consider some browser constraints and design patterns that may help.

Browsers limit the number of simultaneous connections they make to a host and the network overall. Generally it’s 4-6 simultaneous connections per host. You can read more here. For per browser stats, see this site.

These connections are used for Aura Actions, LWC @wire, images, and other assets the app requires. In other words, one quickly runs out of free connections.

Boxcar’ing, which multiplexes multiple requests over a single connection, helps overcome that connection limit. It generally works well but it has its own limits. Eg it serially processes the requests. When you have tens of multi-second requests you run into that limit.

Boxcar’ing or not, one should always consider what constitutes the critical path of their application. Requiring tens of distinct API calls won’t be fast even in a low-latency, high-bandwidth, high-compute browser. A phrase I repeat internally at Salesforce is, The fastest request is the one you don’t make.

There are some patterns you can use to reduce the requests in the critical path. Which you use depends on the use case. Here’s a couple in priority order.

1. Simplify the page

It’s hard knowing what exactly a user needs so one often places everything on the page. A mobile-first design approach forces you to prioritize content because screen real estate is very limited. This same approach should be taken with desktop scenarios. There are many resources online about mobile-first design.

2. Progressive disclosure

Reduce what is in the critical path by not showing non-critical content by default. Use UX patterns like tabsets to “hide” content behind a click and load it lazily. There are many resources online about this pattern.

3. Avoid duplicate requests

Avoid requesting the same content across multiple APIs and ultimately reduce the number of requests. This is often accomplished by requesting the data once from a data manager component and passing it to multiple presentation-only components as props. This approach has other benefits like:

  • Simplifying the logic in the majority of components. This makes understanding them simpler, testing simpler, and enables greater reuse
  • Grouping API use into fewer components so API transaction boundaries are more appropriate. It also groups API logic so loading and error handling is centralized.

4. Reduce requests

Make fewer requests by fetching all required data in one or a few requests. Sometimes it’s as simple as using a different API that fulfills all your needs, other times it requires more extensive analysis and changes.

5. Caching

Use client-side caching to avoid a trip to the server. Use @AuraEnabled(cacheable=true) for the built-in client side cache mechanism. If you’ve extremely complex needs you can always build your own client-side cache to tune things.

There are also several ways to cache things on the server to avoid making callouts. One example is the Lightning Platform Cache.

Conclusion

From your description — page has many components, each calls various apex methods, apex makes callouts that take 1-2 seconds each — I believe all of the above approaches will improve the page load time.

Please share the results of your investigation. Many will benefit from your experience.

Now to address your immediate question about boxcar’ing, it’s an implementation detail for how Aura and LWC boxcar requests. This means the implementation may change at any time; you should not program against it unless you’re comfortable with that. To save you from digging through the code (remember LWC is open source), requests queued in the same microtask are boxcar’ed together. You can work around this by using setTimeout or another call that enqueues across the microtask boundary. But per my note above, the browser has very few simultaneous connections available so it’s trivial to deplete the pool especially in a page composed of many components, let alone those authored by multiple teams and organizations.

1
  • I appreciate the theory behind boxcarring but in practice it doesn't work. Our Community site is extremely slow because LWC bundles the fast requests with the slow ones, thus slowing everything down. At the same time we are nowhere near to exhausting the browser request pool, so the bundle was pointless. Dec 17, 2020 at 7:32
10

I tried investigating effective solution instead of introducing setTimeout as you need certain delay to separate the method from boxcarred method.

Consider below component javascript:


    import { LightningElement } from 'lwc';
    import getMet1 from '@salesforce/apex/poc.getMet1';
    import getMet2 from '@salesforce/apex/poc.getMet2';
    import getMet3 from '@salesforce/apex/poc.getMet3';
    import getMet4 from '@salesforce/apex/poc.getMet4';
    import getMet5 from '@salesforce/apex/poc.getMet5';
    import getMet6 from '@salesforce/apex/poc.getMet6';
    import getMet7 from '@salesforce/apex/poc.getMet7';
    import getMet8 from '@salesforce/apex/poc.getMet8';
    import getMet9 from '@salesforce/apex/poc.getMet9';
    import getMet10 from '@salesforce/apex/poc.getMet10';

    export default class Poc extends LightningElement {
        connectedCallback() {
            this.mets();
        }
        async mets() {
            getMet1().then(data => this.print(data)).catch(err => this.error(err));
            getMet2().then(data => this.print(data)).catch(err => this.error(err));
            getMet3().then(data => this.print(data)).catch(err => this.error(err));
            getMet4().then(data => this.print(data)).catch(err => this.error(err));
            getMet5().then(data => this.print(data)).catch(err => this.error(err));
            getMet6().then(data => this.print(data)).catch(err => this.error(err));
            getMet7().then(data => this.print(data)).catch(err => this.error(err));
            getMet8().then(data => this.print(data)).catch(err => this.error(err));
            getMet9().then(data => this.print(data)).catch(err => this.error(err));
            getMet10().then(data => this.print(data)).catch(err => this.error(err));
        }
        print = data => console.log("print => ", data);
        error = err => console.error("error => ", err);
    }

All the 10 calls here are boxcarred into single apex call and so you will see single log with all the logs for 10 apex methods.

Now if I want to separate the first method and run it separately, we can put it in fake promise. This will not create any delay and still manage to have separate apex call.

export default class Poc extends LightningElement {
    connectedCallback() {
        this.mets();
    }
    async mets() {
        this.setBackground().then(() => getMet1().then(data => this.print(data)).catch(err => this.error(err)));
        getMet2().then(data => this.print(data)).catch(err => this.error(err));
        getMet3().then(data => this.print(data)).catch(err => this.error(err));
        getMet4().then(data => this.print(data)).catch(err => this.error(err));
        getMet5().then(data => this.print(data)).catch(err => this.error(err));
        getMet6().then(data => this.print(data)).catch(err => this.error(err));
        getMet7().then(data => this.print(data)).catch(err => this.error(err));
        getMet8().then(data => this.print(data)).catch(err => this.error(err));
        getMet9().then(data => this.print(data)).catch(err => this.error(err));
        getMet10().then(data => this.print(data)).catch(err => this.error(err));
    }
    print = data => console.log("print => ", data);
    error = err => console.error("error => ", err);
    setBackground = () => new Promise(resolve => resolve("success")); // never rejects
}

Now consider separating 2 calls like below:

export default class Poc extends LightningElement {
    connectedCallback() {
        this.mets();
    }
    async mets() {
        this.setBackground().then(() => getMet1().then(data => this.print(data)).catch(err => this.error(err)));
        getMet2().then(data => this.print(data)).catch(err => this.error(err));
        getMet3().then(data => this.print(data)).catch(err => this.error(err));
        this.setBackground().then(() => getMet4().then(data => this.print(data)).catch(err => this.error(err)));
        getMet5().then(data => this.print(data)).catch(err => this.error(err));
        getMet6().then(data => this.print(data)).catch(err => this.error(err));
        getMet7().then(data => this.print(data)).catch(err => this.error(err));
        getMet8().then(data => this.print(data)).catch(err => this.error(err));
        getMet9().then(data => this.print(data)).catch(err => this.error(err));
        getMet10().then(data => this.print(data)).catch(err => this.error(err));
    }
    print = data => console.log("print => ", data);
    error = err => console.error("error => ", err);
    setBackground = () => new Promise(resolve => resolve("success")); // never rejects
}

This works perfectly fine if we want to set background upto 4 or 5 calls at a time in single transaction. I am running into boxcarring if I want to separate more than 5 calls like below:

export default class Poc extends LightningElement {
    connectedCallback() {
        this.mets();
    }
    async mets() {
        this.setBackground().then(() => getMet1().then(data => this.print(data)).catch(err => this.error(err)));
        this.setBackground().then(() => getMet2().then(data => this.print(data)).catch(err => this.error(err)));
        this.setBackground().then(() => getMet3().then(data => this.print(data)).catch(err => this.error(err)));
        this.setBackground().then(() => getMet5().then(data => this.print(data)).catch(err => this.error(err)));
        this.setBackground().then(() => getMet6().then(data => this.print(data)).catch(err => this.error(err)));
        this.setBackground().then(() => getMet7().then(data => this.print(data)).catch(err => this.error(err)));
        this.setBackground().then(() => getMet8().then(data => this.print(data)).catch(err => this.error(err)));
        this.setBackground().then(() => getMet9().then(data => this.print(data)).catch(err => this.error(err)));
        this.setBackground().then(() => getMet4().then(data => this.print(data)).catch(err => this.error(err)));
        this.setBackground().then(() => getMet10().then(data => this.print(data)).catch(err => this.error(err)));
    }
    print = data => console.log("print => ", data);
    error = err => console.error("error => ", err);
    setBackground = () => new Promise(resolve => resolve("success")); // never rejects
}

Now when we are trying to background all 10 calls, the last 3 or 4 calls gets boxcarred. I think boxcarring logic is somehow readjusting itself with each function call. This has nothing to do with apex according to my investigation.

Conclusion: We can use this logic safely for up to 4 background calls in single transaction. However if anybody finds the exact reason please do share. Thanks!

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .