[SalesForce] How to turn boxcarring OFF for LWC imperative apex method calls

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
    }
);

Best Answer

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}`);
    });

}